@bendyline/gezel-inference 1.0.0 → 1.0.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.
- package/README.md +90 -8
- package/dist/cache/adapter.d.ts +7 -0
- package/dist/cache/controller.d.ts +12 -0
- package/dist/cache/controller.js +24 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
- package/dist/catalog/chat-models/index.json +1 -1
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/raw-index.json +1 -1
- package/dist/catalog/provenance.json +1 -1
- package/dist/engines/electron-native-reuse.d.ts +9 -0
- package/dist/engines/electron-native-reuse.js +146 -45
- package/dist/engines/native-manifest.d.ts +8 -0
- package/dist/engines/native-manifest.js +30 -0
- package/dist/http/openai-compat/apple-model.d.ts +2 -0
- package/dist/http/openai-compat/apple-model.js +21 -1
- package/dist/http/openai-compat/chat-target.d.ts +12 -0
- package/dist/http/openai-compat/chat-target.js +27 -1
- package/dist/http/routes/engines.js +9 -3
- package/dist/http/routes/v1-chat.js +21 -8
- package/dist/http/routes/v1-models.d.ts +6 -0
- package/dist/http/routes/v1-models.js +133 -28
- package/dist/http/routes/v1-responses.js +9 -2
- package/dist/in-flight-requests.d.ts +19 -0
- package/dist/in-flight-requests.js +34 -0
- package/dist/inference-service.js +3 -3
- package/dist/providers/llama-cpp/build-provider.js +33 -8
- package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
- package/dist/providers/llama-cpp/cache-adapter.js +34 -0
- package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
- package/dist/providers/mlx/provider.js +11 -7
- package/dist/providers/mlx/python/batch_processors.py +26 -0
- package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
- package/dist/providers/mlx/python/response_format.py +374 -0
- package/dist/providers/mlx/python/spec_decode.py +5 -0
- package/dist/providers/mlx/python/think_budget.py +35 -1
- package/dist/providers/mlx/python/tool_grammar.py +62 -3
- package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
- package/dist/providers/mlx/reasoning-stream.js +77 -16
- package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
- package/dist/providers/mlx/runtime-diagnostics.js +29 -2
- package/dist/providers/native/capacity-broker.d.ts +35 -0
- package/dist/providers/native/capacity-broker.js +69 -4
- package/dist/providers/native/local-engine-runtime.js +7 -2
- package/dist/remotes/identity.d.ts +1 -0
- package/dist/remotes/identity.js +6 -0
- package/dist/remotes/pinned-fetch.d.ts +2 -0
- package/dist/remotes/pinned-fetch.js +30 -1
- package/dist/remotes/types.d.ts +14 -0
- package/dist/runtime-lock.js +1 -1
- package/dist/system/memory.js +11 -1
- package/dist/utils/service-worker-entry.d.ts +4 -0
- package/dist/utils/service-worker-entry.js +4 -0
- package/package.json +14 -10
|
@@ -96,8 +96,15 @@ class SafeToolGrammarProcessor:
|
|
|
96
96
|
salvage layer is still there as the post-hoc safety net.
|
|
97
97
|
"""
|
|
98
98
|
|
|
99
|
-
def __init__(
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
inner: Any,
|
|
102
|
+
label: str = "tool-grammar",
|
|
103
|
+
fallback: str = "unconstrained + TS salvage",
|
|
104
|
+
) -> None:
|
|
100
105
|
self._inner = inner
|
|
106
|
+
self._label = label
|
|
107
|
+
self._fallback = fallback
|
|
101
108
|
self.disabled = False
|
|
102
109
|
|
|
103
110
|
def __call__(self, input_ids: Any, logits: Any) -> Any:
|
|
@@ -107,8 +114,8 @@ class SafeToolGrammarProcessor:
|
|
|
107
114
|
return self._inner(input_ids, logits)
|
|
108
115
|
except Exception as exc: # noqa: BLE001 - defensive: any matcher fault
|
|
109
116
|
print(
|
|
110
|
-
f"[
|
|
111
|
-
f"this turn (falling back to
|
|
117
|
+
f"[{self._label}] matcher error; disabling grammar for the rest of "
|
|
118
|
+
f"this turn (falling back to {self._fallback}): {exc}",
|
|
112
119
|
flush=True,
|
|
113
120
|
)
|
|
114
121
|
self.disabled = True
|
|
@@ -698,6 +705,58 @@ def build_grammar_string(
|
|
|
698
705
|
return None
|
|
699
706
|
|
|
700
707
|
|
|
708
|
+
# What each format's own grammar opens with, and how its reasoning block
|
|
709
|
+
# reads, for splicing an answer branch in front of the format's call rules.
|
|
710
|
+
_FORMAT_HEADS = {"hermes": _HERMES_HEAD, "gemma": _GEMMA_HEAD, "glm": _GLM_HEAD}
|
|
711
|
+
_THINK_BLOCKS = {
|
|
712
|
+
"hermes": "<think> TEXT </think>",
|
|
713
|
+
"glm": "<think> TEXT </think>",
|
|
714
|
+
"gemma": "<|channel> TEXT <channel|>",
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def build_answer_or_calls_grammar(
|
|
719
|
+
tools: Optional[List[Dict[str, Any]]],
|
|
720
|
+
hint: Dict[str, Any],
|
|
721
|
+
answer_schema: Dict[str, Any],
|
|
722
|
+
allow_think: bool = True,
|
|
723
|
+
) -> Optional[str]:
|
|
724
|
+
"""One grammar for a request with both tools and a response format.
|
|
725
|
+
|
|
726
|
+
OpenAI's contract: the model may call tools, and when it answers, the
|
|
727
|
+
answer follows the schema. So the reply is optional reasoning, then
|
|
728
|
+
either one or more calls in the format's own syntax (with this module's
|
|
729
|
+
name and key constraints) or a JSON value matching `answer_schema`.
|
|
730
|
+
Stacking the plain JSON grammar on the tool grammar instead left only
|
|
731
|
+
their intersection, JSON, which no call can satisfy. Prose outside the
|
|
732
|
+
reasoning is ruled out, as it is for a plain response format.
|
|
733
|
+
|
|
734
|
+
None when the format has no grammar here; the caller then keeps tool
|
|
735
|
+
calls free and leaves the answer unconstrained, as before.
|
|
736
|
+
|
|
737
|
+
`allow_think` False drops the reasoning block for a request that turned
|
|
738
|
+
thinking off: with prose masked, Gemma took the open `<|channel>` as its
|
|
739
|
+
way out and reasoned until max_tokens (2026-10-14).
|
|
740
|
+
"""
|
|
741
|
+
fmt = str((hint or {}).get("format") or "").strip()
|
|
742
|
+
head = _FORMAT_HEADS.get(fmt)
|
|
743
|
+
base = build_grammar_string(tools, hint)
|
|
744
|
+
if head is None or base is None or not base.startswith(head):
|
|
745
|
+
return None
|
|
746
|
+
schema = dict(answer_schema)
|
|
747
|
+
# Whitespace stays free inside the answer; see response_format.py.
|
|
748
|
+
schema["x-guidance"] = {**schema.get("x-guidance", {}), "whitespace_flexible": True}
|
|
749
|
+
return (
|
|
750
|
+
"%llguidance {}\n"
|
|
751
|
+
+ ("start: think? WS? (calls | answer) WS?\n" if allow_think else "start: WS? (calls | answer) WS?\n")
|
|
752
|
+
+ (f"think: {_THINK_BLOCKS[fmt]}\n" if allow_think else "")
|
|
753
|
+
+ "calls: tool_call (WS? tool_call)*\n"
|
|
754
|
+
+ f"answer: %json {json.dumps(schema)}\n"
|
|
755
|
+
+ "WS: /[ \\t\\r\\n]+/\n"
|
|
756
|
+
+ "TEXT: /(.|\\n)*/\n"
|
|
757
|
+
) + base[len(head) :]
|
|
758
|
+
|
|
759
|
+
|
|
701
760
|
def build_tool_grammar_processor(
|
|
702
761
|
tokenizer: Any,
|
|
703
762
|
tools: Optional[List[Dict[str, Any]]],
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/** Response header in which the sidecar reports the rendered prompt's state. */
|
|
2
|
+
export declare const REASONING_OPEN_HEADER = "x-gezel-reasoning-open";
|
|
3
|
+
/**
|
|
4
|
+
* Whether the engine says this turn's prompt left the model inside a reasoning
|
|
5
|
+
* block. The sidecar reads the rendered prompt, so this beats anything derived
|
|
6
|
+
* from the template text: Qwen 3.5's small models open `<think>` only when
|
|
7
|
+
* `enable_thinking` is explicitly true, and Gemma 4 opens its thought channel
|
|
8
|
+
* only after a tool response. Undefined when the header is absent.
|
|
9
|
+
*/
|
|
10
|
+
export declare function engineOpensReasoning(header: string | null | undefined): boolean | undefined;
|
|
1
11
|
/**
|
|
2
12
|
* How a chat template's generation prompt leaves the model inside a reasoning
|
|
3
13
|
* block:
|
|
@@ -11,7 +21,9 @@ export type TemplateReasoningOpen = 'always' | 'unless-thinking-off';
|
|
|
11
21
|
/**
|
|
12
22
|
* Whether the model's chat template opens a reasoning block as the last thing
|
|
13
23
|
* it emits before the model starts generating, and whether that depends on
|
|
14
|
-
* `enable_thinking`.
|
|
24
|
+
* `enable_thinking`. A fallback for a sidecar that does not report
|
|
25
|
+
* {@link REASONING_OPEN_HEADER}: a template guess cannot see which branch the
|
|
26
|
+
* request takes.
|
|
15
27
|
*
|
|
16
28
|
* Qwen-family templates end their generation prompt with a bare `<think>`, so
|
|
17
29
|
* the model's output begins *inside* reasoning and the only tag that ever
|
|
@@ -66,6 +78,11 @@ export interface ReasoningSplitChunk {
|
|
|
66
78
|
export declare class StreamingReasoningSplit {
|
|
67
79
|
private buffer;
|
|
68
80
|
private inReasoning;
|
|
81
|
+
/** A channel opener names its channel (`thought`) before the reasoning starts. */
|
|
82
|
+
private channelNamePending;
|
|
83
|
+
/** The blank lines a model leaves after closing its reasoning are not reply. */
|
|
84
|
+
private afterClose;
|
|
85
|
+
private sawVisible;
|
|
69
86
|
private readonly enabled;
|
|
70
87
|
constructor(opts: {
|
|
71
88
|
opensInReasoning: boolean;
|
|
@@ -79,7 +96,8 @@ export declare class StreamingReasoningSplit {
|
|
|
79
96
|
*/
|
|
80
97
|
flush(): ReasoningSplitChunk;
|
|
81
98
|
private drain;
|
|
82
|
-
private
|
|
99
|
+
private out;
|
|
100
|
+
private firstMarker;
|
|
83
101
|
/** Length of the trailing fragment that could still be a partial marker. */
|
|
84
102
|
private holdBackLength;
|
|
85
103
|
}
|
|
@@ -3,17 +3,39 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { createLogger } from '@bendyline/gezel';
|
|
4
4
|
const log = createLogger('mlx');
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Markers that open and close a reasoning block. Kept in sync with the paired
|
|
7
|
+
* forms `extractReasoning` strips at end of turn — this is the streaming half
|
|
8
|
+
* of the same knowledge. Gemma 4 opens a named channel (`<|channel>thought\n`,
|
|
9
|
+
* sometimes without the leading pipe) and closes it with `<channel|>`.
|
|
9
10
|
*/
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const
|
|
11
|
+
const OPEN_MARKERS = ['<think>', '<reasoning>', '[THINK]', '<|channel>', '<channel>'];
|
|
12
|
+
const CLOSE_MARKERS = ['</think>', '</reasoning>', '[/THINK]', '<|end|>', '<channel|>'];
|
|
13
|
+
const OPEN_PATTERN = /<think>|<reasoning>|\[THINK\]|<\|?channel>/i;
|
|
14
|
+
const CHANNEL_NAME = /^[A-Za-z_][\w-]*/;
|
|
15
|
+
/** The longest marker, so `push` knows how much tail to hold back. */
|
|
16
|
+
const MAX_MARKER_LENGTH = Math.max(...[...OPEN_MARKERS, ...CLOSE_MARKERS].map((marker) => marker.length));
|
|
17
|
+
/** Response header in which the sidecar reports the rendered prompt's state. */
|
|
18
|
+
export const REASONING_OPEN_HEADER = 'x-gezel-reasoning-open';
|
|
19
|
+
/**
|
|
20
|
+
* Whether the engine says this turn's prompt left the model inside a reasoning
|
|
21
|
+
* block. The sidecar reads the rendered prompt, so this beats anything derived
|
|
22
|
+
* from the template text: Qwen 3.5's small models open `<think>` only when
|
|
23
|
+
* `enable_thinking` is explicitly true, and Gemma 4 opens its thought channel
|
|
24
|
+
* only after a tool response. Undefined when the header is absent.
|
|
25
|
+
*/
|
|
26
|
+
export function engineOpensReasoning(header) {
|
|
27
|
+
if (header === '1')
|
|
28
|
+
return true;
|
|
29
|
+
if (header === '0')
|
|
30
|
+
return false;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
13
33
|
/**
|
|
14
34
|
* Whether the model's chat template opens a reasoning block as the last thing
|
|
15
35
|
* it emits before the model starts generating, and whether that depends on
|
|
16
|
-
* `enable_thinking`.
|
|
36
|
+
* `enable_thinking`. A fallback for a sidecar that does not report
|
|
37
|
+
* {@link REASONING_OPEN_HEADER}: a template guess cannot see which branch the
|
|
38
|
+
* request takes.
|
|
17
39
|
*
|
|
18
40
|
* Qwen-family templates end their generation prompt with a bare `<think>`, so
|
|
19
41
|
* the model's output begins *inside* reasoning and the only tag that ever
|
|
@@ -108,6 +130,11 @@ export function requestOpensReasoning(templateOpen, body) {
|
|
|
108
130
|
export class StreamingReasoningSplit {
|
|
109
131
|
buffer = '';
|
|
110
132
|
inReasoning;
|
|
133
|
+
/** A channel opener names its channel (`thought`) before the reasoning starts. */
|
|
134
|
+
channelNamePending = false;
|
|
135
|
+
/** The blank lines a model leaves after closing its reasoning are not reply. */
|
|
136
|
+
afterClose = false;
|
|
137
|
+
sawVisible = false;
|
|
111
138
|
enabled;
|
|
112
139
|
constructor(opts) {
|
|
113
140
|
this.enabled = opts.enabled ?? true;
|
|
@@ -134,20 +161,48 @@ export class StreamingReasoningSplit {
|
|
|
134
161
|
let reasoning = '';
|
|
135
162
|
while (this.buffer.length > 0) {
|
|
136
163
|
if (this.inReasoning) {
|
|
137
|
-
|
|
164
|
+
if (this.channelNamePending) {
|
|
165
|
+
const name = CHANNEL_NAME.exec(this.buffer)?.[0] ?? '';
|
|
166
|
+
if (!final && name.length === this.buffer.length)
|
|
167
|
+
break;
|
|
168
|
+
this.buffer = this.buffer.slice(name.length).replace(/^[ \t]*\r?\n/, '');
|
|
169
|
+
this.channelNamePending = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const close = this.firstMarker(CLOSE_MARKERS);
|
|
138
173
|
if (!close)
|
|
139
174
|
break;
|
|
140
175
|
reasoning += this.buffer.slice(0, close.index);
|
|
141
176
|
this.buffer = this.buffer.slice(close.index + close.marker.length);
|
|
142
177
|
this.inReasoning = false;
|
|
178
|
+
this.afterClose = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (this.afterClose) {
|
|
182
|
+
if (!this.sawVisible && visible.trim() === '') {
|
|
183
|
+
visible = '';
|
|
184
|
+
this.buffer = this.buffer.replace(/^\s+/, '');
|
|
185
|
+
if (this.buffer.length === 0)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
this.afterClose = false;
|
|
189
|
+
}
|
|
190
|
+
const open = OPEN_PATTERN.exec(this.buffer);
|
|
191
|
+
// A closer with no opener is a template-opened block the engine did not
|
|
192
|
+
// report; the text before it has already streamed, but the tag need not.
|
|
193
|
+
const stray = this.firstMarker(CLOSE_MARKERS);
|
|
194
|
+
if (stray && (!open || stray.index < open.index)) {
|
|
195
|
+
visible += this.buffer.slice(0, stray.index);
|
|
196
|
+
this.buffer = this.buffer.slice(stray.index + stray.marker.length);
|
|
197
|
+
this.afterClose = true;
|
|
143
198
|
continue;
|
|
144
199
|
}
|
|
145
|
-
const open = /<think>|<reasoning>|\[THINK\]/i.exec(this.buffer);
|
|
146
200
|
if (!open)
|
|
147
201
|
break;
|
|
148
202
|
visible += this.buffer.slice(0, open.index);
|
|
149
203
|
this.buffer = this.buffer.slice(open.index + open[0].length);
|
|
150
204
|
this.inReasoning = true;
|
|
205
|
+
this.channelNamePending = /channel/i.test(open[0]);
|
|
151
206
|
}
|
|
152
207
|
if (final) {
|
|
153
208
|
if (this.inReasoning)
|
|
@@ -155,8 +210,10 @@ export class StreamingReasoningSplit {
|
|
|
155
210
|
else
|
|
156
211
|
visible += this.buffer;
|
|
157
212
|
this.buffer = '';
|
|
158
|
-
return
|
|
213
|
+
return this.out(visible, reasoning);
|
|
159
214
|
}
|
|
215
|
+
if (this.channelNamePending)
|
|
216
|
+
return this.out(visible, reasoning);
|
|
160
217
|
// Hold back a tail that could still grow into a marker; release the rest.
|
|
161
218
|
const safeLength = this.buffer.length - this.holdBackLength();
|
|
162
219
|
if (safeLength > 0) {
|
|
@@ -167,12 +224,18 @@ export class StreamingReasoningSplit {
|
|
|
167
224
|
else
|
|
168
225
|
visible += released;
|
|
169
226
|
}
|
|
227
|
+
return this.out(visible, reasoning);
|
|
228
|
+
}
|
|
229
|
+
out(visible, reasoning) {
|
|
230
|
+
if (visible.trim() !== '')
|
|
231
|
+
this.sawVisible = true;
|
|
170
232
|
return { visible, reasoning };
|
|
171
233
|
}
|
|
172
|
-
|
|
234
|
+
firstMarker(markers) {
|
|
235
|
+
const haystack = this.buffer.toLowerCase();
|
|
173
236
|
let best = null;
|
|
174
|
-
for (const marker of
|
|
175
|
-
const index =
|
|
237
|
+
for (const marker of markers) {
|
|
238
|
+
const index = haystack.indexOf(marker.toLowerCase());
|
|
176
239
|
if (index === -1)
|
|
177
240
|
continue;
|
|
178
241
|
if (!best || index < best.index)
|
|
@@ -182,12 +245,10 @@ export class StreamingReasoningSplit {
|
|
|
182
245
|
}
|
|
183
246
|
/** Length of the trailing fragment that could still be a partial marker. */
|
|
184
247
|
holdBackLength() {
|
|
248
|
+
const candidates = (this.inReasoning ? CLOSE_MARKERS : [...OPEN_MARKERS, ...CLOSE_MARKERS]).map((marker) => marker.toLowerCase());
|
|
185
249
|
const maxTail = Math.min(MAX_MARKER_LENGTH - 1, this.buffer.length);
|
|
186
250
|
for (let length = maxTail; length > 0; length -= 1) {
|
|
187
251
|
const tail = this.buffer.slice(this.buffer.length - length).toLowerCase();
|
|
188
|
-
const candidates = this.inReasoning
|
|
189
|
-
? CLOSE_MARKERS.map((marker) => marker.toLowerCase())
|
|
190
|
-
: ['<think>', '<reasoning>', '[think]'];
|
|
191
252
|
if (candidates.some((marker) => marker.startsWith(tail)))
|
|
192
253
|
return length;
|
|
193
254
|
}
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
/** Read a process's resident-set size (bytes) via `ps` for engine telemetry. */
|
|
2
2
|
export declare function readProcessRssBytes(pid: number): Promise<number | null>;
|
|
3
|
+
/**
|
|
4
|
+
* The engine refused the request as the caller's to fix: a `response_format`
|
|
5
|
+
* schema its JSON grammar cannot enforce. Routes answer it with a 400
|
|
6
|
+
* `invalid_request_error`. Reported as a 500 `server_error`, it was retried
|
|
7
|
+
* by OpenAI SDKs although no retry could succeed.
|
|
8
|
+
*/
|
|
9
|
+
export declare class UnsupportedResponseFormatError extends Error {
|
|
10
|
+
readonly code = "unsupported_response_format";
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The error a failed engine response becomes. The sidecar answers 422 with a
|
|
15
|
+
* `response_format` detail only for a schema it refused (response_format.py);
|
|
16
|
+
* its other 422s are about images, and stay engine errors.
|
|
17
|
+
*/
|
|
18
|
+
export declare function mlxHttpError(status: number, statusText: string, body: string): Error;
|
|
3
19
|
/** Translate known mlx_vlm response shapes into user-actionable errors. */
|
|
4
20
|
export declare function translateMlxHttpError(status: number, statusText: string, body: string): string;
|
|
5
21
|
/** Detect an engine-side HTTP response stream closing mid-flight. */
|
|
@@ -27,6 +27,33 @@ export async function readProcessRssBytes(pid) {
|
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The engine refused the request as the caller's to fix: a `response_format`
|
|
32
|
+
* schema its JSON grammar cannot enforce. Routes answer it with a 400
|
|
33
|
+
* `invalid_request_error`. Reported as a 500 `server_error`, it was retried
|
|
34
|
+
* by OpenAI SDKs although no retry could succeed.
|
|
35
|
+
*/
|
|
36
|
+
export class UnsupportedResponseFormatError extends Error {
|
|
37
|
+
code = 'unsupported_response_format';
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'UnsupportedResponseFormatError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The error a failed engine response becomes. The sidecar answers 422 with a
|
|
45
|
+
* `response_format` detail only for a schema it refused (response_format.py);
|
|
46
|
+
* its other 422s are about images, and stay engine errors.
|
|
47
|
+
*/
|
|
48
|
+
export function mlxHttpError(status, statusText, body) {
|
|
49
|
+
if (status === 422) {
|
|
50
|
+
const detail = tryParseJsonDetail(body, 1_000);
|
|
51
|
+
if (detail && /response[ _]format/i.test(detail)) {
|
|
52
|
+
return new UnsupportedResponseFormatError(detail);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return new Error(translateMlxHttpError(status, statusText, body));
|
|
56
|
+
}
|
|
30
57
|
/** Translate known mlx_vlm response shapes into user-actionable errors. */
|
|
31
58
|
export function translateMlxHttpError(status, statusText, body) {
|
|
32
59
|
if (/Repository Not Found for url/i.test(body) || /401 Client Error/i.test(body)) {
|
|
@@ -148,11 +175,11 @@ export function computePreFirstByteBudgetMs(approxPromptTokens) {
|
|
|
148
175
|
const scaled = PRE_FIRST_BYTE_BASE_MS + (over / 1000) * PRE_FIRST_BYTE_MS_PER_1K_TOKENS;
|
|
149
176
|
return Math.min(PRE_FIRST_BYTE_CAP_MS, Math.round(scaled));
|
|
150
177
|
}
|
|
151
|
-
function tryParseJsonDetail(body) {
|
|
178
|
+
function tryParseJsonDetail(body, maxChars = 200) {
|
|
152
179
|
try {
|
|
153
180
|
const obj = JSON.parse(body);
|
|
154
181
|
if (obj && typeof obj === 'object' && 'detail' in obj && typeof obj.detail === 'string') {
|
|
155
|
-
return obj.detail.slice(0,
|
|
182
|
+
return obj.detail.slice(0, maxChars);
|
|
156
183
|
}
|
|
157
184
|
}
|
|
158
185
|
catch {
|
|
@@ -502,6 +502,34 @@ export declare function liveLaunchCapBytes(input: {
|
|
|
502
502
|
export declare const MIN_VIABLE_LOCAL_CONTEXT_TOKENS = 65536;
|
|
503
503
|
/** The same minimum on a host that has to trade context for memory. */
|
|
504
504
|
export declare const MIN_VIABLE_CONSTRAINED_CONTEXT_TOKENS = 32768;
|
|
505
|
+
/**
|
|
506
|
+
* The most KV cache an automatic choice plans for one llama.cpp engine:
|
|
507
|
+
* adaptive growth stops here, and the Gemma `--swa-full` auto-default is
|
|
508
|
+
* declined when its full-size cache would pass it. Explicit settings (a
|
|
509
|
+
* numeric context, a pinned `swaFull`) are the operator's call and bypass it;
|
|
510
|
+
* it never shortens the ladder-settled base window either.
|
|
511
|
+
*
|
|
512
|
+
* Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
|
|
513
|
+
* follows the tokens actually present, not the reservation, so the rest only
|
|
514
|
+
* defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
|
|
515
|
+
* scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
|
|
516
|
+
* it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
|
|
517
|
+
* about 6 GB of its KV. That memory is worth more as a second resident
|
|
518
|
+
* model, and the same week two daemons each grew into one unified pool and
|
|
519
|
+
* hung the machine.
|
|
520
|
+
*/
|
|
521
|
+
export declare const AUTO_KV_CACHE_MAX_BYTES: number;
|
|
522
|
+
/** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
|
|
523
|
+
export declare function autoKvCacheMaxBytes(): number;
|
|
524
|
+
/**
|
|
525
|
+
* The share of a shared pool's live free memory, left after the un-grown
|
|
526
|
+
* launch, that adaptive growth may spend. The rest stays free for whatever
|
|
527
|
+
* starts next: the desktop's own apps, a second model, another daemon. The
|
|
528
|
+
* base grant already holds the practical window; growth is the extra, and an
|
|
529
|
+
* extra that takes everything down to the OS reserve leaves the next tenant
|
|
530
|
+
* to page the machine.
|
|
531
|
+
*/
|
|
532
|
+
export declare const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
|
|
505
533
|
/**
|
|
506
534
|
* The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
|
|
507
535
|
* back a 64K window next to the weights, and the admission ladder's only
|
|
@@ -747,6 +775,8 @@ export interface AdaptiveCtxGrowthInput {
|
|
|
747
775
|
* growth's to spend.
|
|
748
776
|
*/
|
|
749
777
|
allowSlotTrade?: boolean;
|
|
778
|
+
/** Test seam; defaults to {@link autoKvCacheMaxBytes}. */
|
|
779
|
+
maxKvCacheBytes?: number;
|
|
750
780
|
}
|
|
751
781
|
export interface AdaptiveCtxGrowthResult {
|
|
752
782
|
perTurnCtxTokens: number;
|
|
@@ -787,6 +817,11 @@ export interface AdaptiveCtxGrowthResult {
|
|
|
787
817
|
* the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
|
|
788
818
|
* {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
|
|
789
819
|
*
|
|
820
|
+
* Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
|
|
821
|
+
* shared pool with a live reading, never past the base KV plus
|
|
822
|
+
* {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
|
|
823
|
+
* free. Both only stop growth; neither shrinks the base grant.
|
|
824
|
+
*
|
|
790
825
|
* MoE models never grow on a discrete card: `planMoeOffload` places routed
|
|
791
826
|
* experts around the KV reserve, and a grown reserve would silently evict
|
|
792
827
|
* experts from VRAM — trading decode speed for context behind the user's
|
|
@@ -713,6 +713,38 @@ function envGrowthCeilingTokens() {
|
|
|
713
713
|
const tokens = Number.parseInt(raw, 10);
|
|
714
714
|
return Number.isFinite(tokens) && tokens > 0 ? tokens : null;
|
|
715
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* The most KV cache an automatic choice plans for one llama.cpp engine:
|
|
718
|
+
* adaptive growth stops here, and the Gemma `--swa-full` auto-default is
|
|
719
|
+
* declined when its full-size cache would pass it. Explicit settings (a
|
|
720
|
+
* numeric context, a pinned `swaFull`) are the operator's call and bypass it;
|
|
721
|
+
* it never shortens the ladder-settled base window either.
|
|
722
|
+
*
|
|
723
|
+
* Past this a bigger cache buys nothing: llama.cpp's prefill and decode cost
|
|
724
|
+
* follows the tokens actually present, not the reservation, so the rest only
|
|
725
|
+
* defers compaction. Across the 2026-10-12 scorecard (DGX Spark, n=3 per
|
|
726
|
+
* scenario) gemma4-31b never passed 41.7K tokens of an 82,944-token window
|
|
727
|
+
* it held as a 69.6 GB `--swa-full` cache, and qwen3.8-27b peaked at 94.8K,
|
|
728
|
+
* about 6 GB of its KV. That memory is worth more as a second resident
|
|
729
|
+
* model, and the same week two daemons each grew into one unified pool and
|
|
730
|
+
* hung the machine.
|
|
731
|
+
*/
|
|
732
|
+
export const AUTO_KV_CACHE_MAX_BYTES = 32 * GIB;
|
|
733
|
+
/** {@link AUTO_KV_CACHE_MAX_BYTES}, unless `GEZEL_LLAMA_AUTO_KV_MAX_GB` overrides it. */
|
|
734
|
+
export function autoKvCacheMaxBytes() {
|
|
735
|
+
const raw = process.env.GEZEL_LLAMA_AUTO_KV_MAX_GB;
|
|
736
|
+
const gb = raw ? Number.parseFloat(raw) : Number.NaN;
|
|
737
|
+
return Number.isFinite(gb) && gb > 0 ? gb * GIB : AUTO_KV_CACHE_MAX_BYTES;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The share of a shared pool's live free memory, left after the un-grown
|
|
741
|
+
* launch, that adaptive growth may spend. The rest stays free for whatever
|
|
742
|
+
* starts next: the desktop's own apps, a second model, another daemon. The
|
|
743
|
+
* base grant already holds the practical window; growth is the extra, and an
|
|
744
|
+
* extra that takes everything down to the OS reserve leaves the next tenant
|
|
745
|
+
* to page the machine.
|
|
746
|
+
*/
|
|
747
|
+
export const ADAPTIVE_GROWTH_SHARED_FREE_SHARE = 0.5;
|
|
716
748
|
/**
|
|
717
749
|
* The context floor THIS host is held to. A 16 GB Mac or an 8 GB card cannot
|
|
718
750
|
* back a 64K window next to the weights, and the admission ladder's only
|
|
@@ -948,6 +980,11 @@ export const ADAPTIVE_GROWTH_TRADE_MIN_GAIN = 1.25;
|
|
|
948
980
|
* the inputs match the base clamp. Inherits `VRAM_USABLE_FRACTION` and
|
|
949
981
|
* {@link LOCAL_ENGINE_COMPUTE_HEADROOM} through the shared clamp.
|
|
950
982
|
*
|
|
983
|
+
* Ceiling: total KV never grows past {@link autoKvCacheMaxBytes}, and on a
|
|
984
|
+
* shared pool with a live reading, never past the base KV plus
|
|
985
|
+
* {@link ADAPTIVE_GROWTH_SHARED_FREE_SHARE} of what the base launch leaves
|
|
986
|
+
* free. Both only stop growth; neither shrinks the base grant.
|
|
987
|
+
*
|
|
951
988
|
* MoE models never grow on a discrete card: `planMoeOffload` places routed
|
|
952
989
|
* experts around the KV reserve, and a grown reserve would silently evict
|
|
953
990
|
* experts from VRAM — trading decode speed for context behind the user's
|
|
@@ -974,6 +1011,27 @@ export function planAdaptiveContextGrowth(input) {
|
|
|
974
1011
|
? Math.max(0, input.measuredFreeVramBytes)
|
|
975
1012
|
: undefined;
|
|
976
1013
|
const cardBytes = measuredFreeVram !== undefined ? Math.min(input.vramBytes, measuredFreeVram) : input.vramBytes;
|
|
1014
|
+
const kvTotalAt = (slots, ctx) => slots * (kvFixedPerSlot + ctx * input.kvBytesPerToken);
|
|
1015
|
+
const baseKv = kvTotalAt(startSlots, base);
|
|
1016
|
+
const capKv = Math.max(0, input.maxKvCacheBytes ?? autoKvCacheMaxBytes());
|
|
1017
|
+
// Priced like the shared clamp (same OS reserve, same compute headroom) so
|
|
1018
|
+
// "what the base launch leaves free" means the same thing to both.
|
|
1019
|
+
const leftAfterBase = !discrete && input.freeSystemRamBytes !== undefined
|
|
1020
|
+
? Math.max(0, (input.freeSystemRamBytes -
|
|
1021
|
+
liveRamOsReserveBytes() -
|
|
1022
|
+
CTX_PLANNING_MARGIN_BYTES -
|
|
1023
|
+
input.weightsResidentBytes) *
|
|
1024
|
+
(1 - LOCAL_ENGINE_COMPUTE_HEADROOM) -
|
|
1025
|
+
baseKv)
|
|
1026
|
+
: undefined;
|
|
1027
|
+
const shareKv = leftAfterBase !== undefined
|
|
1028
|
+
? baseKv + leftAfterBase * ADAPTIVE_GROWTH_SHARED_FREE_SHARE
|
|
1029
|
+
: Number.POSITIVE_INFINITY;
|
|
1030
|
+
const kvCeiling = Math.min(capKv, shareKv);
|
|
1031
|
+
const ctxUnderCeiling = (slots) => {
|
|
1032
|
+
const perSlot = kvCeiling / slots - kvFixedPerSlot;
|
|
1033
|
+
return perSlot > 0 ? Math.floor(perSlot / input.kvBytesPerToken / 1024) * 1024 : 0;
|
|
1034
|
+
};
|
|
977
1035
|
const safeAt = (slots) => {
|
|
978
1036
|
const safe = clampCtxTokensForMemory({
|
|
979
1037
|
requestedPerTurnCtxTokens: target,
|
|
@@ -995,7 +1053,7 @@ export function planAdaptiveContextGrowth(input) {
|
|
|
995
1053
|
vramBytes: discrete ? cardBytes : 0,
|
|
996
1054
|
minPerTurnCtxTokens: 1,
|
|
997
1055
|
});
|
|
998
|
-
return Math.min(target, safe.perTurnCtxTokens);
|
|
1056
|
+
return Math.min(target, safe.perTurnCtxTokens, ctxUnderCeiling(slots));
|
|
999
1057
|
};
|
|
1000
1058
|
// Walk the slot count down only while the target stays out of reach:
|
|
1001
1059
|
// the first (largest) count that reaches it wins, else the floor count
|
|
@@ -1003,7 +1061,9 @@ export function planAdaptiveContextGrowth(input) {
|
|
|
1003
1061
|
const floorSlots = input.allowSlotTrade
|
|
1004
1062
|
? Math.min(startSlots, ADAPTIVE_GROWTH_MIN_SLOTS)
|
|
1005
1063
|
: startSlots;
|
|
1006
|
-
|
|
1064
|
+
// Floored at the base grant: when the base already sits above the KV
|
|
1065
|
+
// ceiling, the ceiling must not make a lane trade look like a big gain.
|
|
1066
|
+
const noTradeCtx = Math.max(base, safeAt(startSlots));
|
|
1007
1067
|
let chosenSlots = startSlots;
|
|
1008
1068
|
let chosenCtx = noTradeCtx;
|
|
1009
1069
|
for (let slots = startSlots - 1; slots >= floorSlots && chosenCtx < target; slots -= 1) {
|
|
@@ -1027,14 +1087,19 @@ export function planAdaptiveContextGrowth(input) {
|
|
|
1027
1087
|
const measuredNote = measuredFreeVram !== undefined && measuredFreeVram < Math.min(fastLeft, input.vramBytes)
|
|
1028
1088
|
? ` (capped at the ~${gb(measuredFreeVram)} of graphics memory other applications leave free)`
|
|
1029
1089
|
: '';
|
|
1090
|
+
const ceilingNote = grownTo < target && ctxUnderCeiling(chosenSlots) <= grownTo
|
|
1091
|
+
? kvCeiling === capKv
|
|
1092
|
+
? `; stopped at the ~${gb(capKv)} automatic KV ceiling`
|
|
1093
|
+
: `; stopped to keep ~${gb((leftAfterBase ?? 0) * (1 - ADAPTIVE_GROWTH_SHARED_FREE_SHARE))} free for other work`
|
|
1094
|
+
: '';
|
|
1030
1095
|
return {
|
|
1031
1096
|
perTurnCtxTokens: grownTo,
|
|
1032
1097
|
slots: chosenSlots,
|
|
1033
1098
|
grown: true,
|
|
1034
1099
|
reason: `growing context ${base} → ${grownTo} tokens/turn at ${chosenSlots} slot${chosenSlots === 1 ? '' : 's'}${slotNote} ` +
|
|
1035
1100
|
`(target ${target}): weights ~${gb(input.weightsResidentBytes)} + grown KV ` +
|
|
1036
|
-
`~${gb(chosenSlots
|
|
1037
|
-
`~${gb(fastLeft)}${measuredNote}`,
|
|
1101
|
+
`~${gb(kvTotalAt(chosenSlots, grownTo))} fits fast memory ` +
|
|
1102
|
+
`~${gb(fastLeft)}${measuredNote}${ceilingNote}`,
|
|
1038
1103
|
};
|
|
1039
1104
|
}
|
|
1040
1105
|
/**
|
|
@@ -1161,7 +1161,7 @@ export class LocalEngineRuntime {
|
|
|
1161
1161
|
// engine: the full plan must run so a stale resident window surfaces as
|
|
1162
1162
|
// restart-required instead of masquerading as the applied setting.
|
|
1163
1163
|
const shortCircuit = (residentContextWindow !== undefined && !overrideActive) || envNumCtx !== undefined;
|
|
1164
|
-
const { defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
|
|
1164
|
+
const { autoKvCacheMaxBytes, defaultLocalEngineSlots, estimatePerSlotKvBytes, kvQuantScale, llamaCppSlotCeiling, planAdaptiveContextGrowth, planCtxTokensForMemory, plannedLocalEngineSlots, } = await import('./capacity-broker.js');
|
|
1165
1165
|
const capacity = await this.previewCapacityInputs('llama-cpp', modelId, capacityOpts);
|
|
1166
1166
|
const { budgetBytes: admissionBudgetBytes, fastBudgetBytes, committedOtherBytes } = capacity;
|
|
1167
1167
|
const configuredSlots = config.providerConcurrency?.['llama-cpp'];
|
|
@@ -1492,7 +1492,12 @@ export class LocalEngineRuntime {
|
|
|
1492
1492
|
// `requirement.minimumPerTurnCtxTokens`, so the windowed
|
|
1493
1493
|
// re-plan sheds slots or denies but never shortens the window.
|
|
1494
1494
|
const explicitSwaFull = config.llamaCppSwaFull ?? manifestEngineConfig?.swaFull;
|
|
1495
|
-
const
|
|
1495
|
+
const fullCacheOverCeiling = exactKvAtReference !== undefined &&
|
|
1496
|
+
(exactKvAtReference / referenceCtx) * grantedCtx * slots > autoKvCacheMaxBytes();
|
|
1497
|
+
const windowedCacheWillRun = (!admission.minimumSatisfied ||
|
|
1498
|
+
admission.clamped ||
|
|
1499
|
+
admission.slots < slots ||
|
|
1500
|
+
fullCacheOverCeiling) &&
|
|
1496
1501
|
(explicitSwaFull === false ||
|
|
1497
1502
|
(explicitSwaFull === undefined &&
|
|
1498
1503
|
isGemmaModel({ architecture: installed.architecture, modelId })));
|
|
@@ -73,6 +73,7 @@ export declare const IdentityResponseSchema: z.ZodObject<{
|
|
|
73
73
|
"machine-engine": "machine-engine";
|
|
74
74
|
"legacy-full": "legacy-full";
|
|
75
75
|
}>>;
|
|
76
|
+
pairingMethods: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
76
77
|
}, z.core.$strip>;
|
|
77
78
|
export type IdentityResponse = z.infer<typeof IdentityResponseSchema>;
|
|
78
79
|
/** Hex SHA-256 of a PEM-encoded X.509 cert's DER bytes. */
|
package/dist/remotes/identity.js
CHANGED
|
@@ -70,6 +70,12 @@ export const IdentityResponseSchema = z.object({
|
|
|
70
70
|
gezelVersion: z.string().optional(),
|
|
71
71
|
protocolVersion: z.number().optional(),
|
|
72
72
|
serviceRole: z.enum(['user', 'machine-engine', 'legacy-full']).optional(),
|
|
73
|
+
/**
|
|
74
|
+
* How a client may pair: `code` (a connect code shown on this device,
|
|
75
|
+
* SPAKE2 over `/v1/apps/pair/*`) and/or `approval` (`/v1/apps/register`,
|
|
76
|
+
* approved here). Absent → a server from before connect codes: approval only.
|
|
77
|
+
*/
|
|
78
|
+
pairingMethods: z.array(z.string()).max(8).optional(),
|
|
73
79
|
});
|
|
74
80
|
/** Hex SHA-256 of a PEM-encoded X.509 cert's DER bytes. */
|
|
75
81
|
export function certFingerprintFromPem(certPem) {
|
|
@@ -17,6 +17,8 @@ import type { PairedRemote, RemotesRegistry } from './types.js';
|
|
|
17
17
|
export type ClosableFetch = typeof fetch & {
|
|
18
18
|
close(): Promise<void>;
|
|
19
19
|
};
|
|
20
|
+
/** Connect timeout for a paired peer (see {@link createPinnedFetch}). */
|
|
21
|
+
export declare const PINNED_CONNECT_TIMEOUT_MS = 5000;
|
|
20
22
|
/**
|
|
21
23
|
* Pin a single self-signed cert as the sole CA and skip hostname/SAN
|
|
22
24
|
* verification. Long-call-friendly (no header/body timeouts) for streamed
|
|
@@ -15,6 +15,29 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
17
17
|
import { IdentityResponseSchema, certFingerprintFromPem, fingerprintOfPublicKeyPem, verifyIdentitySignature, } from './identity.js';
|
|
18
|
+
/** Connect timeout for a paired peer (see {@link createPinnedFetch}). */
|
|
19
|
+
export const PINNED_CONNECT_TIMEOUT_MS = 5_000;
|
|
20
|
+
/** Failures before any TLS handshake: no certificate was presented, so none can be stale. */
|
|
21
|
+
const NO_HANDSHAKE_CODES = new Set([
|
|
22
|
+
'ECONNREFUSED',
|
|
23
|
+
'EHOSTUNREACH',
|
|
24
|
+
'ENETUNREACH',
|
|
25
|
+
'EHOSTDOWN',
|
|
26
|
+
'ENETDOWN',
|
|
27
|
+
'ETIMEDOUT',
|
|
28
|
+
'EADDRNOTAVAIL',
|
|
29
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
30
|
+
]);
|
|
31
|
+
function failedBeforeHandshake(err) {
|
|
32
|
+
let current = err;
|
|
33
|
+
for (let depth = 0; depth < 6 && current && typeof current === 'object'; depth++) {
|
|
34
|
+
const code = current.code;
|
|
35
|
+
if (typeof code === 'string' && NO_HANDSHAKE_CODES.has(code))
|
|
36
|
+
return true;
|
|
37
|
+
current = current.cause;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
18
41
|
async function closeFetch(fetchImpl) {
|
|
19
42
|
const close = fetchImpl?.close;
|
|
20
43
|
if (typeof close === 'function')
|
|
@@ -34,6 +57,10 @@ export function createPinnedFetch(certPem) {
|
|
|
34
57
|
// IP; the cert identity itself is the pin, reinforced by the identity
|
|
35
58
|
// signature verified at connect time.
|
|
36
59
|
checkServerIdentity: () => undefined,
|
|
60
|
+
// A peer on the local network answers a connect within milliseconds.
|
|
61
|
+
// Waiting undici's default 10 s for one that left the network only
|
|
62
|
+
// delays the fallback; streamed replies keep their unlimited timeouts.
|
|
63
|
+
timeout: PINNED_CONNECT_TIMEOUT_MS,
|
|
37
64
|
},
|
|
38
65
|
allowH2: true,
|
|
39
66
|
headersTimeout: 0,
|
|
@@ -100,7 +127,9 @@ export function createPairedRemoteFetch(remote, registry, deps = {}) {
|
|
|
100
127
|
return await active(url, init);
|
|
101
128
|
}
|
|
102
129
|
catch (originalError) {
|
|
103
|
-
|
|
130
|
+
// A refresh re-dials the same address; when that address answered
|
|
131
|
+
// nothing at all it doubles the wait and fails the same way.
|
|
132
|
+
if (init?.signal?.aborted || failedBeforeHandshake(originalError))
|
|
104
133
|
throw originalError;
|
|
105
134
|
refresh ??= refreshPairedCertificate(remote, registry, makeBootstrap).then((cert) => {
|
|
106
135
|
const previous = active;
|