llm-switcher 1.6.16 → 1.6.18
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/CHANGELOG.md +10 -0
- package/formats.mjs +20 -3
- package/package.json +1 -1
- package/preflight-cache.json +11 -0
- package/proxy.mjs +2 -0
- package/tests/formats.test.mjs +108 -0
- package/tests/upstream-stream.test.mjs +23 -1
- package/upstream-stream.mjs +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.18 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
- **DeepSeek and Kimi on a turn with no reasoning.** `hasNativeReasoning` now matches `deepseek` and `kimi` model ids. Codex sends a continue or compaction turn with no reasoning item, and the proxy took such a model for a non-thinking one. The tool-call turns then went out with no `reasoning_content`, and DeepSeek thinking mode answered 400 "reasoning_content must be passed back". A model that is not a thinking model still gets no field, and `thinkingMode: off` still sends none.
|
|
6
|
+
- **Tool names in the debug log.** With `debug: true`, the proxy now logs the names of the tools it declares upstream and the `tool_choice`, on both the HTTP and the WebSocket route. Before, the log held the first 300 to 500 characters of the body, which never reached `tools`. This is how to tell whether an upstream "undeclared tool" error comes from a missing declaration.
|
|
7
|
+
|
|
8
|
+
## Release 1.6.17 — 2026-10-13
|
|
9
|
+
|
|
10
|
+
- **Documents in a tool result.** A document with a text source, a content source or a URL goes to the model as its text or its URL. Before 1.6.17, it went as a bare `[document]` label. Only base64 data becomes a label, for example `[file: a.pdf]`.
|
|
11
|
+
- **The upstream idle limit.** `LLM_SWITCHER_UPSTREAM_IDLE_MS` must be a positive number. The proxy uses 300000 for any other value, and it writes a warning. A value above 2147483647 becomes 2147483647. Before 1.6.17, a negative, infinite or too large value ended every stream after 1 ms. The limit counts from the response headers. It applies to the body only, and an SSE comment line resets it.
|
|
12
|
+
|
|
3
13
|
## Release 1.6.16 — 2026-10-13
|
|
4
14
|
|
|
5
15
|
- **A silent upstream stream.** The proxy now ends an upstream stream that sends no byte for 5 minutes, with an error. Before, only the default limit of the Node HTTP client stopped it. Set `LLM_SWITCHER_UPSTREAM_IDLE_MS` to change the limit.
|
package/formats.mjs
CHANGED
|
@@ -697,9 +697,22 @@ function responsesOutputImages(out) {
|
|
|
697
697
|
// A file or document block carries its bytes as base64; stringified into text it costs millions of tokens
|
|
698
698
|
// (The-NeXT-AI/ai-gateway#28), so it is named instead. Null for any other block.
|
|
699
699
|
function mediaPlaceholder(b) {
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
|
|
700
|
+
const src = b?.source;
|
|
701
|
+
if (src?.type === 'base64' || typeof b?.file_data === 'string') {
|
|
702
|
+
if (b.type === 'input_file') return `[file: ${b.filename || b.file_id || 'attachment'}]`;
|
|
703
|
+
if (b.type === 'document') return `[document${b.title ? `: ${b.title}` : ''}]`;
|
|
704
|
+
return `[${b.type || 'attachment'}]`;
|
|
705
|
+
}
|
|
706
|
+
// Not base64: the content itself goes on, as text the model can read.
|
|
707
|
+
if (b?.type === 'document' && src?.type === 'text' && typeof src.data === 'string') return src.data;
|
|
708
|
+
if (b?.type === 'document' && src?.type === 'content') {
|
|
709
|
+
if (typeof src.content === 'string') return src.content;
|
|
710
|
+
if (Array.isArray(src.content)) {
|
|
711
|
+
return src.content.map(c => (c?.type === 'text' ? c.text || '' : c?.type === 'image' ? '[image omitted]' : '')).filter(Boolean).join('\n');
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (b?.type === 'document' && src?.type === 'url' && src.url) return `[document: ${src.url}]`;
|
|
715
|
+
if (b?.type === 'input_file' && b.file_url) return `[file: ${b.file_url}]`;
|
|
703
716
|
return null;
|
|
704
717
|
}
|
|
705
718
|
|
|
@@ -1036,7 +1049,11 @@ function withUpstreamToolNames(ir, names) {
|
|
|
1036
1049
|
function hasNativeReasoning(model) {
|
|
1037
1050
|
// model IDs change often, but all current Opus variants support reasoning.
|
|
1038
1051
|
const m = String(model || '').toLowerCase();
|
|
1052
|
+
// DeepSeek and Kimi run in thinking mode by default and refuse the next tool step when an earlier
|
|
1053
|
+
// tool-call turn lacks reasoning_content, even if the client (Codex on a continue/compaction turn)
|
|
1054
|
+
// sent no reasoning item with the request.
|
|
1039
1055
|
return m.includes('thinking') || m.includes('reasoning') || m.includes('reasoner') || m.includes('opus')
|
|
1056
|
+
|| m.includes('deepseek') || m.includes('kimi')
|
|
1040
1057
|
|| /(^|[/_.:-])(o[134]|r1|qwq)([/_.:-]|$)/.test(m);
|
|
1041
1058
|
}
|
|
1042
1059
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claude": {
|
|
3
|
+
"cacheKey": "claude-intact:zencore/space-bunny-free,zencore/muse-spark-1.3-contributor-free",
|
|
4
|
+
"time": 1792631963371,
|
|
5
|
+
"lines": [
|
|
6
|
+
"[llm-switcher] claude -> claude-intact | 127.0.0.1:20142 | zencore/space-bunny-free",
|
|
7
|
+
"[llm-switcher:preflight] ⚠️ CẢNH BÁO: Model đã setup không còn khả dụng trên provider: 'zencore/space-bunny-free' [slot sonnet], 'zencore/muse-spark-1.3-contributor-free' [slot haiku].",
|
|
8
|
+
"[llm-switcher:preflight] Gợi ý: Chạy 'switch ui' để cập nhật lại model hợp lệ."
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
package/proxy.mjs
CHANGED
|
@@ -1320,6 +1320,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1320
1320
|
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1321
1321
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1322
1322
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1323
|
+
debugLog(`[${profileKey}] tools declared upstream:`, JSON.stringify((upBody.tools || []).map(t => t.function?.name ?? t.name ?? t.type)), 'tool_choice:', JSON.stringify(upBody.tool_choice ?? null));
|
|
1323
1324
|
|
|
1324
1325
|
let upstreamRes;
|
|
1325
1326
|
try {
|
|
@@ -2592,6 +2593,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2592
2593
|
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
2593
2594
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2594
2595
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2596
|
+
debugLog(`[${profileKey}:ws] tools declared upstream:`, JSON.stringify((upBody.tools || []).map(t => t.function?.name ?? t.name ?? t.type)), 'tool_choice:', JSON.stringify(upBody.tool_choice ?? null));
|
|
2595
2597
|
|
|
2596
2598
|
let upstreamRes;
|
|
2597
2599
|
try {
|
package/tests/formats.test.mjs
CHANGED
|
@@ -1263,6 +1263,26 @@ test('irToChatBody replays thinking as reasoning_content on tool-call turns', ()
|
|
|
1263
1263
|
assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
|
|
1264
1264
|
});
|
|
1265
1265
|
|
|
1266
|
+
// Codex continue/compaction turns carry no reasoning item; DeepSeek still answers 400 "reasoning_content must be passed back".
|
|
1267
|
+
test('irToChatBody auto mode replays reasoning_content for DeepSeek/Kimi even when the request has no reasoning', () => {
|
|
1268
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1269
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1270
|
+
{ type: 'function_call', call_id: 'c1', name: 'exec', arguments: '{}' },
|
|
1271
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' },
|
|
1272
|
+
{ type: 'message', role: 'user', content: 'continue' }
|
|
1273
|
+
] });
|
|
1274
|
+
for (const model of ['zencore/deepseek-v4.1-flash-free', 'buddycore/deepseek-v4.1-flash', 'zencore/kimi-k2.7']) {
|
|
1275
|
+
const call = irToChatBody(ir, model, { thinkingMode: 'auto' }).messages.find(m => m.tool_calls);
|
|
1276
|
+
assert.equal(call.reasoning_content, '', model);
|
|
1277
|
+
}
|
|
1278
|
+
for (const model of ['gpt-5', 'zencore/glm-5.3-flash-free']) {
|
|
1279
|
+
const body = irToChatBody(ir, model, { thinkingMode: 'auto' });
|
|
1280
|
+
assert.ok(body.messages.every(m => !('reasoning_content' in m)), model);
|
|
1281
|
+
}
|
|
1282
|
+
const off = irToChatBody(ir, 'zencore/deepseek-v4.1-flash-free', { thinkingMode: 'off' });
|
|
1283
|
+
assert.ok(off.messages.every(m => !('reasoning_content' in m)));
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1266
1286
|
// Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
|
|
1267
1287
|
test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
|
|
1268
1288
|
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
@@ -1754,3 +1774,91 @@ test('irToAnthropicBody: a whitespace-only text block is blank too', () => {
|
|
|
1754
1774
|
const body = irToAnthropicBody(ir, 'claude');
|
|
1755
1775
|
assert.ok(body.messages.every(m => m.content.every(b => b.type !== 'text' || b.text.trim())), JSON.stringify(body.messages));
|
|
1756
1776
|
});
|
|
1777
|
+
|
|
1778
|
+
// ---- roundtable ruling 2026-10-13: media in tool results, held notices ----
|
|
1779
|
+
|
|
1780
|
+
const BLOB = 'QUFB'.repeat(2000);
|
|
1781
|
+
const MISSING_TOOL_RESULT = '[Tool result unavailable: it was removed from the conversation history]';
|
|
1782
|
+
function anthropicToolText(content) {
|
|
1783
|
+
const ir = anthropicToIR({ model: 'x', messages: [
|
|
1784
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'read', input: {} }] },
|
|
1785
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content }] }
|
|
1786
|
+
] });
|
|
1787
|
+
return ir.messages.find(m => m.role === 'tool').content;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
test('a tool-result document keeps its text, its URL and its label; only base64 becomes a placeholder', () => {
|
|
1791
|
+
assert.match(anthropicToolText([{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello doc' } }]), /hello doc/);
|
|
1792
|
+
const content = anthropicToolText([{ type: 'document', source: { type: 'content', content: [
|
|
1793
|
+
{ type: 'text', text: 'part one' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: BLOB } }] } }]);
|
|
1794
|
+
assert.match(content, /part one/);
|
|
1795
|
+
assert.match(content, /\[image omitted\]/);
|
|
1796
|
+
assert.ok(!content.includes(BLOB));
|
|
1797
|
+
assert.equal(anthropicToolText([{ type: 'document', source: { type: 'content', content: 'plain string' } }]), 'plain string');
|
|
1798
|
+
assert.match(anthropicToolText([{ type: 'document', source: { type: 'url', url: 'https://example.com/a.pdf' } }]), /https:\/\/example\.com\/a\.pdf/);
|
|
1799
|
+
assert.match(anthropicToolText([{ type: 'input_file', file_url: 'https://example.com/f.pdf' }]), /https:\/\/example\.com\/f\.pdf/);
|
|
1800
|
+
assert.equal(anthropicToolText([{ type: 'input_file', filename: 'a.pdf', file_data: `data:application/pdf;base64,${BLOB}` }]), '[file: a.pdf]');
|
|
1801
|
+
assert.equal(anthropicToolText([{ type: 'document', title: 'T', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]), '[document: T]');
|
|
1802
|
+
assert.ok(!anthropicToolText([{ type: 'document', source: { type: 'url' } }]).includes('[document: undefined]'));
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
test('an orphan tool result turned into text keeps a text document', () => {
|
|
1806
|
+
const { payload } = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1807
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'orphan', content: [{ type: 'document', source: { type: 'text', data: 'hello doc' } }] }] }
|
|
1808
|
+
] });
|
|
1809
|
+
assert.match(JSON.stringify(payload.messages), /hello doc/);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
function chatShape(input) {
|
|
1813
|
+
return irToChatBody(responsesToIR({ model: 'x', input }), 'gpt-4o').messages
|
|
1814
|
+
.map(m => (m.role === 'tool' ? `tool:${m.content}` : m.role === 'user' ? `user:${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}` : m.role));
|
|
1815
|
+
}
|
|
1816
|
+
const callAB = [
|
|
1817
|
+
{ type: 'function_call', call_id: 'c1', name: 'a', arguments: '{}' },
|
|
1818
|
+
{ type: 'function_call', call_id: 'c2', name: 'b', arguments: '{}' }
|
|
1819
|
+
];
|
|
1820
|
+
|
|
1821
|
+
test('healToolPairs: a held notice follows the placeholder of an output that never came', () => {
|
|
1822
|
+
const missing = chatShape([...callAB, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice' }]);
|
|
1823
|
+
assert.deepEqual(missing.slice(-4), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:notice']);
|
|
1824
|
+
const later = chatShape([...callAB, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice' },
|
|
1825
|
+
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'next turn' }] }]);
|
|
1826
|
+
assert.deepEqual(later.slice(-2), ['user:notice', 'assistant']);
|
|
1827
|
+
const two = chatShape([...callAB, { role: 'user', content: 'notice1' }, { type: 'function_call_output', call_id: 'c1', output: 'out1' }, { role: 'user', content: 'notice2' }]);
|
|
1828
|
+
assert.deepEqual(two.slice(-5), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:notice1', 'user:notice2']);
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
test('placeholders and URLs are exact on every route that turns a tool result into text', () => {
|
|
1832
|
+
assert.equal(anthropicToolText([{ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]), '[document]');
|
|
1833
|
+
assert.equal(anthropicToolText([{ type: 'document', source: { type: 'url', url: 'https://example.com/a.pdf' } }]), '[document: https://example.com/a.pdf]');
|
|
1834
|
+
assert.equal(anthropicToolText([{ type: 'input_file', file_url: 'https://example.com/f.pdf' }]), '[file: https://example.com/f.pdf]');
|
|
1835
|
+
const orphanText = (content) => {
|
|
1836
|
+
const { payload } = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1837
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'orphan', content }] }] });
|
|
1838
|
+
return JSON.stringify(payload.messages);
|
|
1839
|
+
};
|
|
1840
|
+
const doc = orphanText([{ type: 'document', title: 'T', source: { type: 'base64', media_type: 'application/pdf', data: BLOB } }]);
|
|
1841
|
+
assert.ok(!doc.includes(BLOB) && doc.includes('[document: T]'), doc.slice(0, 300));
|
|
1842
|
+
const file = orphanText([{ type: 'input_file', filename: 'a.pdf', file_data: `data:application/pdf;base64,${BLOB}` }]);
|
|
1843
|
+
assert.ok(!file.includes(BLOB) && file.includes('[file: a.pdf]'), file.slice(0, 300));
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
test('the image of a Responses tool output reaches the chat and Anthropic bodies', () => {
|
|
1847
|
+
for (const image_url of ['data:image/png;base64,AAAA', { url: 'data:image/png;base64,AAAA' }]) {
|
|
1848
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1849
|
+
{ type: 'function_call', call_id: 'c1', name: 'shot', arguments: '{}' },
|
|
1850
|
+
{ type: 'function_call_output', call_id: 'c1', output: [{ type: 'input_image', image_url }] }] });
|
|
1851
|
+
const chat = JSON.stringify(irToChatBody(ir, 'gpt-4o').messages);
|
|
1852
|
+
assert.match(chat, /data:image\/png;base64,AAAA/);
|
|
1853
|
+
const anth = JSON.stringify(irToAnthropicBody(ir, 'claude-x').messages);
|
|
1854
|
+
assert.match(anth, /"type":"image"/);
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
test('healToolPairs: an orphan output and a held notice in one batch come out orphan first, then the notice', () => {
|
|
1859
|
+
const shape = chatShape([...callAB,
|
|
1860
|
+
{ type: 'function_call_output', call_id: 'zz', output: 'stray' },
|
|
1861
|
+
{ role: 'user', content: 'notice' },
|
|
1862
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'out1' }]);
|
|
1863
|
+
assert.deepEqual(shape.slice(-5), ['assistant', 'tool:out1', `tool:${MISSING_TOOL_RESULT}`, 'user:[Tool Result (zz)]: stray', 'user:notice']);
|
|
1864
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { readUpstreamPayloads } from '../upstream-stream.mjs';
|
|
3
|
+
import { readUpstreamPayloads, parseIdleMs } from '../upstream-stream.mjs';
|
|
4
4
|
|
|
5
5
|
async function collect(text, widths = [1, 3, 7]) {
|
|
6
6
|
const bytes = Buffer.from(text); let offset = 0, i = 0;
|
|
@@ -47,3 +47,25 @@ test('an upstream that goes silent mid-stream ends with an error after the idle
|
|
|
47
47
|
assert.deepEqual(values, [{ a: 1 }]);
|
|
48
48
|
assert.ok(cancelled, 'the silent upstream body is cancelled');
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
test('parseIdleMs keeps the idle limit inside what setTimeout accepts', () => {
|
|
52
|
+
// Node fires a timer of a negative, infinite or > 2^31-1 delay after 1 ms.
|
|
53
|
+
assert.equal(parseIdleMs('3000000000'), 2147483647);
|
|
54
|
+
for (const bad of ['-5', 'Infinity', 'abc', '0', '0.5']) assert.equal(parseIdleMs(bad), 300000, bad);
|
|
55
|
+
assert.equal(parseIdleMs('120000'), 120000);
|
|
56
|
+
assert.equal(parseIdleMs(undefined), 300000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the idle limit counts the gap between reads, not the whole stream', async () => {
|
|
60
|
+
let n = 0;
|
|
61
|
+
const body = new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
if (n === 5) return controller.close();
|
|
64
|
+
await new Promise(r => setTimeout(r, 60));
|
|
65
|
+
controller.enqueue(Buffer.from(`data: {"n":${n++}}\n\n`));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const values = [];
|
|
69
|
+
for await (const v of readUpstreamPayloads({ body }, { idleMs: 200 })) values.push(v.n);
|
|
70
|
+
assert.deepEqual(values, [0, 1, 2, 3, 4]);
|
|
71
|
+
});
|
package/upstream-stream.mjs
CHANGED
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
// `state.done` turns true on an OpenAI [DONE] line, which ends a stream cleanly even without a finish reason.
|
|
4
4
|
// A stream that sends no byte for `state.idleMs` ends with an error: a dead HTTP/2 session can hold it open
|
|
5
5
|
// with no bytes, and the default limit of the HTTP client is not this module's to rely on.
|
|
6
|
-
|
|
6
|
+
const DEFAULT_IDLE_MS = 300000;
|
|
7
|
+
const MAX_TIMER_MS = 2147483647;
|
|
8
|
+
|
|
9
|
+
// The idle limit as setTimeout takes it: Node fires a negative, infinite or larger delay after 1 ms.
|
|
10
|
+
export function parseIdleMs(value) {
|
|
11
|
+
if (value === undefined || value === null || value === '') return DEFAULT_IDLE_MS;
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
14
|
+
console.warn(`[llm-switcher] upstream idle limit ${JSON.stringify(value)} is not a positive number; using ${DEFAULT_IDLE_MS} ms`);
|
|
15
|
+
return DEFAULT_IDLE_MS;
|
|
16
|
+
}
|
|
17
|
+
return Math.min(n, MAX_TIMER_MS);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const UPSTREAM_IDLE_MS = parseIdleMs(process.env.LLM_SWITCHER_UPSTREAM_IDLE_MS);
|
|
7
21
|
|
|
8
22
|
export async function* readUpstreamPayloads(upstreamRes, state = {}) {
|
|
9
23
|
const reader = upstreamRes.body.getReader();
|
|
10
|
-
const idleMs = state.idleMs
|
|
24
|
+
const idleMs = state.idleMs === undefined ? UPSTREAM_IDLE_MS : parseIdleMs(state.idleMs);
|
|
11
25
|
const read = () => {
|
|
12
26
|
let timer;
|
|
13
27
|
const idle = new Promise((_, reject) => {
|