@duckmind/dm-windows-x64 0.61.3 → 0.61.5

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.
@@ -0,0 +1,1051 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ detectLlamaCpp,
4
+ detectLmStudio,
5
+ detectModels,
6
+ detectMtplx,
7
+ detectOllama,
8
+ detectOmlx,
9
+ detectOpenAI,
10
+ detectSglang,
11
+ detectVllm,
12
+ isNinferCards,
13
+ nearestTier,
14
+ parseNinferContext,
15
+ probeNinfer,
16
+ probeRequestCompat
17
+ } from "./detect.js";
18
+ function mockFetch(routes) {
19
+ vi.stubGlobal("fetch", vi.fn(async (url) => {
20
+ const key = String(url);
21
+ if (!(key in routes)) {
22
+ return { ok: false, json: async () => ({}) };
23
+ }
24
+ return { ok: true, json: async () => routes[key] };
25
+ }));
26
+ }
27
+ function mockFetchAllStatus(status) {
28
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status, json: async () => ({}) })));
29
+ }
30
+ function mockFetchAllReject(err) {
31
+ vi.stubGlobal("fetch", vi.fn(async () => {
32
+ throw err;
33
+ }));
34
+ }
35
+ function mockOllama(tagsResponse, showResponsesByModel, psResponse = { models: [] }) {
36
+ vi.stubGlobal("fetch", vi.fn(async (url, init) => {
37
+ const u = String(url);
38
+ if (u.endsWith("/api/tags")) {
39
+ return { ok: true, json: async () => tagsResponse };
40
+ }
41
+ if (u.endsWith("/api/ps")) {
42
+ return { ok: true, json: async () => psResponse };
43
+ }
44
+ if (u.endsWith("/api/show")) {
45
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
46
+ const resp = showResponsesByModel[body.model];
47
+ if (!resp)
48
+ return { ok: false, json: async () => ({}) };
49
+ return { ok: true, json: async () => resp };
50
+ }
51
+ return { ok: false, json: async () => ({}) };
52
+ }));
53
+ }
54
+ afterEach(() => {
55
+ vi.unstubAllGlobals();
56
+ });
57
+ describe("detectMtplx", () => {
58
+ it("returns null when /health lacks model or context_window", async () => {
59
+ mockFetch({ "http://x/health": {} });
60
+ expect(await detectMtplx("http://x", "")).toBeNull();
61
+ });
62
+ it("returns null on network failure / non-200", async () => {
63
+ mockFetch({});
64
+ expect(await detectMtplx("http://x", "")).toBeNull();
65
+ });
66
+ it("parses a full /health response", async () => {
67
+ mockFetch({
68
+ "http://x/health": {
69
+ model: "org/Model-7B",
70
+ context_window: 65536,
71
+ max_response_tokens: 4096,
72
+ enable_thinking: true,
73
+ vision: { enabled: true }
74
+ }
75
+ });
76
+ expect(await detectMtplx("http://x", "")).toEqual({
77
+ apiType: "mtplx",
78
+ models: [
79
+ {
80
+ id: "org/Model-7B",
81
+ name: "Model-7B",
82
+ contextWindow: 65536,
83
+ maxTokens: 4096,
84
+ reasoning: true,
85
+ input: ["text", "image"]
86
+ }
87
+ ]
88
+ });
89
+ });
90
+ it("falls back to capped maxTokens when max_response_tokens is absent", async () => {
91
+ mockFetch({ "http://x/health": { model: "m", context_window: 4000 } });
92
+ const result = await detectMtplx("http://x", "");
93
+ expect(result?.models[0].maxTokens).toBe(2000);
94
+ });
95
+ it("treats reasoning:'on' the same as enable_thinking:true", async () => {
96
+ mockFetch({ "http://x/health": { model: "m", context_window: 4000, reasoning: "on" } });
97
+ const result = await detectMtplx("http://x", "");
98
+ expect(result?.models[0].reasoning).toBe(true);
99
+ });
100
+ it("defaults to text-only input when vision is absent or disabled", async () => {
101
+ mockFetch({ "http://x/health": { model: "m", context_window: 4000 } });
102
+ const result = await detectMtplx("http://x", "");
103
+ expect(result?.models[0].input).toEqual(["text"]);
104
+ });
105
+ });
106
+ describe("detectOmlx", () => {
107
+ it("returns null when the server has no models", async () => {
108
+ mockFetch({ "http://x/v1/models/status": { models: [] } });
109
+ expect(await detectOmlx("http://x", "")).toBeNull();
110
+ });
111
+ it("filters out non llm/vlm model types", async () => {
112
+ mockFetch({
113
+ "http://x/v1/models/status": {
114
+ models: [
115
+ { id: "a", model_type: "llm", max_context_window: 8192 },
116
+ { id: "b", model_type: "embedding", max_context_window: 8192 },
117
+ { id: "c" }
118
+ ]
119
+ }
120
+ });
121
+ const result = await detectOmlx("http://x", "");
122
+ expect(result?.models.map((m) => m.id)).toEqual(["a"]);
123
+ });
124
+ it("prefers model_alias, falls back to display_name then id", async () => {
125
+ mockFetch({
126
+ "http://x/v1/models/status": {
127
+ models: [{ id: "id1", display_name: "Display", model_type: "llm", max_context_window: 4096 }]
128
+ }
129
+ });
130
+ const result = await detectOmlx("http://x", "");
131
+ expect(result?.models[0].name).toBe("Display");
132
+ });
133
+ it("marks vlm models as vision-capable and reads thinking_default", async () => {
134
+ mockFetch({
135
+ "http://x/v1/models/status": {
136
+ models: [
137
+ {
138
+ id: "id1",
139
+ model_alias: "Alias",
140
+ model_type: "vlm",
141
+ max_context_window: 4096,
142
+ thinking_default: true
143
+ }
144
+ ]
145
+ }
146
+ });
147
+ const result = await detectOmlx("http://x", "");
148
+ expect(result?.models[0]).toMatchObject({
149
+ name: "Alias",
150
+ input: ["text", "image"],
151
+ reasoning: true
152
+ });
153
+ });
154
+ it("reads loaded status and estimated_size", async () => {
155
+ mockFetch({
156
+ "http://x/v1/models/status": {
157
+ models: [
158
+ {
159
+ id: "id1",
160
+ model_type: "llm",
161
+ max_context_window: 4096,
162
+ loaded: true,
163
+ estimated_size: 4912898304
164
+ }
165
+ ]
166
+ }
167
+ });
168
+ const result = await detectOmlx("http://x", "");
169
+ expect(result?.models[0]).toMatchObject({ loaded: true, sizeBytes: 4912898304 });
170
+ });
171
+ it("treats a missing loaded field as not loaded", async () => {
172
+ mockFetch({
173
+ "http://x/v1/models/status": {
174
+ models: [{ id: "id1", model_type: "llm", max_context_window: 4096 }]
175
+ }
176
+ });
177
+ const result = await detectOmlx("http://x", "");
178
+ expect(result?.models[0].loaded).toBe(false);
179
+ });
180
+ });
181
+ describe("detectLmStudio", () => {
182
+ it("returns null when the server has no models", async () => {
183
+ mockFetch({ "http://x/api/v1/models": { models: [] } });
184
+ expect(await detectLmStudio("http://x", "")).toBeNull();
185
+ });
186
+ it("filters by type and reads capabilities", async () => {
187
+ mockFetch({
188
+ "http://x/api/v1/models": {
189
+ models: [
190
+ {
191
+ key: "k1",
192
+ type: "llm",
193
+ max_context_length: 32768,
194
+ capabilities: { reasoning: { allowed_options: ["low"] } }
195
+ },
196
+ { key: "k2", type: "embedding" }
197
+ ]
198
+ }
199
+ });
200
+ const result = await detectLmStudio("http://x", "");
201
+ expect(result?.models).toHaveLength(1);
202
+ expect(result?.models[0]).toMatchObject({ id: "k1", contextWindow: 32768, reasoning: true });
203
+ });
204
+ it("marks vision-capable models via capabilities.vision or vlm type", async () => {
205
+ mockFetch({
206
+ "http://x/api/v1/models": {
207
+ models: [{ key: "k1", type: "vlm", capabilities: { vision: true } }]
208
+ }
209
+ });
210
+ const result = await detectLmStudio("http://x", "");
211
+ expect(result?.models[0].input).toEqual(["text", "image"]);
212
+ });
213
+ it("reads loaded status, size, and quantization", async () => {
214
+ mockFetch({
215
+ "http://x/api/v1/models": {
216
+ models: [
217
+ {
218
+ key: "k1",
219
+ type: "llm",
220
+ max_context_length: 4096,
221
+ loaded_instances: [{}],
222
+ size_bytes: 4912898304,
223
+ quantization: { name: "Q4_K_M" }
224
+ }
225
+ ]
226
+ }
227
+ });
228
+ const result = await detectLmStudio("http://x", "");
229
+ expect(result?.models[0]).toMatchObject({
230
+ loaded: true,
231
+ sizeBytes: 4912898304,
232
+ quantization: "Q4_K_M"
233
+ });
234
+ });
235
+ it("treats an empty loaded_instances array as not loaded", async () => {
236
+ mockFetch({
237
+ "http://x/api/v1/models": {
238
+ models: [{ key: "k1", type: "llm", loaded_instances: [] }]
239
+ }
240
+ });
241
+ const result = await detectLmStudio("http://x", "");
242
+ expect(result?.models[0].loaded).toBe(false);
243
+ });
244
+ });
245
+ describe("detectLlamaCpp", () => {
246
+ it("returns null when /props lacks n_ctx or model_path", async () => {
247
+ mockFetch({ "http://x/props": { default_generation_settings: {} } });
248
+ expect(await detectLlamaCpp("http://x", "")).toBeNull();
249
+ });
250
+ it("combines /props (context, vision) with /v1/models (alias-aware id)", async () => {
251
+ mockFetch({
252
+ "http://x/props": {
253
+ default_generation_settings: { n_ctx: 8192 },
254
+ model_path: "../models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
255
+ modalities: { vision: true }
256
+ },
257
+ "http://x/v1/models": {
258
+ data: [{ id: "gpt-4o-mini", meta: { n_ctx_train: 131072 } }]
259
+ }
260
+ });
261
+ const result = await detectLlamaCpp("http://x", "");
262
+ expect(result).toEqual({
263
+ apiType: "llamacpp",
264
+ models: [
265
+ {
266
+ id: "gpt-4o-mini",
267
+ name: "gpt-4o-mini",
268
+ contextWindow: 8192,
269
+ maxTokens: 4096,
270
+ reasoning: false,
271
+ input: ["text", "image"]
272
+ }
273
+ ]
274
+ });
275
+ });
276
+ it("derives a name from the model file basename when no --alias is set", async () => {
277
+ mockFetch({
278
+ "http://x/props": {
279
+ default_generation_settings: { n_ctx: 4096 },
280
+ model_path: "../models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
281
+ }
282
+ });
283
+ const result = await detectLlamaCpp("http://x", "");
284
+ expect(result?.models[0]).toMatchObject({
285
+ id: "../models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
286
+ name: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
287
+ });
288
+ });
289
+ it("falls back to n_ctx_train when /props doesn't report a running context", async () => {
290
+ mockFetch({
291
+ "http://x/props": {
292
+ default_generation_settings: { n_ctx: 0 },
293
+ model_path: "/models/m.gguf"
294
+ },
295
+ "http://x/v1/models": { data: [{ id: "/models/m.gguf", meta: { n_ctx_train: 131072 } }] }
296
+ });
297
+ const result = await detectLlamaCpp("http://x", "");
298
+ expect(result?.models[0].contextWindow).toBe(131072);
299
+ });
300
+ it("reads file size from /v1/models meta", async () => {
301
+ mockFetch({
302
+ "http://x/props": { default_generation_settings: { n_ctx: 4096 }, model_path: "/models/m.gguf" },
303
+ "http://x/v1/models": { data: [{ id: "/models/m.gguf", meta: { size: 4912898304 } }] }
304
+ });
305
+ const result = await detectLlamaCpp("http://x", "");
306
+ expect(result?.models[0].sizeBytes).toBe(4912898304);
307
+ });
308
+ });
309
+ function mockSglang(overrides = {}) {
310
+ mockFetch({
311
+ "http://x/get_model_info": overrides.modelInfo ?? {
312
+ model_path: "/models/Qwen3.8-27B-NVFP4",
313
+ is_generation: true,
314
+ has_image_understanding: true
315
+ },
316
+ "http://x/get_server_info": overrides.serverInfo ?? { reasoning_parser: "qwen3", context_length: null },
317
+ "http://x/v1/models": overrides.models ?? {
318
+ data: [{ id: "/models/Qwen3.8-27B-NVFP4", owned_by: "sglang", max_model_len: 262144 }]
319
+ },
320
+ "http://x/api/tags": {
321
+ models: [
322
+ {
323
+ name: "/models/Qwen3.8-27B-NVFP4",
324
+ model: "/models/Qwen3.8-27B-NVFP4",
325
+ details: { format: "sglang" }
326
+ }
327
+ ]
328
+ },
329
+ "http://x/api/show": { model_info: {}, capabilities: ["completion"] }
330
+ });
331
+ }
332
+ function mockSglangProbe(statuses, routes = {}) {
333
+ vi.stubGlobal("fetch", vi.fn(async (url, init) => {
334
+ const u = String(url);
335
+ if (u.endsWith("/chat/completions")) {
336
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
337
+ const isDeveloper = (body.messages ?? []).some((m) => m.role === "developer");
338
+ const key = isDeveloper ? "__developer__" : String(body.reasoning_effort);
339
+ const status = statuses[key] ?? 400;
340
+ if (status === "network-error")
341
+ throw new Error("connection refused");
342
+ return { ok: status === 200, status, text: async () => "", json: async () => ({}) };
343
+ }
344
+ if (u in routes)
345
+ return { ok: true, status: 200, json: async () => routes[u] };
346
+ return { ok: false, status: 404, json: async () => ({}) };
347
+ }));
348
+ }
349
+ const QWEN38_STATUSES = {
350
+ none: 200,
351
+ minimal: 400,
352
+ low: 200,
353
+ medium: 200,
354
+ high: 400,
355
+ xhigh: 200,
356
+ max: 400,
357
+ __developer__: 400
358
+ };
359
+ describe("nearestTier", () => {
360
+ const accepted = ["none", "low", "medium", "xhigh"];
361
+ it("passes through a level the model accepts verbatim", () => {
362
+ expect(nearestTier("low", accepted)).toBe("low");
363
+ expect(nearestTier("xhigh", accepted)).toBe("xhigh");
364
+ });
365
+ it("lifts a level below the model's floor up to it", () => {
366
+ expect(nearestTier("minimal", accepted)).toBe("low");
367
+ });
368
+ it("drops a level above the model's ceiling down to it", () => {
369
+ expect(nearestTier("max", accepted)).toBe("xhigh");
370
+ });
371
+ it("breaks a tie toward the weaker tier", () => {
372
+ expect(nearestTier("high", accepted)).toBe("medium");
373
+ });
374
+ it("never resolves a thinking level to none", () => {
375
+ expect(nearestTier("minimal", ["none", "xhigh"])).toBe("xhigh");
376
+ });
377
+ it("returns undefined when the model accepts no thinking tier", () => {
378
+ expect(nearestTier("medium", ["none"])).toBeUndefined();
379
+ });
380
+ });
381
+ describe("probeRequestCompat", () => {
382
+ it("reproduces the map hand-verified against a Qwen3.8 server", async () => {
383
+ mockSglangProbe(QWEN38_STATUSES);
384
+ const result = await probeRequestCompat("http://x/v1", "", "m");
385
+ expect(result).toEqual({
386
+ compat: { supportsReasoningEffort: true, supportsDeveloperRole: false },
387
+ thinkingLevelMap: {
388
+ off: "none",
389
+ minimal: "low",
390
+ low: "low",
391
+ medium: "medium",
392
+ high: "medium",
393
+ xhigh: "xhigh",
394
+ max: "xhigh"
395
+ }
396
+ });
397
+ });
398
+ it("records the developer role when the template accepts it", async () => {
399
+ mockSglangProbe({ ...QWEN38_STATUSES, __developer__: 200 });
400
+ const result = await probeRequestCompat("http://x/v1", "", "m");
401
+ expect(result.compat?.supportsDeveloperRole).toBe(true);
402
+ });
403
+ it("leaves off unmapped when the model cannot disable reasoning", async () => {
404
+ mockSglangProbe({ ...QWEN38_STATUSES, none: 400 });
405
+ const result = await probeRequestCompat("http://x/v1", "", "m");
406
+ expect(result.thinkingLevelMap).not.toHaveProperty("off");
407
+ expect(result.thinkingLevelMap?.medium).toBe("medium");
408
+ });
409
+ it("claims nothing when the server rejects every tier", async () => {
410
+ mockSglangProbe({ __developer__: 400 });
411
+ expect(await probeRequestCompat("http://x/v1", "", "m")).toEqual({});
412
+ });
413
+ it("claims nothing when a single tier fails for a reason other than rejection", async () => {
414
+ mockSglangProbe({ ...QWEN38_STATUSES, medium: 503 });
415
+ expect(await probeRequestCompat("http://x/v1", "", "m")).toEqual({});
416
+ });
417
+ it("treats a rate-limited tier as unanswered, not as unsupported", async () => {
418
+ mockSglangProbe({ ...QWEN38_STATUSES, xhigh: 429 });
419
+ expect(await probeRequestCompat("http://x/v1", "", "m")).toEqual({});
420
+ });
421
+ it("still trusts a sweep when only the developer probe fails", async () => {
422
+ mockSglangProbe({ ...QWEN38_STATUSES, __developer__: "network-error" });
423
+ const result = await probeRequestCompat("http://x/v1", "", "m");
424
+ expect(result.compat).toEqual({ supportsReasoningEffort: true, supportsDeveloperRole: false });
425
+ expect(result.thinkingLevelMap?.high).toBe("medium");
426
+ });
427
+ it("claims nothing when no probe gets a reply at all", async () => {
428
+ mockSglangProbe({
429
+ none: "network-error",
430
+ minimal: "network-error",
431
+ low: "network-error",
432
+ medium: "network-error",
433
+ high: "network-error",
434
+ xhigh: "network-error",
435
+ max: "network-error",
436
+ __developer__: "network-error"
437
+ });
438
+ expect(await probeRequestCompat("http://x/v1", "", "m")).toEqual({});
439
+ });
440
+ });
441
+ describe("detectSglang", () => {
442
+ it("reads context from /v1/models and capabilities from the SGLang endpoints", async () => {
443
+ mockSglang();
444
+ const result = await detectSglang("http://x", "http://x/v1", "");
445
+ expect(result?.apiType).toBe("sglang");
446
+ expect(result?.models).toHaveLength(1);
447
+ expect(result?.models[0]).toMatchObject({
448
+ id: "/models/Qwen3.8-27B-NVFP4",
449
+ name: "Qwen3.8-27B-NVFP4",
450
+ contextWindow: 262144,
451
+ maxTokens: 65536,
452
+ reasoning: true,
453
+ input: ["text", "image"]
454
+ });
455
+ });
456
+ it("applies Qwen's published thinking temperature", async () => {
457
+ mockSglang({
458
+ modelInfo: {
459
+ model_path: "/models/m",
460
+ is_generation: true,
461
+ model_type: "qwen3_5",
462
+ has_image_understanding: true
463
+ }
464
+ });
465
+ const result = await detectSglang("http://x", "http://x/v1", "");
466
+ expect(result?.models[0].samplingParams).toEqual({ temperature: 0.6 });
467
+ });
468
+ it("leaves an unfamiliar family on the server's own default", async () => {
469
+ mockSglang({
470
+ modelInfo: { model_path: "/models/m", is_generation: true, model_type: "llama" }
471
+ });
472
+ const result = await detectSglang("http://x", "http://x/v1", "");
473
+ expect(result?.models[0].samplingParams).toBeUndefined();
474
+ });
475
+ it("leaves a non-reasoning Qwen alone", async () => {
476
+ mockSglang({
477
+ modelInfo: { model_path: "/models/m", is_generation: true, model_type: "qwen3_5" },
478
+ serverInfo: { reasoning_parser: null }
479
+ });
480
+ const result = await detectSglang("http://x", "http://x/v1", "");
481
+ expect(result?.models[0].samplingParams).toBeUndefined();
482
+ });
483
+ it("attaches the measured request compat to every model", async () => {
484
+ mockSglangProbe(QWEN38_STATUSES, {
485
+ "http://x/get_model_info": {
486
+ model_path: "/models/m",
487
+ is_generation: true,
488
+ has_image_understanding: true
489
+ },
490
+ "http://x/get_server_info": { reasoning_parser: "qwen3" },
491
+ "http://x/v1/models": { data: [{ id: "/models/m", max_model_len: 262144 }] }
492
+ });
493
+ const result = await detectSglang("http://x", "http://x/v1", "");
494
+ expect(result?.models[0].compat).toEqual({
495
+ supportsReasoningEffort: true,
496
+ supportsDeveloperRole: false
497
+ });
498
+ expect(result?.models[0].thinkingLevelMap?.high).toBe("medium");
499
+ });
500
+ it("reports no reasoning, and probes nothing, without a reasoning_parser", async () => {
501
+ mockSglang({ serverInfo: { reasoning_parser: null } });
502
+ const result = await detectSglang("http://x", "http://x/v1", "");
503
+ expect(result?.models[0].reasoning).toBe(false);
504
+ expect(result?.models[0].compat).toBeUndefined();
505
+ const calls = fetch.mock.calls;
506
+ expect(calls.some((c) => String(c[0]).endsWith("/chat/completions"))).toBe(false);
507
+ });
508
+ it("reports text-only when the model has no image understanding", async () => {
509
+ mockSglang({
510
+ modelInfo: { model_path: "/models/m", is_generation: true, has_image_understanding: false }
511
+ });
512
+ const result = await detectSglang("http://x", "http://x/v1", "");
513
+ expect(result?.models[0].input).toEqual(["text"]);
514
+ });
515
+ it("declines an embedding-only server so the chain keeps looking", async () => {
516
+ mockSglang({ modelInfo: { model_path: "/models/e", is_generation: false } });
517
+ expect(await detectSglang("http://x", "http://x/v1", "")).toBeNull();
518
+ });
519
+ it("returns null when /get_model_info is absent", async () => {
520
+ mockFetch({ "http://x/v1/models": { data: [{ id: "m", max_model_len: 4096 }] } });
521
+ expect(await detectSglang("http://x", "http://x/v1", "")).toBeNull();
522
+ });
523
+ it("wins over Ollama in the chain despite the compatibility shim", async () => {
524
+ mockSglang();
525
+ const result = await detectModels("http://x/v1", "");
526
+ expect(result.apiType).toBe("sglang");
527
+ expect(result.models[0].input).toEqual(["text", "image"]);
528
+ });
529
+ it("leaves a real Ollama server to the Ollama probe", async () => {
530
+ mockOllama({ models: [{ name: "llama3:8b", model: "llama3:8b" }] }, {
531
+ "llama3:8b": {
532
+ model_info: { "general.architecture": "llama", "llama.context_length": 8192 },
533
+ capabilities: ["completion", "thinking"]
534
+ }
535
+ });
536
+ const result = await detectModels("http://x/v1", "");
537
+ expect(result.apiType).toBe("ollama");
538
+ });
539
+ });
540
+ describe("detectOllama", () => {
541
+ it("returns null when there are no local models", async () => {
542
+ mockOllama({ models: [] }, {});
543
+ expect(await detectOllama("http://x", "")).toBeNull();
544
+ });
545
+ it("reads context_length via the architecture-prefixed key and thinking/vision capabilities", async () => {
546
+ mockOllama({ models: [{ name: "deepseek-r1:latest", model: "deepseek-r1:latest" }] }, {
547
+ "deepseek-r1:latest": {
548
+ model_info: { "general.architecture": "qwen2", "qwen2.context_length": 32768 },
549
+ capabilities: ["completion", "thinking"]
550
+ }
551
+ });
552
+ const result = await detectOllama("http://x", "");
553
+ expect(result).toEqual({
554
+ apiType: "ollama",
555
+ models: [
556
+ {
557
+ id: "deepseek-r1:latest",
558
+ name: "deepseek-r1:latest",
559
+ contextWindow: 32768,
560
+ maxTokens: 16384,
561
+ reasoning: true,
562
+ input: ["text"],
563
+ loaded: false
564
+ }
565
+ ]
566
+ });
567
+ });
568
+ it("marks vision-capable models via the vision capability", async () => {
569
+ mockOllama({ models: [{ name: "llava:latest", model: "llava:latest" }] }, { "llava:latest": { model_info: {}, capabilities: ["completion", "vision"] } });
570
+ const result = await detectOllama("http://x", "");
571
+ expect(result?.models[0].input).toEqual(["text", "image"]);
572
+ });
573
+ it("queries /api/show independently per model", async () => {
574
+ mockOllama({
575
+ models: [
576
+ { name: "a:latest", model: "a:latest" },
577
+ { name: "b:latest", model: "b:latest" }
578
+ ]
579
+ }, {
580
+ "a:latest": { model_info: { "general.architecture": "llama", "llama.context_length": 8192 }, capabilities: [] },
581
+ "b:latest": { model_info: { "general.architecture": "llama", "llama.context_length": 4096 }, capabilities: ["thinking"] }
582
+ });
583
+ const result = await detectOllama("http://x", "");
584
+ expect(result?.models.map((m) => [m.id, m.contextWindow, m.reasoning])).toEqual([
585
+ ["a:latest", 8192, false],
586
+ ["b:latest", 4096, true]
587
+ ]);
588
+ });
589
+ it("defaults to 32768 when /api/show fails or lacks context_length", async () => {
590
+ mockOllama({ models: [{ name: "a:latest", model: "a:latest" }] }, {});
591
+ const result = await detectOllama("http://x", "");
592
+ expect(result?.models[0].contextWindow).toBe(32768);
593
+ });
594
+ it("reads size and quantization directly from /api/tags, and loaded state from /api/ps", async () => {
595
+ mockOllama({
596
+ models: [
597
+ { name: "a:latest", model: "a:latest", size: 4683075271, details: { quantization_level: "Q4_K_M" } },
598
+ { name: "b:latest", model: "b:latest", size: 2019393189, details: { quantization_level: "Q8_0" } }
599
+ ]
600
+ }, {}, { models: [{ model: "a:latest" }] });
601
+ const result = await detectOllama("http://x", "");
602
+ expect(result?.models).toEqual([
603
+ expect.objectContaining({
604
+ id: "a:latest",
605
+ sizeBytes: 4683075271,
606
+ quantization: "Q4_K_M",
607
+ loaded: true
608
+ }),
609
+ expect.objectContaining({
610
+ id: "b:latest",
611
+ sizeBytes: 2019393189,
612
+ quantization: "Q8_0",
613
+ loaded: false
614
+ })
615
+ ]);
616
+ });
617
+ });
618
+ describe("detectVllm", () => {
619
+ it("returns null when /version doesn't respond", async () => {
620
+ mockFetch({ "http://x/v1/models": { data: [{ id: "m1", max_model_len: 4096 }] } });
621
+ expect(await detectVllm("http://x", "http://x/v1", "")).toBeNull();
622
+ });
623
+ it("returns null when /version responds but no model has max_model_len", async () => {
624
+ mockFetch({
625
+ "http://x/version": { version: "0.6.3" },
626
+ "http://x/v1/models": { data: [{ id: "m1" }] }
627
+ });
628
+ expect(await detectVllm("http://x", "http://x/v1", "")).toBeNull();
629
+ });
630
+ it("requires both /version and a max_model_len-bearing /v1/models entry", async () => {
631
+ mockFetch({
632
+ "http://x/version": { version: "0.6.3" },
633
+ "http://x/v1/models": { data: [{ id: "org/Model-7B", max_model_len: 32768 }] }
634
+ });
635
+ const result = await detectVllm("http://x", "http://x/v1", "");
636
+ expect(result).toEqual({
637
+ apiType: "vllm",
638
+ models: [
639
+ {
640
+ id: "org/Model-7B",
641
+ name: "Model-7B",
642
+ contextWindow: 32768,
643
+ maxTokens: 8192,
644
+ reasoning: false,
645
+ input: ["text"]
646
+ }
647
+ ]
648
+ });
649
+ });
650
+ });
651
+ describe("detectOpenAI", () => {
652
+ it("reads max_model_len, then context_window, then defaults to 32768", async () => {
653
+ mockFetch({
654
+ "http://x/v1/models": {
655
+ data: [{ id: "m1", max_model_len: 16384 }, { id: "m2", context_window: 8192 }, { id: "m3" }]
656
+ }
657
+ });
658
+ const result = await detectOpenAI("http://x/v1", "");
659
+ expect(result.models.map((m) => m.contextWindow)).toEqual([16384, 8192, 32768]);
660
+ });
661
+ it("reads OpenRouter-style context_length and top_provider.context_length", async () => {
662
+ mockFetch({
663
+ "http://x/v1/models": {
664
+ data: [
665
+ { id: "m1", context_length: 131072 },
666
+ { id: "m2", context_length: 8192, top_provider: { context_length: 131072 } }
667
+ ]
668
+ }
669
+ });
670
+ const result = await detectOpenAI("http://x/v1", "");
671
+ expect(result.models.map((m) => m.contextWindow)).toEqual([131072, 131072]);
672
+ });
673
+ it("reads name, reasoning and vision from an OpenRouter-style card", async () => {
674
+ mockFetch({
675
+ "http://x/v1/models": {
676
+ data: [
677
+ {
678
+ id: "deepseek-v4-flash",
679
+ name: "DeepSeek V4 Flash",
680
+ context_length: 131072,
681
+ top_provider: { context_length: 131072, max_completion_tokens: 131072 },
682
+ architecture: { input_modalities: ["text", "image"] },
683
+ supported_parameters: ["tools", "temperature", "reasoning_effort"]
684
+ }
685
+ ]
686
+ }
687
+ });
688
+ const result = await detectOpenAI("http://x/v1", "");
689
+ expect(result.models[0]).toMatchObject({
690
+ id: "deepseek-v4-flash",
691
+ name: "DeepSeek V4 Flash",
692
+ contextWindow: 131072,
693
+ reasoning: true,
694
+ input: ["text", "image"]
695
+ });
696
+ });
697
+ it("ignores a max_completion_tokens that just repeats the context window", async () => {
698
+ mockFetch({
699
+ "http://x/v1/models": {
700
+ data: [
701
+ { id: "m1", context_length: 131072, top_provider: { max_completion_tokens: 131072 } },
702
+ { id: "m2", context_length: 131072, top_provider: { max_completion_tokens: 4096 } }
703
+ ]
704
+ }
705
+ });
706
+ const result = await detectOpenAI("http://x/v1", "");
707
+ expect(result.models.map((m) => m.maxTokens)).toEqual([8192, 4096]);
708
+ });
709
+ it("returns an empty model list when the response has no data", async () => {
710
+ mockFetch({ "http://x/v1/models": {} });
711
+ const result = await detectOpenAI("http://x/v1", "");
712
+ expect(result).toEqual({ apiType: "openai", models: [] });
713
+ });
714
+ it("gives a reasoning model more headroom than a plain one", async () => {
715
+ mockFetch({
716
+ "http://x/v1/models": {
717
+ data: [
718
+ { id: "plain", context_length: 262144 },
719
+ { id: "thinker", context_length: 262144, supported_parameters: ["reasoning_effort"] }
720
+ ]
721
+ }
722
+ });
723
+ const result = await detectOpenAI("http://x/v1", "");
724
+ expect(result.models.map((m) => m.maxTokens)).toEqual([8192, 65536]);
725
+ });
726
+ it("keeps the half-context floor below either ceiling", async () => {
727
+ mockFetch({
728
+ "http://x/v1/models": {
729
+ data: [{ id: "small", context_length: 8192, supported_parameters: ["reasoning_effort"] }]
730
+ }
731
+ });
732
+ const result = await detectOpenAI("http://x/v1", "");
733
+ expect(result.models[0].maxTokens).toBe(4096);
734
+ });
735
+ });
736
+ function ds4Card(id, ctx = 131072, maxCompletion = 131072) {
737
+ return {
738
+ id,
739
+ object: "model",
740
+ created: 1767225600,
741
+ owned_by: "ds4.c",
742
+ name: "DeepSeek V4 Flash",
743
+ context_length: ctx,
744
+ top_provider: { context_length: ctx, max_completion_tokens: maxCompletion, is_moderated: false },
745
+ supported_parameters: ["tools", "tool_choice", "max_tokens", "temperature", "reasoning_effort"]
746
+ };
747
+ }
748
+ describe("ds4", () => {
749
+ it("identifies ds4 by owned_by and registers every alias", async () => {
750
+ mockFetch({
751
+ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash"), ds4Card("deepseek-v4-pro")] }
752
+ });
753
+ const result = await detectOpenAI("http://x/v1", "");
754
+ expect(result.apiType).toBe("ds4");
755
+ expect(result.models.map((m) => m.id)).toEqual(["deepseek-v4-flash", "deepseek-v4-pro"]);
756
+ expect(result.models[0]).toMatchObject({
757
+ name: "DeepSeek V4 Flash",
758
+ contextWindow: 131072,
759
+ maxTokens: 65536,
760
+ reasoning: true,
761
+ input: ["text"],
762
+ compat: { supportsReasoningEffort: true, supportsDeveloperRole: true }
763
+ });
764
+ });
765
+ it("maps the two thinking levels ds4 would otherwise get wrong", async () => {
766
+ mockFetch({ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash")] } });
767
+ const result = await detectOpenAI("http://x/v1", "");
768
+ expect(result.models[0].thinkingLevelMap).toEqual({ off: "none", max: "max" });
769
+ });
770
+ it("leaves xhigh unmapped so DM does not offer a duplicate of high", async () => {
771
+ mockFetch({ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash")] } });
772
+ const result = await detectOpenAI("http://x/v1", "");
773
+ expect(result.models[0].thinkingLevelMap).not.toHaveProperty("xhigh");
774
+ });
775
+ it("identifies a GLM-DSA engine's alias set too", async () => {
776
+ mockFetch({
777
+ "http://x/v1/models": {
778
+ data: [ds4Card("glm-5.2"), ds4Card("glm-5.2-chat"), ds4Card("glm-5.2-reasoner")]
779
+ }
780
+ });
781
+ const result = await detectOpenAI("http://x/v1", "");
782
+ expect(result.apiType).toBe("ds4");
783
+ expect(result.models).toHaveLength(3);
784
+ });
785
+ it("honours --default-tokens when it restricts output below the context", async () => {
786
+ mockFetch({ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash", 131072, 4096)] } });
787
+ const result = await detectOpenAI("http://x/v1", "");
788
+ expect(result.models[0].maxTokens).toBe(4096);
789
+ });
790
+ it("stays generic when only some cards carry the ds4 owner", async () => {
791
+ mockFetch({
792
+ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash"), { id: "other", owned_by: "acme" }] }
793
+ });
794
+ const result = await detectOpenAI("http://x/v1", "");
795
+ expect(result.apiType).toBe("openai");
796
+ expect(result.models.every((m) => m.compat === undefined)).toBe(true);
797
+ });
798
+ it("reaches ds4 through the full detection chain", async () => {
799
+ mockFetch({ "http://x/v1/models": { data: [ds4Card("deepseek-v4-flash")] } });
800
+ const result = await detectModels("http://x/v1", "");
801
+ expect(result.apiType).toBe("ds4");
802
+ });
803
+ });
804
+ describe("detectModels chain", () => {
805
+ it("prefers MTPLX when its /health responds with valid data", async () => {
806
+ mockFetch({ "http://x/health": { model: "m", context_window: 4096 } });
807
+ const result = await detectModels("http://x/v1", "");
808
+ expect(result.apiType).toBe("mtplx");
809
+ });
810
+ it("falls through to oMLX when MTPLX is absent", async () => {
811
+ mockFetch({
812
+ "http://x/v1/models/status": {
813
+ models: [{ id: "a", model_type: "llm", max_context_window: 4096 }]
814
+ }
815
+ });
816
+ const result = await detectModels("http://x/v1", "");
817
+ expect(result.apiType).toBe("omlx");
818
+ });
819
+ it("falls through to LM Studio when MTPLX and oMLX are absent", async () => {
820
+ mockFetch({
821
+ "http://x/api/v1/models": {
822
+ models: [{ key: "k1", type: "llm", max_context_length: 4096 }]
823
+ }
824
+ });
825
+ const result = await detectModels("http://x/v1", "");
826
+ expect(result.apiType).toBe("lmstudio");
827
+ });
828
+ it("falls through to llama.cpp when MTPLX/oMLX/LM Studio are absent", async () => {
829
+ mockFetch({
830
+ "http://x/props": {
831
+ default_generation_settings: { n_ctx: 4096 },
832
+ model_path: "/models/m.gguf"
833
+ }
834
+ });
835
+ const result = await detectModels("http://x/v1", "");
836
+ expect(result.apiType).toBe("llamacpp");
837
+ });
838
+ it("falls through to Ollama when the above are all absent", async () => {
839
+ mockFetch({
840
+ "http://x/api/tags": { models: [{ name: "a:latest", model: "a:latest" }] }
841
+ });
842
+ const result = await detectModels("http://x/v1", "");
843
+ expect(result.apiType).toBe("ollama");
844
+ });
845
+ it("falls through to vLLM when only /version + max_model_len are present", async () => {
846
+ mockFetch({
847
+ "http://x/version": { version: "0.6.3" },
848
+ "http://x/v1/models": { data: [{ id: "m1", max_model_len: 4096 }] }
849
+ });
850
+ const result = await detectModels("http://x/v1", "");
851
+ expect(result.apiType).toBe("vllm");
852
+ });
853
+ it("falls all the way through to the generic OpenAI probe", async () => {
854
+ mockFetch({ "http://x/v1/models": { data: [{ id: "m1" }] } });
855
+ const result = await detectModels("http://x/v1", "");
856
+ expect(result.apiType).toBe("openai");
857
+ });
858
+ it("shares one AbortSignal across every probe instead of a fresh one each", async () => {
859
+ const seenSignals = [];
860
+ vi.stubGlobal("fetch", vi.fn(async (_url, init) => {
861
+ seenSignals.push(init?.signal);
862
+ return { ok: false, json: async () => ({}) };
863
+ }));
864
+ await detectModels("http://x/v1", "");
865
+ expect(seenSignals.length).toBeGreaterThanOrEqual(7);
866
+ expect(new Set(seenSignals).size).toBe(1);
867
+ expect(seenSignals[0]).toBeInstanceOf(AbortSignal);
868
+ });
869
+ it("combines an externally-provided signal with the chain deadline rather than replacing it", async () => {
870
+ const seenSignals = [];
871
+ vi.stubGlobal("fetch", vi.fn(async (_url, init) => {
872
+ seenSignals.push(init?.signal);
873
+ return { ok: false, json: async () => ({}) };
874
+ }));
875
+ const external = new AbortController().signal;
876
+ await detectModels("http://x/v1", "", external);
877
+ expect(new Set(seenSignals).size).toBe(1);
878
+ expect(seenSignals[0]).not.toBe(external);
879
+ });
880
+ });
881
+ describe("detectModels error summarization", () => {
882
+ it("reports an auth failure when every probe returns 401", async () => {
883
+ mockFetchAllStatus(401);
884
+ const result = await detectModels("http://x/v1", "wrong-key");
885
+ expect(result.models).toEqual([]);
886
+ expect(result.error).toBe("Authentication failed (HTTP 401) — check the API key.");
887
+ });
888
+ it("reports an auth failure when every probe returns 403", async () => {
889
+ mockFetchAllStatus(403);
890
+ const result = await detectModels("http://x/v1", "");
891
+ expect(result.error).toBe("Authentication failed (HTTP 403) — check the API key.");
892
+ });
893
+ it("reports a timeout when every probe aborts", async () => {
894
+ const abortError = new DOMException("The operation was aborted.", "AbortError");
895
+ mockFetchAllReject(abortError);
896
+ const result = await detectModels("http://x/v1", "");
897
+ expect(result.error).toBe("Timed out waiting for a response — check the server is running and reachable.");
898
+ });
899
+ it("reports a connection failure when every probe throws a network error", async () => {
900
+ mockFetchAllReject(new TypeError("fetch failed"));
901
+ const result = await detectModels("http://x/v1", "");
902
+ expect(result.error).toBe("Could not connect to the server — check the URL and that it's running.");
903
+ });
904
+ it("leaves error undefined when the server genuinely has zero models (no auth/timeout signal)", async () => {
905
+ mockFetch({ "http://x/v1/models": { data: [] } });
906
+ const result = await detectModels("http://x/v1", "");
907
+ expect(result.models).toEqual([]);
908
+ expect(result.error).toBeUndefined();
909
+ });
910
+ });
911
+ function ninferCard(id = "qwen3.8-27b") {
912
+ return { id, object: "model", created: 1786000000, owned_by: "ninfer" };
913
+ }
914
+ function mockNinfer(cards, replies = {}, tiers = { none: 200, low: 200, medium: 200, xhigh: 200 }) {
915
+ vi.stubGlobal("fetch", vi.fn(async (url, init) => {
916
+ const u = String(url);
917
+ const reply = (status, body) => ({ ok: status === 200, status, text: async () => body, json: async () => ({}) });
918
+ if (u.endsWith("/v1/models")) {
919
+ return { ok: true, status: 200, json: async () => ({ object: "list", data: cards }) };
920
+ }
921
+ if (u.endsWith("/responses/input_tokens")) {
922
+ const v = replies.vision ?? { status: 400, body: '{"error":{"code":"vision_disabled"}}' };
923
+ return reply(v.status, v.body);
924
+ }
925
+ if (u.endsWith("/chat/completions")) {
926
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
927
+ if (typeof body.reasoning_effort === "string") {
928
+ return reply(tiers[body.reasoning_effort] ?? 400, "");
929
+ }
930
+ if (body.messages?.some((m) => m.role === "developer")) {
931
+ return reply(400, '{"error":{"message":"Unexpected message role"}}');
932
+ }
933
+ const c = replies.context ?? {
934
+ status: 400,
935
+ body: '{"error":{"message":"prepared prompt has 300004 tokens, exceeding Engine max_context 16384"}}'
936
+ };
937
+ return reply(c.status, c.body);
938
+ }
939
+ return reply(404, "");
940
+ }));
941
+ }
942
+ describe("parseNinferContext", () => {
943
+ it("reads the ceiling out of a real rejection", () => {
944
+ const body = '{"error":{"code":"context_length_exceeded","message":"prepared prompt has 25052 tokens, ' + 'exceeding Engine max_context 16384","param":"messages","type":"invalid_request_error"}}';
945
+ expect(parseNinferContext(body)).toBe(16384);
946
+ });
947
+ it("reads the ceiling out of the rejection", () => {
948
+ expect(parseNinferContext("prepared prompt has 300004 tokens, exceeding Engine max_context 16384")).toBe(16384);
949
+ });
950
+ it("survives the message arriving wrapped in JSON", () => {
951
+ expect(parseNinferContext('{"error":{"message":"... Engine max_context 8192"}}')).toBe(8192);
952
+ });
953
+ it("gives up rather than guess", () => {
954
+ expect(parseNinferContext("context length exceeded")).toBeUndefined();
955
+ expect(parseNinferContext("")).toBeUndefined();
956
+ expect(parseNinferContext("max_context none")).toBeUndefined();
957
+ });
958
+ });
959
+ describe("isNinferCards", () => {
960
+ it("identifies the owner the schema hard-codes", () => {
961
+ expect(isNinferCards([ninferCard()])).toBe(true);
962
+ });
963
+ it("stays out of the way of a card that is not ninfer's", () => {
964
+ expect(isNinferCards([{ id: "m", owned_by: "sglang" }])).toBe(false);
965
+ expect(isNinferCards([{ id: "m" }])).toBe(false);
966
+ expect(isNinferCards([])).toBe(false);
967
+ });
968
+ it("declines a mixed list, as a gateway aggregating backends would send", () => {
969
+ expect(isNinferCards([ninferCard(), { id: "other", owned_by: "vllm" }])).toBe(false);
970
+ });
971
+ });
972
+ describe("probeNinfer", () => {
973
+ it("learns the context ceiling from the rejection", async () => {
974
+ mockNinfer([ninferCard()]);
975
+ expect((await probeNinfer("http://x/v1", "", "m")).contextWindow).toBe(16384);
976
+ });
977
+ it("falls back to ninfer's default when the rejection says nothing useful", async () => {
978
+ mockNinfer([ninferCard()], { context: { status: 400, body: "context_length_exceeded" } });
979
+ expect((await probeNinfer("http://x/v1", "", "m")).contextWindow).toBe(8192);
980
+ });
981
+ it("takes an accepted prompt as a floor when the overshoot was not enough", async () => {
982
+ mockNinfer([ninferCard()], {
983
+ context: { status: 200, body: '{"usage":{"prompt_tokens":300004,"completion_tokens":1}}' }
984
+ });
985
+ expect((await probeNinfer("http://x/v1", "", "m")).contextWindow).toBe(300004);
986
+ });
987
+ it("falls back when an accepted probe reports no usable prompt size", async () => {
988
+ mockNinfer([ninferCard()], { context: { status: 200, body: "not json" } });
989
+ expect((await probeNinfer("http://x/v1", "", "m")).contextWindow).toBe(8192);
990
+ });
991
+ it("falls back when the probe never gets an answer", async () => {
992
+ vi.stubGlobal("fetch", vi.fn(async () => {
993
+ throw new Error("connection refused");
994
+ }));
995
+ expect((await probeNinfer("http://x/v1", "", "m")).contextWindow).toBe(8192);
996
+ });
997
+ it("reads vision off the token-count rejection", async () => {
998
+ mockNinfer([ninferCard()]);
999
+ expect((await probeNinfer("http://x/v1", "", "m")).vision).toBe(false);
1000
+ });
1001
+ it("reads vision on when the token count is accepted", async () => {
1002
+ mockNinfer([ninferCard()], { vision: { status: 200, body: '{"input_tokens":12}' } });
1003
+ expect((await probeNinfer("http://x/v1", "", "m")).vision).toBe(true);
1004
+ });
1005
+ it("leaves vision unknown when the rejection is about something else", async () => {
1006
+ mockNinfer([ninferCard()], { vision: { status: 400, body: '{"error":{"code":"invalid_request"}}' } });
1007
+ expect((await probeNinfer("http://x/v1", "", "m")).vision).toBeUndefined();
1008
+ });
1009
+ });
1010
+ describe("ninfer through the chain", () => {
1011
+ it("is detected, measured, and mapped", async () => {
1012
+ mockNinfer([ninferCard()]);
1013
+ const result = await detectModels("http://x/v1", "");
1014
+ expect(result.apiType).toBe("ninfer");
1015
+ expect(result.models[0]).toMatchObject({
1016
+ id: "qwen3.8-27b",
1017
+ contextWindow: 16384,
1018
+ reasoning: true,
1019
+ input: ["text"]
1020
+ });
1021
+ });
1022
+ it("maps DM's levels onto the tiers the artifact accepts", async () => {
1023
+ mockNinfer([ninferCard()]);
1024
+ const result = await detectModels("http://x/v1", "");
1025
+ expect(result.models[0].thinkingLevelMap).toEqual({
1026
+ off: "none",
1027
+ minimal: "low",
1028
+ low: "low",
1029
+ medium: "medium",
1030
+ high: "medium",
1031
+ xhigh: "xhigh",
1032
+ max: "xhigh"
1033
+ });
1034
+ });
1035
+ it("reports a text-only artifact as text-only", async () => {
1036
+ mockNinfer([ninferCard()]);
1037
+ const result = await detectModels("http://x/v1", "");
1038
+ expect(result.models[0].input).toEqual(["text"]);
1039
+ });
1040
+ it("reports vision when the server was started with it", async () => {
1041
+ mockNinfer([ninferCard()], { vision: { status: 200, body: '{"input_tokens":12}' } });
1042
+ const result = await detectModels("http://x/v1", "");
1043
+ expect(result.models[0].input).toEqual(["text", "image"]);
1044
+ });
1045
+ it("claims no reasoning when the template exposes no tiers", async () => {
1046
+ mockNinfer([ninferCard()], {}, {});
1047
+ const result = await detectModels("http://x/v1", "");
1048
+ expect(result.models[0].reasoning).toBe(false);
1049
+ expect(result.models[0].thinkingLevelMap).toBeUndefined();
1050
+ });
1051
+ });