@qvac/sdk 0.17.1 → 0.18.1

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 (156) 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 +168 -82
  48. package/dist/schemas/load-model.d.ts +3678 -323
  49. package/dist/schemas/load-model.js +21 -3
  50. package/dist/schemas/public.d.ts +3 -0
  51. package/dist/schemas/public.js +6 -0
  52. package/dist/schemas/sdk-config.d.ts +60 -24
  53. package/dist/schemas/sdk-errors-server.d.ts +1 -0
  54. package/dist/schemas/sdk-errors-server.js +5 -0
  55. package/dist/schemas/text-to-speech.d.ts +1086 -110
  56. package/dist/schemas/text-to-speech.js +194 -4
  57. package/dist/schemas/tools.d.ts +0 -16
  58. package/dist/schemas/tools.js +0 -15
  59. package/dist/schemas/transcription-config.d.ts +2 -0
  60. package/dist/schemas/transcription-config.js +10 -4
  61. package/dist/schemas/transcription.d.ts +3 -0
  62. package/dist/schemas/vla.d.ts +83 -0
  63. package/dist/schemas/vla.js +105 -2
  64. package/dist/scripts/link-workspace-inference.d.ts +2 -0
  65. package/dist/scripts/link-workspace-inference.js +31 -0
  66. package/dist/server/bare/ops/cancel.d.ts +1 -1
  67. package/dist/server/bare/ops/cancel.js +1 -1
  68. package/dist/server/bare/ops/kv-cache-retention.js +11 -0
  69. package/dist/server/bare/ops/kv-cache-utils.d.ts +1 -1
  70. package/dist/server/bare/ops/kv-cache-utils.js +40 -12
  71. package/dist/server/bare/ops/transcribe.d.ts +2 -2
  72. package/dist/server/bare/ops/transcribe.js +54 -48
  73. package/dist/server/bare/ops/translate-stats.d.ts +5 -0
  74. package/dist/server/bare/ops/translate-stats.js +11 -0
  75. package/dist/server/bare/ops/translate.js +66 -38
  76. package/dist/server/bare/ops/unload-model.js +30 -24
  77. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.d.ts +20 -0
  78. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.js +153 -0
  79. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-stream.js +52 -1
  80. package/dist/server/bare/plugins/audiogen-ggml/plugin.d.ts +28 -0
  81. package/dist/server/bare/plugins/ggml-ocr/plugin.js +3 -1
  82. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.d.ts +3 -0
  83. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.js +9 -0
  84. package/dist/server/bare/plugins/ggml-vla/plugin.d.ts +37 -0
  85. package/dist/server/bare/plugins/ggml-vla/plugin.js +21 -4
  86. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-completion-stream.js +16 -15
  87. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.d.ts +1 -1
  88. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.js +2 -2
  89. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.d.ts +6 -0
  90. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.js +10 -0
  91. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.d.ts +1 -0
  92. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.js +131 -123
  93. package/dist/server/bare/plugins/llamacpp-completion/ops/finetune.js +110 -20
  94. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.d.ts +49 -23
  95. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.js +308 -143
  96. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.d.ts +1 -0
  97. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.js +2 -2
  98. package/dist/server/bare/plugins/llamacpp-completion/plugin.d.ts +6 -6
  99. package/dist/server/bare/plugins/llamacpp-completion/plugin.js +55 -36
  100. package/dist/server/bare/plugins/llamacpp-completion/transform.js +1 -7
  101. package/dist/server/bare/plugins/llamacpp-embedding/plugin.js +2 -3
  102. package/dist/server/bare/plugins/nmtcpp-translation/plugin.js +6 -5
  103. package/dist/server/bare/plugins/parakeet-transcription/plugin.js +27 -16
  104. package/dist/server/bare/plugins/tts-ggml/ops/parler-options.js +23 -3
  105. package/dist/server/bare/plugins/tts-ggml/plugin.d.ts +302 -4
  106. package/dist/server/bare/plugins/tts-ggml/plugin.js +131 -0
  107. package/dist/server/bare/plugins/whispercpp-transcription/plugin.js +48 -39
  108. package/dist/server/bare/registry/model-registry.d.ts +1 -0
  109. package/dist/server/bare/runtime/request-registry-singleton.js +29 -15
  110. package/dist/server/bare/runtime/request-registry.d.ts +27 -0
  111. package/dist/server/bare/runtime/request-registry.js +246 -76
  112. package/dist/server/bare/types/addon-responses.d.ts +4 -12
  113. package/dist/server/rpc/handlers/load-model/handler.js +2 -2
  114. package/dist/server/rpc/handlers/load-model/resolve-session.d.ts +1 -1
  115. package/dist/server/rpc/handlers/load-model/resolve-session.js +3 -3
  116. package/dist/server/rpc/handlers/load-model/resolve.d.ts +12 -2
  117. package/dist/server/rpc/handlers/load-model/resolve.js +90 -12
  118. package/dist/server/utils/audio/decoder.d.ts +37 -1
  119. package/dist/server/utils/audio/decoder.js +81 -51
  120. package/dist/server/utils/config-transform.d.ts +3 -0
  121. package/dist/server/utils/config-transform.js +6 -0
  122. package/dist/server/utils/index.d.ts +0 -1
  123. package/dist/server/utils/index.js +0 -1
  124. package/dist/server/utils/shard-utils.d.ts +1 -0
  125. package/dist/server/utils/shard-utils.js +14 -0
  126. package/dist/server/utils/tool-integration.d.ts +3 -10
  127. package/dist/server/utils/tool-integration.js +3 -12
  128. package/dist/utils/errors-server.d.ts +3 -0
  129. package/dist/utils/errors-server.js +5 -0
  130. package/package.json +17 -14
  131. package/dist/examples/llamacpp-dynamic-tools.d.ts +0 -2
  132. package/dist/examples/llamacpp-dynamic-tools.js +0 -152
  133. package/dist/models/update-models/codegen.d.ts +0 -3
  134. package/dist/models/update-models/codegen.js +0 -196
  135. package/dist/models/update-models/companions.d.ts +0 -30
  136. package/dist/models/update-models/companions.js +0 -296
  137. package/dist/models/update-models/history.d.ts +0 -24
  138. package/dist/models/update-models/history.js +0 -107
  139. package/dist/models/update-models/index.d.ts +0 -2
  140. package/dist/models/update-models/index.js +0 -123
  141. package/dist/models/update-models/naming.d.ts +0 -4
  142. package/dist/models/update-models/naming.js +0 -396
  143. package/dist/models/update-models/processing.d.ts +0 -8
  144. package/dist/models/update-models/processing.js +0 -65
  145. package/dist/models/update-models/registry.d.ts +0 -5
  146. package/dist/models/update-models/registry.js +0 -87
  147. package/dist/models/update-models/shards.d.ts +0 -4
  148. package/dist/models/update-models/shards.js +0 -57
  149. package/dist/models/update-models/types.d.ts +0 -88
  150. package/dist/models/update-models/types.js +0 -1
  151. package/dist/models/update-models/utils.d.ts +0 -3
  152. package/dist/models/update-models/utils.js +0 -21
  153. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.d.ts +0 -23
  154. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.js +0 -30
  155. package/dist/server/utils/expand-gguf-shards.d.ts +0 -3
  156. package/dist/server/utils/expand-gguf-shards.js +0 -22
@@ -1,10 +1,9 @@
1
- import { TOOLS_MODE } from '../../../../../schemas/tools.js';
2
1
  import { logCacheDisabled, logCacheInit, logCacheSave, logMessagesToAddon } from '../../../../../server/bare/plugins/llamacpp-completion/ops/cache-logger.js';
3
2
  import { extractSystemPrompt, getCurrentCacheInfo } from '../../../../../server/bare/ops/kv-cache-utils.js';
4
3
  import { getModel, getModelConfig } from '../../../../../server/bare/registry/model-registry.js';
5
4
  import { decideCachedHistorySlice, shouldCommitCachedTurn } from '../../../../../server/bare/plugins/llamacpp-completion/ops/kv-cache-state.js';
6
5
  import { createKvCacheSession, generateConfigHash } from '../../../../../server/bare/plugins/llamacpp-completion/ops/kv-cache-session.js';
7
- import { appendToolsToHistory, detectToolDialect, prependToolsToHistory } from '../../../../../server/utils/tool-integration.js';
6
+ import { detectToolDialect, prependToolsToHistory } from '../../../../../server/utils/tool-integration.js';
8
7
  import { parseToolCalls } from '../../../../../server/utils/tools/index.js';
9
8
  import { getResponseFormatJsonSchema } from '../../../../../server/utils/response-format.js';
10
9
  import { buildAutoCacheSaveHistory } from '../../../../../server/utils/index.js';
@@ -56,29 +55,60 @@ export function transformMessages(messages) {
56
55
  }
57
56
  return transformed;
58
57
  }
59
- async function initSystemPromptCache(model, cachePathToUse, systemPromptToUse, cacheKey, tools) {
58
+ /**
59
+ * Prime the cache prefix with the system prompt only.
60
+ *
61
+ * Tools deliberately stay out of the prefix: a message list with no user turn
62
+ * is not a renderable conversation for every chat template (Qwen3.5 raises
63
+ * `No user query found in messages.` because it anchors its tool block on the
64
+ * last user query), and a template failure degrades the whole render rather
65
+ * than just the tool block. Tools travel with the turn instead.
66
+ */
67
+ async function initSystemPromptCache(model, cachePathToUse, systemPromptToUse, cacheKey, onResponse) {
60
68
  const primeMessages = [{ role: 'system', content: systemPromptToUse }];
61
- let toolCount = 0;
62
- if (tools && tools.length > 0) {
63
- const transformedTools = transformMessages(tools);
64
- primeMessages.push(...transformedTools);
65
- toolCount = tools.length;
66
- }
67
- logCacheInit(cacheKey, systemPromptToUse, toolCount);
69
+ logCacheInit(cacheKey, systemPromptToUse);
68
70
  logMessagesToAddon(primeMessages, 'CACHE_INIT');
69
71
  const primeResponse = await runModel(model, primeMessages, {
70
72
  cacheKey: cachePathToUse,
71
73
  saveCacheToDisk: true,
72
74
  prefill: true
73
75
  });
76
+ // Register the prime so an abort during a cold-cache prefill cancels it.
77
+ onResponse?.(primeResponse);
74
78
  await primeResponse.await();
75
79
  }
80
+ /**
81
+ * Attach the tool block ahead of a turn payload, mirroring the no-kv-cache
82
+ * path (`prependToolsToHistory`).
83
+ */
84
+ function withToolBlock(messages, toolBlock) {
85
+ if (toolBlock.length === 0)
86
+ return messages;
87
+ return [...toolBlock, ...messages];
88
+ }
89
+ /**
90
+ * Whether a payload carrying this tool block actually gets it in front of the
91
+ * model. Qwen-family templates anchor their tool section on the last user
92
+ * query and raise without one, and the addon answers that by re-rendering the
93
+ * turn with tools stripped — a usable prompt with no tools in it. Recording
94
+ * such a turn as "the block is cached now" would suppress the block for the
95
+ * rest of the session, so require a user message before believing it landed.
96
+ */
97
+ function rendersToolBlock(messages, toolBlock) {
98
+ if (toolBlock.length === 0)
99
+ return false;
100
+ return messages.some((msg) => msg.role === 'user');
101
+ }
76
102
  /**
77
103
  * Pick the messages that need to reach the model for the next turn.
78
104
  *
79
- * Static mode (no `tools` argument):
80
- * - Cache miss: send the whole history minus the system message (which
81
- * was primed during cache init).
105
+ * Tools are never baked into the primed prefix — a prefix with no user turn is
106
+ * not a renderable conversation for every template — so they travel with a
107
+ * turn instead.
108
+ *
109
+ * - Empty history: nothing to slice; send whatever non-system messages
110
+ * exist. (The call site always reports the cache as existing, so this
111
+ * is the only way into this branch.)
82
112
  * - Cache hit with a recorded `savedCount`: send only the unsaved tail
83
113
  * (`history.slice(savedCount)`), so a multi-message turn (e.g. a
84
114
  * consumer pushing both an assistant transcript and a follow-up user
@@ -86,66 +116,46 @@ async function initSystemPromptCache(model, cachePathToUse, systemPromptToUse, c
86
116
  * - Cache hit with a stale/missing `savedCount`: fall back to the full
87
117
  * non-system history. The session is told (`dropStaleSavedCount`) so
88
118
  * the bad boundary doesn't propagate into the next turn.
89
- *
90
- * Dynamic mode (`tools` argument set):
91
- * - The addon anchors the tool block after the last user message and
92
- * trims tools + the assistant's tool-call output from the cache once
93
- * the chain resolves. After that trim, the cache only holds messages
94
- * up to the last user turn, so the SDK has to ship the right slice
95
- * plus the (possibly new) tool set:
96
- * * tool-chain continuation (last role is "tool"): send the trailing
97
- * consecutive tool messages, no tool block — tools are still
98
- * anchored in the cache from the previous round.
99
- * * new user turn after a chain (prev role is "assistant"): send
100
- * [assistant, user] so the model sees its own final reply before
101
- * the new prompt, then re-anchor the tool block.
102
- * * otherwise: send just the last message + tool block.
119
+ * - The tool block travels only with the turn that writes it into the
120
+ * cache; see `skipToolBlock` below.
103
121
  */
104
- function prepareMessagesForCache(session, turn, cacheExists, history, tools) {
105
- const addTools = tools?.length ? transformMessages(tools) : [];
106
- const dynamic = addTools.length > 0;
122
+ function prepareMessagesForCache(session, turn, cacheExists, history, tools, toolBlockEvictable = false) {
123
+ const toolBlock = tools?.length ? transformMessages(tools) : [];
107
124
  if (!(cacheExists && history.length > 0)) {
108
125
  const historyWithoutSystem = history.filter((msg) => msg.role !== 'system');
109
- return [...transformMessages(historyWithoutSystem), ...addTools];
126
+ return {
127
+ messages: withToolBlock(transformMessages(historyWithoutSystem), toolBlock),
128
+ toolBlockCached: rendersToolBlock(historyWithoutSystem, toolBlock)
129
+ };
110
130
  }
111
- if (!dynamic) {
112
- // Static path — slice from the turn's `savedCount` so callers can
113
- // stage multiple messages between completions. `decideCachedHistorySlice`
114
- // also guards against the QVAC-17780 stale-count regression: if the
115
- // saved boundary would slice the history down to an empty payload
116
- // (e.g. after a cancelled mid-decode), it falls back to the full
117
- // non-system history and signals the caller to drop the bad entry.
118
- // The session owns the entry; `dropStaleSavedCount` clears it
119
- // without touching the on-disk file (the file is still trustworthy
120
- // — only the boundary count is wrong).
121
- const { messages, clearStaleCount } = decideCachedHistorySlice(turn.savedCount, cacheExists, history);
122
- if (clearStaleCount) {
123
- session.dropStaleSavedCount(turn);
124
- }
125
- return transformMessages(messages);
126
- }
127
- // Dynamic path. The addon trimmed tools after the previous round, so the
128
- // cache no longer holds the saved-count we'd rely on for slicing — pick
129
- // the right fragment based on the role of the last history message.
130
- const lastMsg = history[history.length - 1];
131
- if (lastMsg.role === 'tool') {
132
- const trailingTools = [];
133
- for (let i = history.length - 1; i >= 0; i--) {
134
- const msg = history[i];
135
- if (msg.role !== 'tool')
136
- break;
137
- trailingTools.unshift(msg);
138
- }
139
- return transformMessages(trailingTools);
131
+ // Slice from the turn's `savedCount` so callers can
132
+ // stage multiple messages between completions. `decideCachedHistorySlice`
133
+ // also guards against the QVAC-17780 stale-count regression: if the
134
+ // saved boundary would slice the history down to an empty payload
135
+ // (e.g. after a cancelled mid-decode), it falls back to the full
136
+ // non-system history and signals the caller to drop the bad entry.
137
+ // The session owns the entry; `dropStaleSavedCount` clears it
138
+ // without touching the on-disk file (the file is still trustworthy
139
+ // — only the boundary count is wrong).
140
+ const { messages, clearStaleCount } = decideCachedHistorySlice(turn.savedCount, cacheExists, history);
141
+ if (clearStaleCount) {
142
+ session.dropStaleSavedCount(turn);
140
143
  }
141
- if (lastMsg.role === 'user') {
142
- const prevMsg = history[history.length - 2];
143
- const tail = prevMsg?.role === 'assistant' ? [prevMsg, lastMsg] : [lastMsg];
144
- return [...transformMessages(tail), ...addTools];
145
- }
146
- return [...transformMessages([lastMsg]), ...addTools];
144
+ // The block is never trimmed back out of the cache, so re-sending it every
145
+ // turn would leave one copy per turn and grow the prefix with the
146
+ // conversation. Skip it only when the prefix is known to hold a rendered
147
+ // one: `toolBlockCached` records that a previous turn actually got it into
148
+ // the cache, which a committed message count does not prove. A stale
149
+ // boundary means we are resending the whole conversation anyway, and an
150
+ // evictable block can no longer be assumed present.
151
+ const skipToolBlock = turn.toolBlockCached && !clearStaleCount && !toolBlockEvictable;
152
+ const blockToSend = skipToolBlock ? [] : toolBlock;
153
+ return {
154
+ messages: withToolBlock(transformMessages(messages), blockToSend),
155
+ toolBlockCached: skipToolBlock || rendersToolBlock(messages, blockToSend)
156
+ };
147
157
  }
148
- async function* processModelResponse(model, messagesToSend, tools, generationParams, cacheOptions, dialect) {
158
+ async function* processModelResponse(model, messagesToSend, tools, generationParams, cacheOptions, dialect, onResponse) {
149
159
  const runOptions = {
150
160
  ...(generationParams && { generationParams }),
151
161
  ...(cacheOptions?.cacheKey !== undefined && {
@@ -158,6 +168,8 @@ async function* processModelResponse(model, messagesToSend, tools, generationPar
158
168
  const hasRunOptions = Object.keys(runOptions).length > 0;
159
169
  const modelStart = nowMs();
160
170
  const response = await runModel(model, messagesToSend, hasRunOptions ? runOptions : undefined);
171
+ // Hand the admitted response back so the caller can cancel just this job.
172
+ onResponse?.(response);
161
173
  let accumulatedText = '';
162
174
  let producedTokens = false;
163
175
  let emittedPieces = 0;
@@ -185,7 +197,8 @@ async function* processModelResponse(model, messagesToSend, tools, generationPar
185
197
  ...buildStreamResult(modelExecutionMs, stats),
186
198
  toolCalls: toolCallsResult,
187
199
  responseText: accumulatedText,
188
- producedTokens
200
+ producedTokens,
201
+ stoppedAtContextBoundary: responseWithStats.stats?.stopReason === 'contextOverflow'
189
202
  };
190
203
  }
191
204
  export async function* completion(params, opts) {
@@ -194,9 +207,13 @@ export async function* completion(params, opts) {
194
207
  const requestLogger = opts.logger ?? logger;
195
208
  const modelConfig = getModelConfig(modelId);
196
209
  const toolsEnabled = modelConfig.tools === true;
197
- const toolsMode = modelConfig.toolsMode;
198
- const dynamicTools = !!tools?.length && toolsEnabled && toolsMode === TOOLS_MODE.dynamic;
199
- const staticTools = !!tools?.length && toolsEnabled && !dynamicTools;
210
+ const toolsActive = !!tools?.length && toolsEnabled;
211
+ // Sliding is opt-in (`n_discarded` defaults to 0). Once on, the addon's
212
+ // discard window opens at the end of the primed prefix — which is where the
213
+ // tool block sits, since the prime is the system prompt alone — and nothing
214
+ // protects it. So while sliding is possible the block cannot be assumed to
215
+ // survive, and it has to travel with every turn.
216
+ const toolBlockEvictable = (modelConfig.n_discarded ?? 0) > 0;
200
217
  const dialect = tools && tools.length > 0 ? (params.toolDialect ?? detectToolDialect(modelId)) : undefined;
201
218
  // `responseFormat` is forwarded to the addon as a per-request
202
219
  // `generationParams.json_schema`, which the addon converts to GBNF and
@@ -216,56 +233,45 @@ export async function* completion(params, opts) {
216
233
  }
217
234
  }
218
235
  const model = getModel(modelId);
219
- // Hard-cancel wiring: when the registry aborts the request's signal,
220
- // forward to the addon so the C++ work stops as soon as it can. The
221
- // SDK still treats `signal.aborted` as the truth for cancel detection
222
- // (post-completion bookkeeping below) — this listener only shortens
223
- // the latency between "user clicked stop" and "addon stops decoding".
224
- //
225
- // Fire-and-forget by construction (event listeners can't `await`), but
226
- // `addon.cancel()` returns a Promise — if it ever rejects the bare
227
- // `void` would leak it as an unhandledRejection. Attach `.catch(...)`
228
- // so a rejection is logged and the process stays clean; the iterator
229
- // below still sees EOF/empty tokens via the addon's normal cancel path
230
- // so callers aren't affected.
231
- const onAbort = () => {
232
- const addon = model.addon;
233
- if (addon?.cancel) {
234
- addon.cancel.call(addon).catch((err) => {
235
- requestLogger.warn(`[cancel] addon.cancel() rejected during abort for modelId=${modelId}: ${err instanceof Error ? err.message : String(err)}`);
236
- });
237
- }
236
+ // Per-request hard cancel: under continuous batching the model runs several
237
+ // jobs at once, so an abort must cancel only THIS request's job, not the
238
+ // whole model — `response.cancel()` routes to the addon's per-job cancel.
239
+ // `.catch(...)` keeps the fire-and-forget cancel from leaking a rejection.
240
+ let activeResponse = null;
241
+ const cancelActive = () => {
242
+ activeResponse?.cancel().catch((err) => {
243
+ requestLogger.warn(`[cancel] response.cancel() rejected during abort for modelId=${modelId}: ${err instanceof Error ? err.message : String(err)}`);
244
+ });
245
+ };
246
+ // Publish each run's response as it's admitted; the `signal.aborted` re-check
247
+ // cancels it if the abort landed while run() was still being admitted.
248
+ const setActiveResponse = (response) => {
249
+ activeResponse = response;
250
+ if (signal.aborted)
251
+ cancelActive();
238
252
  };
253
+ const onAbort = () => cancelActive();
239
254
  signal.addEventListener('abort', onAbort, { once: true });
240
- // `addEventListener("abort", ..., { once: true })` does *not* fire if
241
- // the signal is already aborted at register time — but the registry
242
- // synchronously aborts a fresh controller when `parentSignal` was
243
- // already aborted at `begin(...)`. Without this fall-through, the
244
- // addon would keep decoding until the post-loop check notices.
245
- // Re-using `onAbort` here keeps the listener body as the single
246
- // source of truth for "what cancel does."
255
+ // `{ once: true }` won't fire for an already-aborted signal (e.g. an
256
+ // already-aborted parentSignal at begin), so fire once here; a no-op before
257
+ // any response exists.
247
258
  if (signal.aborted)
248
259
  onAbort();
249
- // Detach the abort listener on every exit path (happy, throw, generator
250
- // `return()` from upstream). `{ once: true }` already removes the
251
- // listener if the signal fires, so the `removeEventListener` here is
252
- // the cleanup hook for the signal-never-fired path.
253
260
  scope.defer(() => {
254
261
  signal.removeEventListener('abort', onAbort);
262
+ // Drop the ref so a late abort can't cancel an already-settled response.
263
+ activeResponse = null;
255
264
  });
256
265
  if (!kvCache) {
257
266
  // KV-cache disabled — straight passthrough, no session involvement.
258
267
  let historyWithTools = history;
259
- if (staticTools && tools) {
268
+ if (toolsActive && tools) {
260
269
  historyWithTools = prependToolsToHistory(history, tools);
261
270
  }
262
- else if (dynamicTools && tools) {
263
- historyWithTools = appendToolsToHistory(history, tools);
264
- }
265
271
  const transformedHistory = transformMessages(historyWithTools);
266
272
  logCacheDisabled();
267
273
  logMessagesToAddon(transformedHistory, 'NO_CACHE');
268
- return yield* processModelResponse(model, transformedHistory, tools, mergedGenerationParams, undefined, dialect);
274
+ return yield* processModelResponse(model, transformedHistory, tools, mergedGenerationParams, undefined, dialect, setActiveResponse);
269
275
  }
270
276
  // ---- KV-cache path. The session owns all three bookkeeping layers
271
277
  // (on-disk `.bin`, `initializedCaches`, `cachedMessageCounts`). The
@@ -275,19 +281,15 @@ export async function* completion(params, opts) {
275
281
  // unwind through the same `scope.defer` hook. ----
276
282
  const session = createKvCacheSession(modelId, { logger: requestLogger });
277
283
  const systemPromptFromHistory = extractSystemPrompt(history);
278
- // Dynamic mode lets each turn carry its own tool set, so the cache
279
- // hash must not depend on the tool list — otherwise a tool change
280
- // would force a fresh cache file and defeat the whole optimisation.
281
- const configHash = generateConfigHash(systemPromptFromHistory, dynamicTools ? undefined : tools);
284
+ // The tool block is baked into the cache on the turn that first sends it and
285
+ // never trimmed, so a late or changed tool set has to land on a fresh cache
286
+ // rather than a warm prefix holding the old block.
287
+ const configHash = generateConfigHash(systemPromptFromHistory, toolsActive ? tools : undefined);
282
288
  const systemPromptToUse = systemPromptFromHistory ||
283
289
  modelConfig.system_prompt ||
284
290
  'You are a helpful assistant.';
285
291
  const primeIfMissing = async (cachePath) => {
286
- await initSystemPromptCache(model, cachePath, systemPromptToUse, typeof kvCache === 'string' ? kvCache : 'auto',
287
- // Static-mode tools are baked into the system-prompt cache so
288
- // they're shared across the session. Dynamic-mode tools belong
289
- // to a per-turn anchor and must not enter the system cache.
290
- staticTools ? tools : undefined);
292
+ await initSystemPromptCache(model, cachePath, systemPromptToUse, typeof kvCache === 'string' ? kvCache : 'auto', setActiveResponse);
291
293
  };
292
294
  let turn;
293
295
  if (typeof kvCache === 'string') {
@@ -295,7 +297,8 @@ export async function* completion(params, opts) {
295
297
  kind: 'custom',
296
298
  customKey: kvCache,
297
299
  configHash,
298
- primeIfMissing
300
+ primeIfMissing,
301
+ signal
299
302
  });
300
303
  }
301
304
  else {
@@ -308,7 +311,8 @@ export async function* completion(params, opts) {
308
311
  kind: 'auto',
309
312
  configHash,
310
313
  history: cacheMessages,
311
- primeIfMissing
314
+ primeIfMissing,
315
+ signal
312
316
  });
313
317
  }
314
318
  // Single cleanup hook for every non-success exit path. `commitTurn`
@@ -320,15 +324,17 @@ export async function* completion(params, opts) {
320
324
  // `cacheExists` is implied by `beginTurn` — the session either found
321
325
  // an existing cache or just primed one. Pass `true` to the message
322
326
  // selector so the slicing branches engage.
323
- const messagesToSend = prepareMessagesForCache(session, turn,
324
- /* cacheExists */ true, history, dynamicTools ? tools : undefined);
327
+ const payload = prepareMessagesForCache(session, turn,
328
+ /* cacheExists */ true, history, toolsActive ? tools : undefined, toolBlockEvictable);
329
+ const messagesToSend = payload.messages;
325
330
  logMessagesToAddon(messagesToSend, 'PROMPT_SEND');
326
- const result = yield* processModelResponse(model, messagesToSend, tools, mergedGenerationParams, { cacheKey: turn.cachePath, saveCacheToDisk: true }, dialect);
331
+ const result = yield* processModelResponse(model, messagesToSend, tools, mergedGenerationParams, { cacheKey: turn.cachePath, saveCacheToDisk: true }, dialect, setActiveResponse);
327
332
  const shouldCommitTurn = shouldCommitCachedTurn({
328
333
  aborted: signal.aborted,
329
334
  producedTokens: result.producedTokens,
330
335
  generatedTokens: result.stats?.generatedTokens,
331
- predict: mergedGenerationParams?.predict ?? modelConfig.predict
336
+ predict: mergedGenerationParams?.predict ?? modelConfig.predict,
337
+ stoppedAtContextBoundary: result.stoppedAtContextBoundary
332
338
  });
333
339
  if (typeof kvCache === 'string') {
334
340
  // Custom-key path: the addon wrote the new cache state inline at
@@ -337,7 +343,8 @@ export async function* completion(params, opts) {
337
343
  if (shouldCommitTurn) {
338
344
  await session.commitTurn(turn, {
339
345
  kind: 'static',
340
- messageCount: history.length + 1
346
+ messageCount: history.length + 1,
347
+ toolBlockCached: payload.toolBlockCached
341
348
  });
342
349
  }
343
350
  return result;
@@ -368,7 +375,8 @@ export async function* completion(params, opts) {
368
375
  await session.commitTurn(turn, {
369
376
  kind: 'autoRename',
370
377
  targetCachePath: postResponseCacheInfo.cachePath,
371
- messageCount: savedHistory.length
378
+ messageCount: savedHistory.length,
379
+ toolBlockCached: payload.toolBlockCached
372
380
  });
373
381
  return result;
374
382
  }
@@ -57,15 +57,52 @@ import { getRequestRegistry, withRequestContext } from '../../../../../server/ba
57
57
  import { generateServerRequestId } from '../../../../../server/bare/runtime/request-id.js';
58
58
  import { getServerLogger } from '../../../../../logging/index.js';
59
59
  const PAUSE_CHECKPOINT_PREFIX = 'pause_checkpoint_step_';
60
- const finetuneRuntimeState = new Set();
60
+ // Ref-count, not a Set: with an exclusive writer a second finetune queues behind
61
+ // the first, and both register before admission. A Set let the first's cleanup
62
+ // clear the key while the second was still training, so getFinetuneState()
63
+ // reported IDLE mid-run. Each call owns one ref and releases exactly one.
64
+ const finetuneRuntimeState = new Map();
61
65
  function getRunningFinetuneState(modelId) {
62
- return finetuneRuntimeState.has(modelId);
66
+ return (finetuneRuntimeState.get(modelId) ?? 0) > 0;
63
67
  }
64
68
  function registerRunningFinetune(modelId) {
65
- finetuneRuntimeState.add(modelId);
69
+ finetuneRuntimeState.set(modelId, (finetuneRuntimeState.get(modelId) ?? 0) + 1);
66
70
  }
67
71
  export function clearFinetuneRuntimeState(modelId) {
68
- finetuneRuntimeState.delete(modelId);
72
+ const count = finetuneRuntimeState.get(modelId);
73
+ if (count === undefined)
74
+ return;
75
+ if (count <= 1)
76
+ finetuneRuntimeState.delete(modelId);
77
+ else
78
+ finetuneRuntimeState.set(modelId, count - 1);
79
+ }
80
+ // modelIds whose single in-flight finetune currently HOLDS the exclusive lane
81
+ // (admitted, past the queued-abort check). One finetune per model (see the
82
+ // reject-second guard in startFinetune), so this is a plain membership set. Only an
83
+ // admitted finetune may be paused through the addon-global model.pause(); a finetune
84
+ // still queued behind a reader is cancelled instead, so pause never touches an
85
+ // unrelated reader.
86
+ const admittedFinetunes = new Set();
87
+ // The single in-flight finetune's requestId per model. A QUEUED finetune is cancelled
88
+ // by this id, not by a broad cancel({ modelId, kind }): the registry's grant→register
89
+ // handoff leaves a brief window where the request is in neither the wait queue nor the
90
+ // active set — a broad cancel misses it, but cancel({ requestId }) covers it via the
91
+ // cancel-before-begin tripwire.
92
+ const finetuneRequestIds = new Map();
93
+ // Models whose admitted finetune is mid-pause: model.pause() has been called but has
94
+ // not settled. A new finetune is rejected during this window even if the runtime ref
95
+ // has already cleared, because handle.await() can settle before model.pause() resolves.
96
+ const pausingModels = new Set();
97
+ // Cancel the model's in-flight finetune by its tracked requestId (covers the
98
+ // registry grant→register handoff window via the cancel-before-begin tripwire),
99
+ // falling back to a broad cancel if the id is unknown.
100
+ function cancelTrackedFinetune(modelId) {
101
+ const requestId = finetuneRequestIds.get(modelId);
102
+ if (requestId !== undefined)
103
+ getRequestRegistry().cancel({ requestId });
104
+ else
105
+ getRequestRegistry().cancel({ modelId, kind: 'finetune' });
69
106
  }
70
107
  export function getFinetuneStateFromCheckpoints(options) {
71
108
  const checkpointDirectory = options.checkpointSaveDir ?? './checkpoints';
@@ -105,24 +142,35 @@ export async function startFinetune(request, onProgress) {
105
142
  try {
106
143
  const model = getModel(request.modelId);
107
144
  validateExplicitFinetuneOperation(request);
108
- // Mark RUNNING before the async begin() so an immediate getFinetuneState()
109
- // poll observes RUNNING, not IDLE. register is a no-op when a finetune is
110
- // already running on this model, so only clear on a failed begin() if this
111
- // call actually set the flag.
112
- const wasRunning = getRunningFinetuneState(request.modelId);
145
+ // One finetune per model: reject a second start/resume while one is pending or
146
+ // running. With no queued finetune peers, pause/cancel never has to disambiguate
147
+ // multiple finetunes, and a finetune that arrives mid-pause is simply rejected
148
+ // because the pausing one still occupies the model until it unwinds.
149
+ if (getRunningFinetuneState(request.modelId) || pausingModels.has(request.modelId)) {
150
+ throw new CompletionFailedError(`Model "${request.modelId}" already has an active finetune; pause or cancel it before starting another`);
151
+ }
152
+ // Stable id, used for registry scoping and for targeted cancellation of this
153
+ // finetune while it is queued (see pauseFinetune / cancelFinetune).
154
+ const requestId = request.requestId ?? generateServerRequestId();
155
+ // Take a ref before the async begin() so an immediate getFinetuneState() poll
156
+ // observes RUNNING, not IDLE, and a concurrent start is rejected above. Released
157
+ // on a failed begin() or on scope unwind below.
113
158
  registerRunningFinetune(request.modelId);
159
+ finetuneRequestIds.set(request.modelId, requestId);
114
160
  // Scope the run into the registry so cancel({ requestId }) and
115
161
  // cancel({ modelId, kind: "finetune" }) reach it; onAbort forwards to
116
162
  // model.cancel().
117
163
  const ctx = __addDisposableResource(env_1, await getRequestRegistry()
118
164
  .begin({
119
- requestId: request.requestId ?? generateServerRequestId(),
165
+ requestId,
120
166
  kind: 'finetune',
121
167
  modelId: request.modelId
122
168
  })
123
169
  .catch((err) => {
124
- if (!wasRunning)
125
- clearFinetuneRuntimeState(request.modelId);
170
+ clearFinetuneRuntimeState(request.modelId);
171
+ if (finetuneRequestIds.get(request.modelId) === requestId) {
172
+ finetuneRequestIds.delete(request.modelId);
173
+ }
126
174
  throw err;
127
175
  }), true);
128
176
  const requestLogger = withRequestContext(getServerLogger(), ctx);
@@ -130,7 +178,30 @@ export async function startFinetune(request, onProgress) {
130
178
  // removes the listener first.
131
179
  ctx.scope.defer(() => {
132
180
  clearFinetuneRuntimeState(request.modelId);
181
+ if (finetuneRequestIds.get(request.modelId) === requestId) {
182
+ finetuneRequestIds.delete(request.modelId);
183
+ }
184
+ });
185
+ // A finetune cancelled while queued resolves aborted without the exclusive
186
+ // slot, so completions may still hold the lane. Stop before the global cancel
187
+ // or any native finetune so neither runs against them.
188
+ if (ctx.signal.aborted) {
189
+ return { type: 'finetune', status: 'CANCELLED' };
190
+ }
191
+ // Past the queued-abort check: this finetune now holds the exclusive lane. Mark
192
+ // it admitted so pauseFinetune pauses THIS finetune via model.pause() rather than
193
+ // global-pausing (and killing a reader) while it is merely queued.
194
+ admittedFinetunes.add(request.modelId);
195
+ ctx.scope.defer(() => {
196
+ admittedFinetunes.delete(request.modelId);
133
197
  });
198
+ // The pre-admission validation above ran before this request queued for the
199
+ // exclusive lane, so a peer finetune could have changed the checkpoint state
200
+ // while we waited. Re-validate now that we hold the lane exclusively and the
201
+ // state is stable.
202
+ validateExplicitFinetuneOperation(request);
203
+ // Global cancel is finetune's only stop; it runs exclusively, so nothing else
204
+ // is on the model when this fires.
134
205
  const onAbort = () => {
135
206
  model.cancel().catch((err) => {
136
207
  requestLogger.warn(`[cancel] model.cancel() rejected during abort for modelId=${request.modelId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -167,19 +238,38 @@ export async function startFinetune(request, onProgress) {
167
238
  }
168
239
  }
169
240
  export async function pauseFinetune(modelId) {
241
+ // Validate the model is loaded on every path (matches main; getModel throws
242
+ // ModelNotFoundError for an unknown/unloaded model) before reporting any state.
170
243
  const model = getModel(modelId);
171
- await model.pause();
172
- return {
173
- type: 'finetune',
174
- status: 'PAUSED'
175
- };
244
+ if (admittedFinetunes.has(modelId)) {
245
+ // The single finetune holds the model exclusively, so the addon-global pause is
246
+ // safe (nothing else runs on the model). Bar a new finetune until the native
247
+ // pause settles: the runtime ref can clear when handle.await() resolves, which
248
+ // may precede model.pause()'s resolution.
249
+ pausingModels.add(modelId);
250
+ try {
251
+ await model.pause();
252
+ }
253
+ finally {
254
+ pausingModels.delete(modelId);
255
+ }
256
+ return { type: 'finetune', status: 'PAUSED' };
257
+ }
258
+ if (getRunningFinetuneState(modelId)) {
259
+ // A finetune is pending but not yet admitted (queued behind a reader). A global
260
+ // pause would cancel that unrelated reader, so cancel the queued finetune by its
261
+ // own requestId. Nothing has trained, so report CANCELLED rather than a false
262
+ // PAUSED.
263
+ cancelTrackedFinetune(modelId);
264
+ return { type: 'finetune', status: 'CANCELLED' };
265
+ }
266
+ // No finetune on this model: a no-op pause, but the model was validated above.
267
+ return { type: 'finetune', status: 'PAUSED' };
176
268
  }
177
269
  // Routes cancellation through the registry; the model.cancel() forward is
178
270
  // installed by startFinetune, so never call model.cancel() here.
179
271
  export function cancelFinetune(modelId) {
180
- // cancel() is synchronous; Promise.resolve keeps the Promise<FinetuneResult>
181
- // return shape.
182
- getRequestRegistry().cancel({ modelId, kind: 'finetune' });
272
+ cancelTrackedFinetune(modelId);
183
273
  return Promise.resolve({
184
274
  type: 'finetune',
185
275
  status: 'CANCELLED'