deel-local-cli 0.9.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.
@@ -1,22 +1,26 @@
1
- // 모델이 한 번에 받아 줄 수 있는 길이(컨텍스트) 서버에서 알아낸다.
1
+ // 모델이 한 번에 받아 줄 수 있는 길이(컨텍스트) 번에 낼 수 있는 답 길이를
2
+ // 서버에서 알아낸다.
2
3
  //
3
4
  // 왜 파일을 따로 뒀는가:
4
5
  //
5
- // 이 숫자 하나가 프로그램 전체를 좌우한다. 이게 작으면 파일을 몇 개 못 읽히고,
6
- // 대화가 금방 접히고, 번에 있는 길이도 같이 줄어든다(effort.js 가
7
- // 값에서 상한을 계산한다). 예전에는 알아내면 32768 뒀는데, 요즘
8
- // 로컬 모델은 262,144 · 655,360 처럼 훨씬 크다. 32k 로 깔고 앉으면 모델이
9
- // 가진 것의 5% 만 쓰는 셈이다.
6
+ // 이 숫자들이 프로그램 전체를 좌우한다. 컨텍스트가 작으면 파일을 몇 개 못 읽히고
7
+ // 대화가 금방 접힌다. 길이 상한이 작으면 **큰 파일이 만들어진다.**
8
+ // 알아내면 32,768 깔고 앉는데, 요즘 로컬 모델은 262,144 · 655,360 이 흔하다.
9
+ // 상태로 쓰면 모델이 가진 것의 5% 쓰는 셈이다.
10
10
  //
11
- // 문제는 서버마다 숫자를 다른 이름, 다른 자리에 둔다는 것이다. 그래서
12
- // 군데만 보지 않고 아는 자리를 전부 훑는다. 번에 찾아도
13
- // 부분만 찾으면 그걸 쓴다.
11
+ // 서버 규격을 하나하나 아는 방식으로는 따라간다:
12
+ // LM Studio · llama.cpp · vLLM · TGI · KoboldCpp · LocalAI · LiteLLM ·
13
+ // OpenRouter · 사내 게이트웨이, 그리고 아직 나오지 않은 것들.
14
+ // 그래서 세 겹으로 간다.
14
15
  //
15
- // OpenAI 호환 /v1/models/{id} 또는 /v1/models 목록 안의 그 항목
16
- // LM Studio /api/v0/models (max_context_length · loaded_context_length)
17
- // llama.cpp /props (n_ctx)
18
- // vLLM /v1/models (max_model_len)
19
- // Ollama /api/show (…​.context_length)
16
+ // 1겹. 아는 자리를 훑는다 ← 파일
17
+ // 2겹. 우리가 원하는 값을 지시한다 ← backend/adapter.js num_ctx
18
+ // 3겹. 거절당하면 그 말에서 배운다 ← backend/learn.js ★ 이게 진짜 답
19
+ //
20
+ // 3겹이 핵심이다. 서버는 거절할 때 정답을 알려 준다 —
21
+ // "This model's maximum context length is 8192 tokens, however you requested 41003"
22
+ // 이 방식은 **처음 보는 서버에서도 통한다.** 규격을 몰라도 되고, 새 서버가
23
+ // 나와도 코드를 안 고쳐도 된다.
20
24
  //
21
25
  // '올린 길이' 와 '모델 최대' 를 구분한다. LM Studio 는 모델이 655,360 까지
22
26
  // 되더라도 8,192 로 올려 둘 수 있다. 그 상태에서 655,360 을 믿고 보내면
@@ -24,19 +28,37 @@
24
28
  // 따로 알려 준다 — 사용자가 서버에서 더 올린 다음 /ctx 로 맞출 수 있게.
25
29
  import { req, headersFor } from './http.js';
26
30
 
27
- // 이 이름들 중 하나면 컨텍스트 길이다. 순서가 곧 우선순위다.
31
+ // 이 이름들 중 하나면 '모델이 낼 수 있는 최대 컨텍스트' 다. 순서가 곧 우선순위다.
28
32
  const 최대이름 = [
29
33
  'max_context_length', // LM Studio
30
34
  'context_window', // 일부 게이트웨이
31
35
  'context_length', // llama.cpp·HF 계열
32
36
  'max_model_len', // vLLM
33
37
  'max_input_tokens', // LiteLLM·OpenRouter 계열
34
- 'n_ctx', // llama.cpp
38
+ 'n_ctx_train', // llama.cpp — 학습 때 길이
35
39
  'max_position_embeddings', // 모델 config 원본
36
40
  'max_sequence_length',
37
41
  'max_seq_len',
38
42
  ];
39
- const 올린이름 = ['loaded_context_length', 'n_ctx', 'context_length'];
43
+
44
+ /**
45
+ * '지금 서버에 실제로 올려 둔 길이'.
46
+ *
47
+ * context_length 를 여기서 뺐다. 그건 **모델 최대**이지 올린 길이가 아니다.
48
+ * Ollama /api/show 가 그 이름으로 131,072 를 주는데 서버는 8,192 만 받는다.
49
+ * 그대로 믿으면 확신에 찬 오답이 된다 — 화면에는 "131,072 · 서버에서 읽음" 이
50
+ * 뜨고, 긴 대화에서 조용히 거절당한다. 못 찾았으면 못 찾았다고 해야 한다.
51
+ *
52
+ * 진짜 올린 길이는 대개 parameters 라는 **글자 덩어리** 안에 있다.
53
+ * 그래서 아래 글에서찾기() 가 필요하다.
54
+ */
55
+ const 올린이름 = ['loaded_context_length', 'num_ctx', 'n_ctx'];
56
+
57
+ // 한 번에 낼 수 있는 답 길이. 컨텍스트와 다른 축인데 전에는 아예 안 봤다.
58
+ const 출력이름 = [
59
+ 'max_output_tokens', 'max_completion_tokens', 'max_tokens',
60
+ 'num_predict', 'n_predict', 'max_output_length',
61
+ ];
40
62
 
41
63
  // 사람이 알아볼 수 없는 값은 안 받는다. 512 미만은 오독, 1000만 초과는 단위 착각.
42
64
  const 최소 = 512;
@@ -50,28 +72,66 @@ function 성한수(v) {
50
72
  }
51
73
 
52
74
  /**
53
- * 객체 어디에 박혀 있어도 찾는다.
75
+ * 글자 덩어리 안에서 값을 찾는다.
76
+ *
77
+ * 서버가 값을 JSON 으로 안 주고 글로 주는 일이 흔하다. Ollama 가 그렇다 —
78
+ * "parameters": "num_ctx 8192\nstop \"<|im_end|>\""
79
+ * 이건 JSON 으로 보면 그냥 긴 문자열 하나다. 파보기() 로는 영영 못 찾는다.
80
+ * 실제로 이것 때문에 8,192 서버에 131,072 를 보내고 있었다.
81
+ *
82
+ * llama.cpp 의 /props 나 일부 게이트웨이의 설명 필드도 같은 모양이다.
83
+ */
84
+ export function 글에서찾기(s, 이름들) {
85
+ const 글 = String(s ?? '');
86
+ if (!글 || 글.length > 200000) return null;
87
+ for (const 이름 of 이름들) {
88
+ // 이름 뒤에 : = 공백 무엇이 와도 받는다. 값에 따옴표나 자릿점이 붙어도 받는다.
89
+ const re = new RegExp(`(?:^|[^a-z0-9_])${이름}["']?\\s*[:=]?\\s*["']?(\\d[\\d,_]*)`, 'i');
90
+ const m = re.exec(글);
91
+ if (m) {
92
+ const v = 성한수(m[1].replace(/[,_]/g, ''));
93
+ if (v) return { value: v, key: 이름 };
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * 객체 어디에 박혀 있어도 찾는다. 글자 덩어리 안까지 본다.
54
101
  *
55
102
  * 서버가 { data: { meta: { config: { max_position_embeddings: 655360 } } } } 처럼
56
103
  * 깊이 넣어 두는 일이 흔하다. 그래서 이름으로 훑는다. 다만 아무 데나 파고들면
57
104
  * 엉뚱한 숫자를 집으므로 깊이를 5 로 막는다.
105
+ *
106
+ * 어느 이름에서 찾았는지도 같이 돌려준다 — 값이 이상할 때 사람이 원인을
107
+ * 짚을 수 있어야 한다. /ctx 자세히 가 이걸 보여 준다.
58
108
  */
59
- function 파보기(obj, 이름들, depth = 0) {
109
+ export function 파보기(obj, 이름들, { 글도 = true, depth = 0 } = {}) {
60
110
  if (!obj || typeof obj !== 'object' || depth > 5) return null;
61
111
  for (const key of 이름들) {
62
112
  const v = 성한수(obj[key]);
63
- if (v) return v;
113
+ if (v) return { value: v, key };
64
114
  }
65
- // 이름이 정확히 안 맞으면 '…context_length' 처럼 끝나는 것도 본다 (Ollama).
66
115
  for (const [k, v] of Object.entries(obj)) {
67
- if (/(^|[._])(context_length|context_window|n_ctx)$/i.test(k)) {
116
+ // 이름이 정확히 안 맞으면 '…context_length' 처럼 끝나는 것도 본다 (Ollama).
117
+ if (이름들 === 최대이름 && /(^|[._])(context_length|context_window|n_ctx)$/i.test(k)) {
68
118
  const n = 성한수(v);
69
- if (n) return n;
119
+ if (n) return { value: n, key: k };
120
+ }
121
+ // 글자 덩어리 안까지 본다. 여기가 Ollama 의 진짜 num_ctx 가 사는 자리다.
122
+ //
123
+ // 찾는 이름은 객체를 볼 때와 **똑같이** 맞춘다. 위의 특별 취급(…context_length
124
+ // 로 끝나는 이름)까지 글에서도 그대로 본다. 두 잣대가 갈리면 '객체로 오면
125
+ // 찾고 글로 오면 못 찾는' 설명 못 할 상태가 된다.
126
+ if (글도 && typeof v === 'string') {
127
+ const 볼이름 = 이름들 === 최대이름 ? [...이름들, 'n_ctx', 'context_window'] : 이름들;
128
+ const 글것 = 글에서찾기(v, 볼이름);
129
+ if (글것) return { value: 글것.value, key: `${k}.${글것.key}` };
70
130
  }
71
131
  }
72
132
  for (const v of Object.values(obj)) {
73
133
  if (v && typeof v === 'object') {
74
- const found = 파보기(v, 이름들, depth + 1);
134
+ const found = 파보기(v, 이름들, { 글도, depth: depth + 1 });
75
135
  if (found) return found;
76
136
  }
77
137
  }
@@ -86,67 +146,79 @@ function 목록에서찾기(json, model) {
86
146
  }
87
147
 
88
148
  /**
89
- * 서버에 물어 컨텍스트 길이를 알아낸다.
149
+ * 서버에 물어 길이를 알아낸다.
150
+ *
151
+ * 두드릴 자리를 **한꺼번에** 두드린다. 전에는 차례로 갔다 — 다섯 곳이 각각
152
+ * 시간 제한에 걸리면 켤 때마다 그만큼 멈춰 있었다. 서로 상관없는 요청이라
153
+ * 순서를 지킬 이유가 없다. 우선순위는 결과를 모아 놓고 정하면 된다.
90
154
  *
91
- * @param {{kind:string, base:string, auth:string, key:string, model:string}} conn
92
- * @returns {Promise<{value:number|null, max:number|null, loaded:number|null, source:string|null, tried:Array}>}
155
+ * @returns {Promise<{value, max, loaded, out, source, outSource, tried, why}>}
93
156
  */
94
- export async function probeCtx(conn, { timeout = 12000 } = {}) {
157
+ export async function probeCtx(conn, { timeout = 6000 } = {}) {
95
158
  const H = () => headersFor(conn.auth, conn.key);
96
159
  const base = String(conn.base ?? '').replace(/\/+$/, '');
97
160
  const origin = base.replace(/\/v\d+$/, '').replace(/\/api$/, '');
98
161
  const model = conn.model;
99
162
  const tried = [];
100
- let max = null;
101
- let loaded = null;
102
- let source = null;
103
163
 
104
164
  const 본다 = async (label, url, opts = {}) => {
105
- if (max && loaded) return null;
106
165
  try {
107
166
  const r = await req(url, { headers: H(), timeout, ...opts });
108
167
  tried.push({ label, url, status: r.status ?? 0, ok: !!r.ok });
109
- return r.ok ? r.json : null;
168
+ return { label, json: r.ok ? r.json : null };
110
169
  } catch (err) {
111
170
  tried.push({ label, url, status: 0, ok: false, why: String(err?.message ?? err) });
112
- return null;
171
+ return { label, json: null };
113
172
  }
114
173
  };
115
174
 
116
- const 담기 = (json, label, { 목록 = false } = {}) => {
117
- if (!json) return;
175
+ // 두드릴 자리. 순서가 우선순위다 앞엣것이 믿을 만하다.
176
+ const 자리 = conn.kind === 'ollama'
177
+ ? [
178
+ ['Ollama 모델 정보', `${origin}/api/show`, { method: 'POST', body: { model } }, {}],
179
+ ]
180
+ : [
181
+ // 이 모델만 콕 집어 묻는다. 있으면 가장 정확하다.
182
+ ['모델 상세', `${base}/models/${encodeURIComponent(model)}`, {}, {}],
183
+ // LM Studio 는 자기 규격에만 '올린 길이' 를 준다. 이게 실제로 쓸 값이다.
184
+ ['LM Studio', `${origin}/api/v0/models/${encodeURIComponent(model)}`, {}, {}],
185
+ ['LM Studio 목록', `${origin}/api/v0/models`, {}, { 목록: true }],
186
+ // 목록 응답 안에 들어 있는 경우 (vLLM 의 max_model_len 이 여기 있다)
187
+ ['모델 목록', `${base}/models`, {}, { 목록: true }],
188
+ // llama.cpp 는 /props 로만 알려 준다. TGI 는 /info 다.
189
+ ['llama.cpp /props', `${origin}/props`, {}, { 올린것도: true }],
190
+ ['TGI /info', `${origin}/info`, {}, {}],
191
+ ];
192
+
193
+ const 답들 = await Promise.all(자리.map(([label, url, opts]) => 본다(label, url, opts)));
194
+
195
+ let max = null; let loaded = null; let out = null;
196
+ let source = null; let outSource = null; let maxKey = null; let loadedKey = null;
197
+
198
+ for (const [i, [label]] of 자리.entries()) {
199
+ const json = 답들[i].json;
200
+ if (!json) continue;
201
+ const { 목록 = false, 올린것도 = false } = 자리[i][3] ?? {};
118
202
  const 대상 = 목록 ? 목록에서찾기(json, model) : json;
119
- if (!대상) return;
203
+ if (!대상) continue;
204
+
120
205
  const m = 파보기(대상, 최대이름);
121
206
  const l = 파보기(대상, 올린이름);
122
- if (m && !max) { max = m; source = label; }
123
- if (l && !loaded) loaded = l;
124
- };
125
-
126
- if (conn.kind === 'ollama') {
127
- 담기(await 본다('Ollama /api/show', `${origin}/api/show`, { method: 'POST', body: { model } }), 'Ollama 모델 정보');
128
- } else {
129
- // 1) 이 모델만 콕 집어 묻는다. 있으면 가장 정확하다.
130
- 담기(await 본다('모델 상세', `${base}/models/${encodeURIComponent(model)}`), '모델 상세');
131
-
132
- // 2) LM Studio 는 자기 규격에만 '올린 길이' 를 준다. 이게 실제로 쓸 값이다.
133
- 담기(await 본다('LM Studio 상세', `${origin}/api/v0/models/${encodeURIComponent(model)}`), 'LM Studio');
134
- 담기(await 본다('LM Studio 목록', `${origin}/api/v0/models`), 'LM Studio', { 목록: true });
135
-
136
- // 3) 목록 응답 안에 들어 있는 경우 (vLLM 의 max_model_len 이 여기 있다)
137
- 담기(await 본다('모델 목록', `${base}/models`), '모델 목록', { 목록: true });
138
-
139
- // 4) llama.cpp 서버는 /props 로만 알려 준다.
140
- const props = await 본다('llama.cpp /props', `${origin}/props`);
141
- if (props) {
142
- const n = 파보기(props, ['n_ctx', 'context_length']);
143
- if (n) { if (!max) { max = n; source = 'llama.cpp'; } if (!loaded) loaded = n; }
144
- }
207
+ const o = 파보기(대상, 출력이름);
208
+ if (m && !max) { max = m.value; maxKey = m.key; source = label; }
209
+ if (l && !loaded) { loaded = l.value; loadedKey = l.key; }
210
+ // llama.cpp 의 n_ctx 는 '지금 올린 길이' 다. 그 서버에서는 최대도 그 값으로 본다.
211
+ if (올린것도 && m && !loaded) { loaded = m.value; loadedKey = m.key; }
212
+ if (o && !out) { out = o.value; outSource = label; }
145
213
  }
146
214
 
147
215
  // 실제로 쓸 값: 올려 둔 길이가 있으면 그것. 없으면 모델 최대.
148
216
  const value = loaded ?? max ?? null;
149
- return { value, max, loaded, source, tried };
217
+ const why = value ? null
218
+ : tried.some((t) => t.ok) ? '서버가 응답은 했지만 길이를 안 알려 줍니다'
219
+ : '두드린 자리에서 아무 응답도 못 받았습니다';
220
+
221
+ return { value, max, loaded, out, source, outSource, maxKey, loadedKey, tried, why };
150
222
  }
151
223
 
152
224
  /**
@@ -27,7 +27,16 @@ async function tryOpenAI(base, key) {
27
27
  }
28
28
  }
29
29
  // 401/403 이면 규격은 맞고 인증만 틀린 것 — 다음 방식으로 계속.
30
- if (r.status && ![401, 403, 0, 404].includes(r.status)) {
30
+ //
31
+ // 그 밖의 오류(500 등)는 '규격은 맞는데 서버가 지금 화가 난 것' 으로 본다.
32
+ // 그래야 서버가 한 말을 사람에게 그대로 보여 줄 수 있다.
33
+ //
34
+ // 단, **200 인데 JSON 이 아니면 여기 해당하지 않는다.** 사내 프록시가
35
+ // 로그인 페이지를 200 으로 내주는 일이 흔한데, 예전에는 그걸 "OpenAI 호환
36
+ // 서버 · 모델 0개" 로 잡았다. 그러면 사람은 모델이 안 올라온 줄 알고
37
+ // 엉뚱한 데를 파게 된다 — 실제로는 인증 페이지에 막힌 것이다.
38
+ const 성공인데JSON아님 = r.status >= 200 && r.status < 300 && !r.json;
39
+ if (r.status && !성공인데JSON아님 && ![401, 403, 0, 404].includes(r.status)) {
31
40
  return { kind: 'openai', base, auth: style.id, models: [], ms: r.ms, warn: serverMessage(r) };
32
41
  }
33
42
  }
@@ -0,0 +1,102 @@
1
+ // 서버가 거절할 때 하는 말에서 진짜 한계를 배운다.
2
+ //
3
+ // 왜 이게 핵심인가:
4
+ //
5
+ // 서버 규격을 하나하나 아는 방식으로는 영영 못 따라간다. LM Studio · llama.cpp ·
6
+ // vLLM · TGI · KoboldCpp · LocalAI · LiteLLM · OpenRouter · 사내 게이트웨이,
7
+ // 그리고 아직 나오지 않은 것들. 새 서버가 나올 때마다 코드를 고쳐야 한다면
8
+ // 그 코드는 늘 한 발 늦는다.
9
+ //
10
+ // 그런데 서버는 거절할 때 **정답을 그대로 말해 준다.**
11
+ //
12
+ // "This model's maximum context length is 8192 tokens, however you
13
+ // requested 41003 tokens (33003 in the messages, 8000 in the completion)."
14
+ //
15
+ // 숫자가 둘 다 들어 있다 — 한계도, 우리가 얼마나 넘겼는지도. 규격을 몰라도
16
+ // 되고, 이름이 무엇인지도 알 필요가 없다. **처음 보는 서버에서도 통한다.**
17
+ //
18
+ // 그런데 지금까지 그 문장을 버리고 있었다. 스트리밍이면 본문을 안 읽고
19
+ // HTTP 400 만 던졌다. 화면에는 `✗ HTTP 400` 한 줄만 남았다.
20
+ // 사용자를 구할 수 있었던 문장이 그 자리에서 사라진 것이다.
21
+ //
22
+ // 여기서는 문장에서 숫자만 뽑는다. 뽑은 값으로 무엇을 할지는 loop.js 가 정한다.
23
+
24
+ const 최소 = 512;
25
+ const 최대허용 = 10_000_000;
26
+
27
+ const 성한수 = (v) => {
28
+ const n = Math.floor(Number(v));
29
+ return Number.isFinite(n) && n >= 최소 && n <= 최대허용 ? n : null;
30
+ };
31
+
32
+ /**
33
+ * 거절 문장에서 배울 것이 있나.
34
+ *
35
+ * 여러 규격의 문장을 받는다. 영어가 대부분이지만 사내 게이트웨이가 한국어로
36
+ * 옮겨 놓은 것도 있어서 그쪽도 같이 본다.
37
+ *
38
+ * @returns {{kind:'ctx'|'out', limit:number, asked:number|null, text:string}|null}
39
+ * kind 'ctx' 는 컨텍스트 전체, 'out' 은 한 번에 낼 답 길이
40
+ * limit 서버가 말한 한계
41
+ * asked 우리가 요청했던 값 (알 수 있으면)
42
+ */
43
+ export function 배울것(message) {
44
+ const s = String(message ?? '');
45
+ if (!s) return null;
46
+
47
+ // ── 1) 컨텍스트 한계 ──────────────────────────────────────────────────
48
+ // OpenAI·vLLM·LiteLLM·대부분의 게이트웨이가 이 모양으로 말한다.
49
+ const ctx표 = [
50
+ // "maximum context length is 8192 tokens, however you requested 41003"
51
+ // "…is 4096 tokens. However, you requested 5000 tokens." ← 마침표가 중간에 낀다.
52
+ // 그래서 [^.] 로 막으면 안 된다. 대신 사이를 80자로만 묶어 엉뚱한 숫자를 안 집게 한다.
53
+ /maximum context length is\s+(\d+)\s*tokens?(?:[\s\S]{0,80}?requested\s+(\d+))?/i,
54
+ /context length[^.\d]{0,40}(\d{3,})[\s\S]{0,80}?requested\s+(\d+)/i,
55
+ // llama.cpp: "the request exceeds the available context size. try increasing the context size or enable context shift"
56
+ // 숫자를 안 주는 경우다. n_ctx = 8192 처럼 따로 적어 주기도 한다.
57
+ /n_ctx\s*[:=]?\s*(\d{3,})/i,
58
+ // 한국어로 옮겨 놓은 사내 게이트웨이
59
+ /(?:최대\s*)?컨텍스트[^\d]{0,20}(\d{3,})/,
60
+ ];
61
+ for (const re of ctx표) {
62
+ const m = re.exec(s);
63
+ if (!m) continue;
64
+ const limit = 성한수(m[1]);
65
+ if (!limit) continue;
66
+ return { kind: 'ctx', limit, asked: 성한수(m[2]) ?? null, text: 짧게(s) };
67
+ }
68
+
69
+ // ── 2) 답 길이 한계 ───────────────────────────────────────────────────
70
+ const out표 = [
71
+ // "max_tokens is too large: 200000. This model supports at most 16384 completion tokens"
72
+ /supports? at most\s+(\d+)\s*(?:completion\s*)?tokens?/i,
73
+ // "max_tokens must be less than or equal to 8192"
74
+ /max_(?:completion_)?tokens[^\d]{0,40}(\d{3,})/i,
75
+ /num_predict[^\d]{0,20}(\d{3,})/i,
76
+ /(?:최대\s*)?(?:출력|답)[^\d]{0,20}(\d{3,})/,
77
+ ];
78
+ for (const re of out표) {
79
+ const m = re.exec(s);
80
+ if (!m) continue;
81
+ const limit = 성한수(m[1]);
82
+ if (!limit) continue;
83
+ return { kind: 'out', limit, asked: null, text: 짧게(s) };
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * 이 오류가 '너무 길어서' 인가.
91
+ *
92
+ * 숫자를 못 뽑았어도 이건 알 수 있을 때가 있다. 그러면 값을 배우지는 못해도
93
+ * **줄여서 다시 해 볼 수는 있다.** 사용자에게는 실패가 안 보이는 편이 낫다.
94
+ */
95
+ export function 길이문제인가(message) {
96
+ const s = String(message ?? '');
97
+ return /context (?:length|size|window)|too (?:long|large|many tokens)|exceeds?[^.]{0,30}(?:context|limit|token)|max_tokens|token limit|컨텍스트|너무 (?:깁|많|큽)/i.test(s);
98
+ }
99
+
100
+ function 짧게(s) {
101
+ return String(s).replace(/\s+/g, ' ').trim().slice(0, 200);
102
+ }