deel-local-cli 1.17.7 → 1.17.9

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.ko.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-8%2C032%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C140%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -301,7 +301,7 @@ deel --offline
301
301
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
302
302
 
303
303
  ```
304
- deel 1.17.7 ⌂ 이 안
304
+ deel 1.17.9 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -1218,7 +1218,7 @@ deel stats # 이 폴더에서 실제로 무엇을 했나 (.de
1218
1218
  ## 개발
1219
1219
 
1220
1220
  ```bash
1221
- npm test 전체 검증 (8,032항목 — 몇몇은 터미널에 따라 갈립니다)
1221
+ npm test 전체 검증 (8,140항목 — 몇몇은 터미널에 따라 갈립니다)
1222
1222
  npm run coverage 검사가 소스의 어디를 밟았는지
1223
1223
  npm run verify 반입·통신 검증만
1224
1224
  npm run bench 편집 성공률 측정
@@ -1287,6 +1287,8 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1287
1287
 
1288
1288
  | 판 | 무엇이 바뀌었나 |
1289
1289
  |---|---|
1290
+ | [1.17.9](docs/ko/releases/1.17.md#1179) | 한도에 걸렸는데 1초 뒤에 또 두드리고 있었습니다 |
1291
+ | [1.17.8](docs/ko/releases/1.17.md#1178) | 긴 지시문을 통째로 붙여 넣으면 시킨 것과 반대로 갔습니다 — 그리고 망이 한 번 끊기면 턴이 사라졌습니다 |
1290
1292
  | [1.17.7](docs/ko/releases/1.17.md#1177) | 나란히 세워 재 봤고, 진 항목을 고쳤습니다 |
1291
1293
  | [1.17.6](docs/ko/releases/1.17.md#1176) | 살아 있다는 신호만 오고 내용이 안 오면, 여태 영원히 기다렸습니다 |
1292
1294
  | [1.17.5](docs/ko/releases/1.17.md#1175) | 한 턴 안에서 단계가 일을 따라갑니다 — 그리고 읽기 도구 넷이 여태 줄을 서 있었습니다 |
package/README.md CHANGED
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-8%2C032%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C140%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -374,7 +374,7 @@ deel --offline
374
374
  The destination is printed at the top of every session:
375
375
 
376
376
  ```
377
- deel 1.17.7 ⌂ inside
377
+ deel 1.17.9 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -1293,7 +1293,7 @@ Stored in `~/.deel/config.json`. A `.deel/config.json` in the project folder tak
1293
1293
  ## Development
1294
1294
 
1295
1295
  ```bash
1296
- npm test Full suite (8,032 checks; a few are TTY-dependent)
1296
+ npm test Full suite (8,140 checks; a few are TTY-dependent)
1297
1297
  npm run coverage Which lines the tests actually execute
1298
1298
  npm run verify Import + network checks only
1299
1299
  npm run bench Edit success rate
@@ -1348,6 +1348,8 @@ so one run tells you everything.
1348
1348
 
1349
1349
  | Version | What changed |
1350
1350
  |---|---|
1351
+ | [1.17.9](docs/en/releases/1.17.md#1179) | Rate-limited, and knocking again one second later |
1352
+ | [1.17.8](docs/en/releases/1.17.md#1178) | A long brief got the opposite of what it asked for — and one network blip threw the turn away |
1351
1353
  | [1.17.7](docs/en/releases/1.17.md#1177) | We measured it side by side, and fixed the row we lost |
1352
1354
  | [1.17.6](docs/en/releases/1.17.md#1176) | Only keep-alive and no content used to mean waiting forever |
1353
1355
  | [1.17.5](docs/en/releases/1.17.md#1175) | The phase now follows the work inside a turn — and four read tools had been queueing up |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.17.7",
3
+ "version": "1.17.9",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp/serve.js CHANGED
@@ -43,7 +43,7 @@ import { History } from '../safety/undo.js';
43
43
  import { Audit, 열쇠묻기 } from '../safety/audit.js';
44
44
  import { activeProfile, load, resolveKey, homeDir, save as saveCfg } from '../config.js';
45
45
  import { 말 as 옮긴말 } from '../i18n/index.js';
46
- import { 알림채움 } from '../backend/retry.js';
46
+ import { 알림채움, 알림말 } from '../backend/retry.js';
47
47
  import { 전선붙이기, 세션이름짓기 } from '../backend/wire.js';
48
48
  import { discover } from '../skills/discover.js';
49
49
  import { allowEndpoint, setOffline } from '../safety/network.js';
@@ -631,7 +631,7 @@ export async function acp(opts = {}) {
631
631
  // 서버가 잠깐 막아 기다리는 중. 아직 흘러간 글이 없으니 답을 새로 시작하지는 않는다.
632
632
  case 'backoff':
633
633
  말하기(`\n\n_(${ev.미리
634
- ? 옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 })
634
+ ? 옮긴말(알림말(ev), { 초: 알림채움(ev).초 })
635
635
  : 옮긴말('loop.backoff', 알림채움(ev))})_\n\n`);
636
636
  break;
637
637
 
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import { load, activeProfile, resolveKey } from '../config.js';
36
36
  import { isLocalHost, isOffline } from '../safety/network.js';
37
- import { 잠잠기본, 무소식기본 } from '../backend/http.js';
37
+ import { 잠잠기본, 무소식기본, 연결기본 } from '../backend/http.js';
38
38
  import { 인증서설정 } from '../backend/clientcert.js';
39
39
 
40
40
  // 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
@@ -61,6 +61,8 @@ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
61
61
  잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
62
62
  // 바이트는 오는데 내용이 안 올 때의 전체 상한 (backend/http.js 의 무소식기본).
63
63
  무소식: prof.무소식 ?? prof.streamNoNewsMs ?? 무소식기본,
64
+ // 소켓을 **얻는 데까지** 줄 시간. 못 붙은 것은 다시 부른다 (backend/retry.js).
65
+ 연결: prof.연결 ?? prof.connectMs ?? 연결기본,
64
66
  인증서: 인증서설정(prof),
65
67
  tools: prof.tools ?? false,
66
68
  json: prof.json ?? false,
@@ -177,8 +177,27 @@ const 계획말 = '계획|플랜|설계|구상|기획|정리|검토|조사|파
177
177
  const 잇는말 = '해\\s*주고|해\\s*놓고|하고\\s*나서|하고서|한\\s*다음|한\\s*뒤|한\\s*후|하고|해서|잡고|짜고|세우고|낸\\s*뒤|낸\\s*다음';
178
178
  const 실행말 = /만들어|만들자|만들어라|구현|작성|추가(해|하)|생성|써\s*(줘|주세요)|짜\s*(줘|주세요)|고쳐|세팅|설치|배포/;
179
179
  const 계획먼저 = new RegExp(`(${계획말})\\s*(?:을|를)?\\s*(?:${잇는말})`);
180
- // 영어도 같이 본다 — "plan and then build it" 처럼 붙여 넣는 일이 있다.
181
- const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and|then|,)\s*(?:then\s*)?\b(build|implement|create|write|make|code)\b/i;
180
+ /*
181
+ * 영어도 같이 본다 — "plan and then build it" 처럼 붙여 넣는 일이 있다.
182
+ *
183
+ * ── 쉼표를 이음말로 세던 것 ────────────────────────────────────────────
184
+ *
185
+ * 이음말 목록에 `,` 가 들어 있었다. 그런데 영어에서 쉼표로 이어진 동사들은
186
+ * **차례가 아니라 열거**다.
187
+ *
188
+ * "independently design, implement, test, verify, and complete …"
189
+ *
190
+ * 이건 「설계하고 나서 만들어라」 가 아니라 「이것들을 다 해라」 다. 그런데
191
+ * `design` 과 `implement` 가 예순 자 안에 있으니 겹침으로 잡혔고, 겹침이면
192
+ * **계획 모드 + 승인 창**으로 간다 — 계획만 내고 멈춘다.
193
+ *
194
+ * 실제로 겪었다. 「Do not stop after producing a plan」 이라고 **명시적으로
195
+ * 적어 둔** 지시문이 계획 모드로 갔다. 시킨 것과 정반대다.
196
+ *
197
+ * 그래서 쉼표를 뺀다. 차례를 뜻하는 말이 있을 때만 겹침이다. 한국어 쪽은
198
+ * 처음부터 잇는말 목록으로 이렇게 하고 있었고, 영어 쪽만 헐거웠다.
199
+ */
200
+ const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and\s+then|then|and|after\s+(?:that|approval)|before\s+you)\s+(?:then\s+)?\b(build|implement|create|write|make|code)\b/i;
182
201
 
183
202
  /*
184
203
  * ── 두 말이 **얼마나 붙어 있어야** 겹침인가 ─────────────────────────────
@@ -212,7 +231,7 @@ const 겹침거리 = 60;
212
231
  *
213
232
  * 이건 짐작이 아니라 사람이 직접 쓴 말이다. 쓴 말이 규칙을 이긴다.
214
233
  */
215
- const 멈추지마 = /(?:중간에\s*)?(?:멈추지|묻지|물어보지|확인\s*요청하지|되묻지)\s*(?:는)?\s*(?:말|마)|끝까지\s*(?:수행|진행|해)|스스로\s*진행|알아서\s*(?:진행|해)|승인\s*없이|\bwithout\s+(?:asking|stopping|confirmation)\b|\bdon'?t\s+(?:stop|ask)\b/i;
234
+ const 멈추지마 = /(?:중간에\s*)?(?:멈추지|묻지|물어보지|확인\s*요청하지|되묻지)\s*(?:는)?\s*(?:말|마)|끝까지\s*(?:수행|진행|해)|스스로\s*진행|알아서\s*(?:진행|해)|승인\s*없이|\bwithout\s+(?:asking|stopping|confirmation)\b|\bdo\s*n[o']?t\s+(?:stop|ask|pause|halt)\b|\bcontinue\s+autonomously\b|\bproceed\s+autonomously\b|\bwithout\s+(?:pausing|waiting|approval)\b/i;
216
235
 
217
236
  /** 사람이 「묻지 말고 끝까지 하라」 고 적어 뒀나. */
218
237
  export function 묻지말라했나(text) {
@@ -292,9 +311,36 @@ const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|
292
311
  // 파일을 못 고치는 모드. modes.js 에서 쓰기 도구를 안 주는 것들과 같아야 한다.
293
312
  const 읽기만하는모드 = new Set(['architect', 'plan', 'ask', 'inspect']);
294
313
 
314
+ /*
315
+ * ── 영어로 시킨 말은 하나도 못 보고 있었다 ──────────────────────────────
316
+ *
317
+ * 위 규칙은 어미(`줘` · `해라` …)로 시킴꼴을 가린다. 영어에는 그 어미가 없다.
318
+ * 그래서 "Build a complete web application." 이 **손대라는 말이 아닌 것**으로
319
+ * 읽혔고, 읽기 전용 모드(설계·계획·점검·묻기)가 후보에서 안 빠졌다.
320
+ *
321
+ * 영어는 **문장 첫머리의 명령형**으로 가린다. 그래야 시키는 말과 설명하는
322
+ * 말이 갈린다 —
323
+ *
324
+ * "Build a work request app." → 손대라는 말이다
325
+ * "Explain how the build works." → 아니다 (build 가 첫머리가 아니다)
326
+ * "The build system is broken." → 아니다
327
+ *
328
+ * 목록에 없는 동사는 손대라는 말이 아니다. 한국어 쪽과 같은 규칙이다 —
329
+ * 넓히면 「설명해 줘」 가 고치라는 말이 되고, 그러면 읽기 전용 모드가 영영
330
+ * 안 골라진다.
331
+ */
332
+ /*
333
+ * 정규식 리터럴로 적는다. 글자열을 이어 붙여 new RegExp 에 넣으면 홑따옴표
334
+ * 안에서 `\s` 의 역슬래시가 사라지고 `\b` 는 백스페이스 글자가 된다 —
335
+ * 검사에서 잡히기 전까지 조용히 아무것도 안 맞는다. 실제로 그렇게 났다.
336
+ */
337
+ const 영어손대라 =
338
+ /(?:^|[.!?\n]\s*|^\s*\d+[.)]\s*|^\s*[-*]\s*)(?:please\s+)?(?:build|implement|create|write|make|add|fix|refactor|remove|delete|rename|migrate|update|modify|change|generate|scaffold|set\s+up|develop)\b/im;
339
+
295
340
  /** 파일을 손대라는 말이 들었나. */
296
341
  export function 손대라했나(text) {
297
- return 손대라는말.test(String(text ?? ''));
342
+ const s = String(text ?? '');
343
+ return 손대라는말.test(s) || 영어손대라.test(s);
298
344
  }
299
345
 
300
346
  /**
@@ -343,13 +389,36 @@ export function route(text) {
343
389
  for (const [m, n] of 순위) if (읽기만하는모드.has(m) && n > 0) 뺀것.push(m);
344
390
  순위 = 순위.filter(([m]) => !읽기만하는모드.has(m));
345
391
  }
392
+ /*
393
+ * ── 긴 명세에서는 한 번 걸린 낱말이 결정하면 안 된다 ──────────────────
394
+ *
395
+ * 점수표는 **한마디**를 보라고 만든 것이다. 「이거 왜 안 되지」 처럼 짧은
396
+ * 말에서는 낱말 하나가 곧 뜻이다.
397
+ *
398
+ * 그런데 5,000자짜리 명세를 붙여 넣으면 이야기가 달라진다. 그런 글에는
399
+ * 모든 모드의 낱말이 한 번씩은 나온다 — 설계도 나오고, 검사도 나오고,
400
+ * 보안도 나온다. 거기서 한 번 걸린 것은 **뜻이 아니라 소음**이다.
401
+ *
402
+ * 실제로 겪었다. 「빈 폴더에서 웹 앱을 처음부터 만들어라」 는 5,254자
403
+ * 지시문이 `fails` 한 낱말 때문에 디버그로 갔다. 그 글에 그 낱말은
404
+ * 「검사가 실패하면 원인을 조사하라」 는 조건절에 딱 한 번 있었다.
405
+ *
406
+ * 그래서 글이 길면 **더 뚜렷할 때만** 고른다. 못 고르면 종합에 그대로
407
+ * 두는데, 긴 명세에는 그게 오히려 맞는 답이다 — 종합은 걸음마다 단계를
408
+ * 따라가므로(agent/단계.js) 계획에서 시작해 만들기로 알아서 넘어간다.
409
+ */
410
+ const 긴글 = s.length >= 1500;
411
+ const 더줘야 = 긴글 ? 2 : 0;
412
+
346
413
  const [으뜸, 점수] = 순위[0] ?? [null, 0];
347
- if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
414
+ if (!으뜸 || 점수 < (문턱[으뜸] ?? 4) + 더줘야) {
348
415
  // 왜 안 바꿨는지는 화면에 그대로 뜬다. "설계로 갈 뻔했는데 고치라는 말이라
349
416
  // 안 보냈다" 와 "그냥 모르겠다" 는 사람에게 아주 다른 말이다.
350
- const 왜 = 뺀것.length
351
- ? `${뺀것.join('·')} 신호가 있었지만 고치라는 말이라 읽기 전용 모드로 안 보냄`
352
- : '무슨 일인지 뚜렷하지 않음';
417
+ const 왜 = 긴글 && 으뜸 && 점수 >= (문턱[으뜸] ?? 4)
418
+ ? `글이 길어 ${으뜸} 신호 하나로는 정함 종합에서 단계를 따라갑니다`
419
+ : 뺀것.length
420
+ ? `${뺀것.join('·')} 신호가 있었지만 고치라는 말이라 읽기 전용 모드로 안 보냄`
421
+ : '무슨 일인지 뚜렷하지 않음';
353
422
  return { mode: null, score: 점수, why: 왜, 겹침: false, 점수들, 뺀것 };
354
423
  }
355
424
 
@@ -3,10 +3,10 @@
3
3
  import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import {
6
- req, headersFor, serverMessage, Aborted, 잠잠기본, 초로,
6
+ req, headersFor, serverMessage, Aborted, 잠잠기본, 연결기본, 초로,
7
7
  무소식기본, 무소식알림기본, 소식없음오류,
8
8
  } from './http.js';
9
- import { 할당량기억, 미리기다릴까, 마지막할당량, 할당량자리 } from './quota.js';
9
+ import { 할당량기억, 미리기다릴까, 왜띄우나, 마지막할당량, 할당량자리 } from './quota.js';
10
10
  import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
11
11
  import { 말 } from '../i18n/index.js';
12
12
  import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
@@ -1011,9 +1011,10 @@ function 몸덤프(body) {
1011
1011
  */
1012
1012
  async function 미리비키기(opts, conn) {
1013
1013
  // **이 창구** 것만 본다. 옆 창구가 바닥났다고 이쪽이 기다리면 안 된다.
1014
- const ms = 미리기다릴까(마지막할당량(할당량자리(conn)));
1014
+ const = 마지막할당량(할당량자리(conn));
1015
+ const ms = 미리기다릴까(것);
1015
1016
  if (!ms) return 0;
1016
- opts.onBackoff?.({ type: 'backoff', status: 429, code: null, wait: ms, attempt: 0, max: 0, 미리: true });
1017
+ opts.onBackoff?.({ type: 'backoff', status: 429, code: null, wait: ms, attempt: 0, max: 0, 미리: true, 왜: 왜띄우나(것) });
1017
1018
  await 기다리기(ms, opts.signal ?? null);
1018
1019
  return ms;
1019
1020
  }
@@ -1086,11 +1087,13 @@ export async function chat(conn, opts) {
1086
1087
  headers: await 머리말짓기(conn, opts),
1087
1088
  body,
1088
1089
  timeout: opts.timeout ?? 300000,
1090
+ 연결: conn.연결 ?? 연결기본,
1089
1091
  signal: opts.signal ?? null,
1090
1092
  });
1091
1093
  // 서버가 남았다고 말해 준 할당량을 적어 둔다 (backend/quota.js).
1092
1094
  // 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
1093
- 할당량기억(r.headers, 할당량자리(conn));
1095
+ // 막힌 것이면 그 사실도 적는다 — 다음 부름이 이걸 보고 띄운다 (backend/quota.js).
1096
+ 할당량기억(r.headers, 할당량자리(conn), { 막힘: r.status === 429 });
1094
1097
  // 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
1095
1098
  // 모른 채로 원래 이름을 받는다.
1096
1099
  if (r.ok) {
@@ -1174,9 +1177,10 @@ export async function* chatStream(conn, opts) {
1174
1177
  let 열쇠다시받음 = false;
1175
1178
  let 쌓인대기 = 0;
1176
1179
  {
1177
- const 미리 = 미리기다릴까(마지막할당량(할당량자리(conn)));
1180
+ const 잰것 = 마지막할당량(할당량자리(conn));
1181
+ const 미리 = 미리기다릴까(잰것);
1178
1182
  if (미리) {
1179
- yield { type: 'backoff', status: 429, code: null, wait: 미리, attempt: 0, max: 0, 미리: true };
1183
+ yield { type: 'backoff', status: 429, code: null, wait: 미리, attempt: 0, max: 0, 미리: true, 왜: 왜띄우나(잰것) };
1180
1184
  await 기다리기(미리, opts.signal ?? null);
1181
1185
  쌓인대기 += 미리;
1182
1186
  }
@@ -1192,11 +1196,13 @@ export async function* chatStream(conn, opts) {
1192
1196
  * 모델의 답이 5분에서 잘리고, 멎어 버린 게이트웨이는 5분을 꽉 채운다.
1193
1197
  */
1194
1198
  timeout: opts.timeout ?? 300000,
1199
+ // 붙는 데까지만 재는 시계. 못 붙은 것은 다시 부른다 (backend/retry.js).
1200
+ 연결: conn.연결 ?? 연결기본,
1195
1201
  잠잠: conn.잠잠 ?? 잠잠기본,
1196
1202
  stream: true,
1197
1203
  signal: opts.signal ?? null,
1198
1204
  });
1199
- 할당량기억(r.headers ?? r.res?.headers, 할당량자리(conn));
1205
+ 할당량기억(r.headers ?? r.res?.headers, 할당량자리(conn), { 막힘: r.status === 429 });
1200
1206
  if (r.ok && r.res?.body) break;
1201
1207
  const 거절 = await 거절읽기(r);
1202
1208
  // 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
@@ -25,6 +25,7 @@ import { Readable } from 'node:stream';
25
25
  import { checkUrl, NetBlocked } from '../safety/network.js';
26
26
  import { 프록시고르기 } from './proxy.js';
27
27
  import { 인증서찾기 } from './clientcert.js';
28
+ import { 말 as 옮긴말 } from '../i18n/index.js';
28
29
 
29
30
  export const AUTH_STYLES = [
30
31
  { id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
@@ -79,11 +80,11 @@ export class Aborted extends Error {
79
80
  * 부르는 쪽이 사람 말로 된 한 줄을 그대로 보여 주면 되게.
80
81
  * 자물쇠(NetBlocked)와 사용자 중단(Aborted)만 던진다. 둘은 통신 실패가 아니다.
81
82
  */
82
- export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 잠잠 = 0 } = {}) {
83
+ export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 잠잠 = 0, 연결 = 연결기본 } = {}) {
83
84
  const started = Date.now();
84
85
  try {
85
86
  const r = await 원시요청(url, {
86
- method, headers, timeout, stream, signal, 잠잠,
87
+ method, headers, timeout, stream, signal, 잠잠, 연결,
87
88
  body: body === undefined ? undefined : JSON.stringify(body),
88
89
  });
89
90
  // 프록시가 407 로 막은 것은 통신 실패도 서버 답도 아니다. 사람 말 한 줄로 준다.
@@ -117,7 +118,7 @@ const 되돌림상태 = new Set([301, 302, 303, 307, 308]);
117
118
  * 아니면 { ok, status, headers, bytes, text, json, ms }
118
119
  * 프록시가 407 로 막으면 { ok:false, status:407, error } (몸은 없다)
119
120
  */
120
- export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5, 잠잠 = 0 } = {}) {
121
+ export async function 원시요청(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null, 되돌림 = null, 최대홉 = 5, 잠잠 = 0, 연결 = 연결기본 } = {}) {
121
122
  const started = Date.now();
122
123
  let 지금 = String(url);
123
124
  let 방법 = method;
@@ -135,7 +136,7 @@ export async function 원시요청(url, { method = 'GET', headers = {}, body, ti
135
136
  */
136
137
  const 인증서 = 인증서찾기(지금);
137
138
  const r = (프록시 || 인증서)
138
- ? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠 })
139
+ ? await 노드로(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 프록시, 인증서, 잠잠, 연결 })
139
140
  : await 직접(지금, { method: 방법, headers: 머리, body: 몸, timeout, stream, signal, 잠잠 });
140
141
 
141
142
  const loc = 되돌림상태.has(r.status) ? r.headers?.get?.('location') : null;
@@ -276,6 +277,36 @@ export const 무소식알림기본 = 90000;
276
277
  */
277
278
  export const 무소식기본 = 600000;
278
279
 
280
+ /**
281
+ * 소켓을 **얻는 데까지** 줄 시간.
282
+ *
283
+ * 위의 두 시계는 붙은 뒤를 잰다. 이건 붙기 전을 잰다 — 셋이 재는 자리가 다르다.
284
+ *
285
+ * ── 왜 따로 있어야 하나 ────────────────────────────────────────────────
286
+ *
287
+ * 곧장 가는 길(fetch)에는 원래 이 시계가 있다. undici 가 10초를 세고
288
+ * `UND_ERR_CONNECT_TIMEOUT` 을 준다. 그런데 **프록시·인증서를 쓰는 길**
289
+ * (노드로)에는 없었다. 거기서는 머리말 시계 하나가 「붙는 시간」 과 「답을
290
+ * 기다리는 시간」 을 같이 재고 있었고, 그 시계는 5분이다.
291
+ *
292
+ * 그래서 같은 게이트웨이인데 프록시를 켠 사람만, 망이 잠깐 끊긴 자리에서
293
+ * **5분을 다 쓰고 나서** 턴이 죽었다. 곧장 가는 사람은 10초 만에 알고
294
+ * 다시 붙는데 말이다. 설명하기 제일 어려운 종류의 차이다.
295
+ *
296
+ * 그래서 10초로 맞춘다. 두 길이 같은 자리에서 같은 말을 하게 하는 것이
297
+ * 이 상수가 있는 이유고, 그러면 다시 부르는 규칙(backend/retry.js)도
298
+ * 두 길에 똑같이 걸린다.
299
+ */
300
+ export const 연결기본 = 10000;
301
+
302
+ /** 붙지도 못한 것. 다시 불러도 되는 자리라 코드를 따로 준다 (backend/retry.js). */
303
+ export function 못붙음오류(연결) {
304
+ return Object.assign(
305
+ new Error(`${초로(연결)}초 안에 연결하지 못했습니다`),
306
+ { name: 'ConnectTimeoutError', code: 'CONNECT_TIMEOUT', 연결 },
307
+ );
308
+ }
309
+
279
310
  export function 소식없음오류(무소식) {
280
311
  return Object.assign(
281
312
  new Error(`${초로(무소식)}초 동안 살아 있다는 신호만 오고 내용이 안 왔습니다`),
@@ -397,7 +428,7 @@ async function 직접흘려(url, { method, headers, body, timeout, signal, 잠
397
428
  * 둘 다 「fetch 로는 못 하는 것」 이라 길이 같다. 여기가 프록시 전용이던 시절
398
429
  * 이름이 프록시로 였는데, 인증서를 실으면서 아닌 자리가 생겼다.
399
430
  */
400
- function 노드로(url, { method, headers, body, timeout, stream, signal, 프록시 = null, 인증서 = null, 잠잠 = 0 }) {
431
+ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록시 = null, 인증서 = null, 잠잠 = 0, 연결 = 연결기본 }) {
401
432
  const 대상 = new URL(url);
402
433
  const 포트 = Number(대상.port || (대상.protocol === 'https:' ? 443 : 80));
403
434
  /*
@@ -412,17 +443,32 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
412
443
  const sig = 손.signal;
413
444
  let 멎음 = null;
414
445
  let 시간초과 = false;
446
+ let 못붙음 = false;
415
447
  let 머리시계 = setTimeout(() => { 시간초과 = true; 손.abort(); }, timeout);
416
448
  const 시계끄기 = () => { if (머리시계) { clearTimeout(머리시계); 머리시계 = null; } };
449
+ /*
450
+ * ── 붙는 데까지만 재는 시계 ──────────────────────────────────────────
451
+ *
452
+ * 곧장 가는 길에는 undici 가 이걸 갖고 있다(10초). 여기엔 없어서, 프록시를
453
+ * 켠 사람만 망이 끊긴 자리에서 **머리말 시계 5분을 통째로** 쓰고 죽었다.
454
+ * 두 길이 같은 자리에서 같은 말을 하게 맞춘다 (연결기본).
455
+ *
456
+ * 소켓을 얻는 순간 끈다. 그 뒤로 오래 걸리는 것은 「못 붙은 것」 이 아니라
457
+ * 「생각하는 중」 이고, 그건 머리말 시계와 잠잠 시계가 맡는 자리다.
458
+ */
459
+ let 연결시계 = 연결 > 0 ? setTimeout(() => { 못붙음 = true; 손.abort(); }, 연결) : null;
460
+ const 붙었다 = () => { if (연결시계) { clearTimeout(연결시계); 연결시계 = null; } };
417
461
  const 사람이 = () => 손.abort();
418
462
  if (signal?.aborted) 손.abort();
419
463
  else signal?.addEventListener('abort', 사람이, { once: true });
420
- // 끊긴 까닭은 셋을 가른다 — 사람이 끊은 것 · 시계가 끊은 것 · 흐름이 멎은 것.
421
- // 화면에서 셋이 다른 말이고, 다시 불러도 되는지도 셋이 다르다.
464
+ // 끊긴 까닭을 가른다 — 사람이 끊은 것 · 못 붙은 것 · 시계가 끊은 것 · 흐름이 멎은 것.
465
+ // 화면에서 넷이 다른 말이고, 다시 불러도 되는지도 넷이 다르다.
422
466
  const 왜끊겼나 = () => (signal?.aborted ? new Aborted()
423
- : 멎음 ?? (시간초과
424
- ? Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' })
425
- : new Aborted()));
467
+ : 멎음 ?? (못붙음
468
+ ? 못붙음오류(연결)
469
+ : 시간초과
470
+ ? Object.assign(new Error('시간 초과 — 응답이 없습니다'), { name: 'TimeoutError', code: 'TimeoutError' })
471
+ : new Aborted()));
426
472
 
427
473
  return new Promise((resolve, reject) => {
428
474
  let rq = null;
@@ -430,6 +476,7 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
430
476
  let 끝났나 = false;
431
477
  const 정리 = () => {
432
478
  시계끄기();
479
+ 붙었다();
433
480
  sig.removeEventListener('abort', 끊기);
434
481
  signal?.removeEventListener('abort', 사람이);
435
482
  };
@@ -535,6 +582,19 @@ function 노드로(url, { method, headers, body, timeout, stream, signal, 프록
535
582
  }, 받기);
536
583
  }
537
584
  if (sig.aborted) return 끊기();
585
+ /*
586
+ * 소켓을 얻는 순간 연결 시계는 할 일이 끝났다.
587
+ *
588
+ * 터널로 받은 소켓은 이미 붙어 있다(createConnection) — `connecting` 이
589
+ * 거짓이라 그 자리에서 바로 꺼진다. 새로 붙는 소켓은 TCP 면 `connect`,
590
+ * TLS 면 `secureConnect` 에서 꺼진다. 둘 다 걸어 두면 어느 길로 와도
591
+ * 한 번은 꺼진다.
592
+ */
593
+ rq.once('socket', (sock) => {
594
+ if (!sock || sock.connecting !== true) return 붙었다();
595
+ sock.once('connect', 붙었다);
596
+ sock.once('secureConnect', 붙었다);
597
+ });
538
598
  rq.once('error', (e) => 실패(e));
539
599
  if (body !== undefined) rq.write(body);
540
600
  rq.end();
@@ -630,6 +690,15 @@ function normalizeError(err) {
630
690
  if (err?.code === 'CERT_READ') return m;
631
691
  // 흐름이 멎어서 우리가 끊은 것. 서버가 끊은 것과 다른 말이어야 한다.
632
692
  if (err?.code === 'STALL') return `${m} — 답을 통째로 모았다가 주는 게이트웨이면 프로필의 잠잠 을 올려 보세요`;
693
+ /*
694
+ * 아직 **붙지도 못한** 것. 아래의 「응답이 없습니다」 와 반드시 갈라야 한다 —
695
+ * 저건 서버가 받아 놓고 답을 안 한 것이고, 이건 서버까지 가지도 못한 것이다.
696
+ * 사람이 볼 자리가 다르고(망·프록시 대 게이트웨이), 다시 불러도 되는지도
697
+ * 다르다 (backend/retry.js 의 못붙은코드).
698
+ */
699
+ if (코드 === 'CONNECT_TIMEOUT' || 코드 === 'UND_ERR_CONNECT_TIMEOUT') {
700
+ return '연결하지 못했습니다 — 망이나 프록시를 확인하세요 (잠시 뒤 다시 해 봅니다)';
701
+ }
633
702
  if (err?.name === 'TimeoutError' || 코드 === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
634
703
  if (/ENOTFOUND|EAI_AGAIN/.test(코드) || /ENOTFOUND|getaddrinfo/i.test(m)) return '주소를 찾을 수 없습니다 (DNS)';
635
704
  if (코드 === 'ECONNREFUSED' || /ECONNREFUSED/i.test(m)) return '연결이 거부되었습니다 (서버가 꺼져 있거나 포트가 다릅니다)';
@@ -700,6 +769,41 @@ export function 프록시힌트(말) {
700
769
  return `게이트웨이 앞단 프록시가 '${도구}' 를 돌리다 실패했습니다 — 모델이 아니라 프록시 쪽 문제입니다.`;
701
770
  }
702
771
 
772
+ /**
773
+ * 429 본문을 보고 **무슨 한도인지** 알아본다.
774
+ *
775
+ * ── 왜 이게 필요한가 ────────────────────────────────────────────────────
776
+ *
777
+ * 화면에 「서버가 잠시 막았습니다 (HTTP 429)」 만 뜨면, 사람이 할 수 있는 것이
778
+ * 기다리는 것밖에 없다. 그런데 429 는 **한 가지가 아니다.** 어떤 것은 1분 뒤에
779
+ * 저절로 풀리고, 어떤 것은 관리자가 할당량을 올려 주기 전까지 안 풀린다. 그
780
+ * 둘에 같은 말을 하면 사람은 30분을 기다리다 포기한다.
781
+ *
782
+ * 실제로 이런 것이 왔다.
783
+ *
784
+ * Too many requests sent to ApplyGuardrail: On-demand ApplyGuardrail
785
+ * sensitive information policy text units per second limit exceeded.
786
+ *
787
+ * 이건 모델 토큰 한도가 아니다. **가드레일이 훑는 글의 양**이고, 단위가 초당
788
+ * text unit(1 TU 는 1,000자쯤)이다. 그래서 대화가 자랄수록 매 요청이 커지고,
789
+ * 어느 순간부터 **매번** 걸린다 — 「잘하다가 갑자기」 가 이 모양이다.
790
+ *
791
+ * 그리고 프리픽스 캐시가 여기서는 하나도 안 듣는다. 캐시는 모델 쪽 이야기고,
792
+ * 가드레일은 캐시와 무관하게 매번 전체 글을 훑는다. 이걸 모르면 「캐시가 걸리는데
793
+ * 왜 한도에 걸리지」 에서 막힌다.
794
+ *
795
+ * 원문은 지우지 않고 뒤에 남긴다 — 사내 담당자에게 그대로 보여 줘야 할 때가
796
+ * 있다. 프록시힌트 와 같은 자세다.
797
+ */
798
+ export function 막힘힌트(글) {
799
+ const s = String(글 ?? '');
800
+ // 가드레일: 초당 **글의 양**. 기다린다고 안 풀리고, 프롬프트를 줄이거나 한도를 올려야 한다.
801
+ if (/guardrail/i.test(s) && /text\s*units?/i.test(s)) return 옮긴말('net.limit.guardrail');
802
+ // 분당 한도: 창이 새로 열린다. 여기서는 기다리는 것이 맞는 답이다.
803
+ if (/per\s*minute|\bTPM\b|\bRPM\b/i.test(s)) return 옮긴말('net.limit.perMinute');
804
+ return null;
805
+ }
806
+
703
807
  // 서버가 준 오류 본문에서 사람이 읽을 문장만 뽑는다.
704
808
  export function serverMessage(r) {
705
809
  if (r.error) return r.error;
@@ -712,6 +816,6 @@ export function serverMessage(r) {
712
816
  else return `HTTP ${r.status}`;
713
817
  // 알아본 것이 있으면 원문 대신 그것을 앞에 세운다. 원문은 뒤에 한 줄로 남긴다 —
714
818
  // 사내 담당자에게 그대로 보여 줘야 할 때가 있다.
715
- const 힌트 = 프록시힌트(말);
819
+ const 힌트 = 프록시힌트(말) ?? (Number(r.status) === 429 ? 막힘힌트(말) : null);
716
820
  return 힌트 ? `${힌트}\n원문: ${말.slice(0, 160)}` : 말;
717
821
  }
@@ -201,9 +201,27 @@ export function 할당량자리(conn) {
201
201
  return `${host}|${String(conn.model ?? '').trim()}${꼴 ? `#${꼴}` : ''}`;
202
202
  }
203
203
 
204
- export function 할당량기억(머리, 어디 = '') {
204
+ /**
205
+ * 응답 머리에서 할당량을 읽어 적어 둔다.
206
+ *
207
+ * `막힘` 은 **그 응답이 429 였나**다. 머리에 남은 수가 안 실려 와도 이 한
208
+ * 글자는 안다 — 그리고 그것만으로도 다음 요청을 띄울 까닭이 된다
209
+ * (아래 미리기다릴까).
210
+ *
211
+ * @param {boolean} [옵션.막힘] 이 응답이 429 였나
212
+ */
213
+ export function 할당량기억(머리, 어디 = '', { 막힘 = false } = {}) {
205
214
  const 것 = 할당량읽기(머리);
206
- if (것.있나) {
215
+ 것.막힘 = !!막힘;
216
+ /*
217
+ * 429 는 머리가 비어 있어도 적어 둔다.
218
+ *
219
+ * 게이트웨이 상당수가 막을 때 남은 수도 `Retry-After` 도 안 준다. 그래도
220
+ * **방금 막혔다는 사실**은 우리가 안다. 그 하나로 다음 요청을 조금 띄울 수
221
+ * 있고, 그게 이 루프에서 제일 크게 듣는다 — 200걸음짜리 턴에서 41번째가
222
+ * 바로 다시 두드리면 한도는 영영 안 풀린다.
223
+ */
224
+ if (것.있나 || 막힘) {
207
225
  const 적을것 = { ...것, 때: Date.now() };
208
226
  마지막 = 적을것;
209
227
  if (어디) {
@@ -212,6 +230,15 @@ export function 할당량기억(머리, 어디 = '') {
212
230
  // 오래 안 쓴 자리부터 버린다. 창구를 옮겨 다녀도 표가 안 자란다.
213
231
  while (자리들.size > 자리최대) 자리들.delete(자리들.keys().next().value);
214
232
  }
233
+ } else if (어디) {
234
+ /*
235
+ * 머리가 없는 **성공** 응답. 적을 것은 없지만, 앞서 적어 둔 「막혔다」 는
236
+ * 지워야 한다. 안 지우면 한 번 막힌 뒤로 낡을 때까지(낡은값) 멀쩡한
237
+ * 요청마다 띄운다 — 고친 것이 아니라 새 고장이다.
238
+ */
239
+ const 옛 = 자리들.get(어디);
240
+ if (옛?.막힘) 자리들.set(어디, { ...옛, 막힘: false });
241
+ if (마지막?.막힘) 마지막 = { ...마지막, 막힘: false };
215
242
  }
216
243
  return 것;
217
244
  }
@@ -253,6 +280,23 @@ export function 할당량잊기(어디 = '') {
253
280
  export const 낡은값 = 60000; // 이보다 오래된 할당량으로는 안 정한다
254
281
  export const 미리기다림상한 = 60000;
255
282
 
283
+ /*
284
+ * 방금 막혔으면 다음 요청을 이만큼은 띄운다.
285
+ *
286
+ * 서버가 「언제 오라」 고 말해 줬으면 그 말을 쓴다(풀림). 이건 **아무 말도 안
287
+ * 해 줬을 때**의 바닥이다. 게이트웨이 상당수가 막을 때 남은 수도 `Retry-After`
288
+ * 도 안 준다 — 그때 우리가 아는 것은 「방금 막혔다」 하나뿐이다.
289
+ *
290
+ * 이건 서버의 창을 짐작하는 것이 아니다. **우리 박자를 늦추는 것**이다. 그
291
+ * 둘은 다르다 — 앞엣것은 없는 것을 지어내는 짓이고, 뒤엣것은 우리가 얼마나
292
+ * 빨리 두드릴지 우리가 정하는 것이다.
293
+ *
294
+ * 5초로 둔다. 실제로 본 한도가 **초당**이라(Bedrock 의 ApplyGuardrail text
295
+ * units per second) 몇 초만 띄워도 듣는다. 그러면서 한 번 막혔다고 사람이
296
+ * 체감할 만큼 느려지지도 않는다.
297
+ */
298
+ export const 막힘띄움 = 5000;
299
+
256
300
  /*
257
301
  * 어느 창구 것인지는 **부르는 쪽이 정해서 준다.** 기본값을 두지 않는다.
258
302
  *
@@ -261,14 +305,40 @@ export const 미리기다림상한 = 60000;
261
305
  * 방금 고친 그 고장이다. 기본값을 없애면 다음에 빠뜨렸을 때 조용히 옛
262
306
  * 동작으로 돌아가는 대신 눈에 보이게 어긋난다.
263
307
  */
308
+ /**
309
+ * 왜 띄우나 — `'바닥'`(남은 것이 0 이라고 서버가 말해 줌) 또는 `'막힘'`(방금 429).
310
+ *
311
+ * 화면 문구가 여기서 갈린다. 「할당량이 바닥났다」 는 **서버가 그렇게 말해 줬을
312
+ * 때만** 할 수 있는 말이다. 429 만 맞고 남은 수는 못 받은 자리에서 그 말을 하면
313
+ * 화면이 모르는 것을 아는 척하는 것이다 — 이 프로그램이 안 하기로 한 바로 그것.
314
+ */
315
+ export function 왜띄우나(것) {
316
+ if (!것) return null;
317
+ const 바닥난것 = (것.요청 !== null && 것.요청 <= 0) || (것.토큰 !== null && 것.토큰 <= 0);
318
+ if (바닥난것) return '바닥';
319
+ return 것.막힘 ? '막힘' : null;
320
+ }
321
+
264
322
  export function 미리기다릴까(것, 지금 = Date.now()) {
265
- if (!것?.있나) return null;
323
+ if (!것?.있나 && !것?.막힘) return null;
266
324
  if (!(것.때 > 0) || 지금 - 것.때 > 낡은값) return null;
325
+
267
326
  const 바닥난것 = (것.요청 !== null && 것.요청 <= 0) || (것.토큰 !== null && 것.토큰 <= 0);
268
- if (!바닥난것) return null;
269
- if (!(것.풀림 > 0)) return null;
270
- // 그 응답을 받은 뒤로 흐른 만큼은 빼 준다.
271
- const 남은초 = 것.풀림 - Math.floor((지금 - 것.때) / 1000);
272
- if (!(남은초 > 0)) return null;
273
- return Math.min(남은초 * 1000, 미리기다림상한);
327
+ // 서버가 언제 오라고 말해 줬으면 그 말이 먼저다. 바닥났다고 했거나 방금 막혔거나.
328
+ if ((바닥난것 || 것.막힘) && 것.풀림 > 0) {
329
+ // 그 응답을 받은 뒤로 흐른 만큼은 빼 준다.
330
+ const 남은초 = 것.풀림 - Math.floor((지금 - 것.때) / 1000);
331
+ if (남은초 > 0) return Math.min(남은초 * 1000, 미리기다림상한);
332
+ }
333
+
334
+ /*
335
+ * 아무 말도 없이 막기만 한 자리. 그래도 방금 막혔다는 것은 안다 —
336
+ * 그만큼은 띄운다 (막힘띄움). 바닥났다고만 하고 풀림 시각이 없는 것은
337
+ * 여기 안 들어온다. 언제 풀릴지 모르는 것과 방금 맞은 것은 다르다.
338
+ */
339
+ if (것.막힘) {
340
+ const 남은 = 막힘띄움 - (지금 - 것.때);
341
+ if (남은 > 0) return Math.min(남은, 미리기다림상한);
342
+ }
343
+ return null;
274
344
  }
@@ -20,8 +20,13 @@
20
20
  // 401 에 딱 하나 예외가 있는데, 그것도 여기가 아니라 backend/adapter.js 가 한다 —
21
21
  // 열쇠받기(safety/authcmd.js)가 걸려 있으면 **같은 열쇠로 다시 부르는 것이 아니라**
22
22
  // 새 열쇠를 받아서 부르는 것이라, 「불러 봐야 같다」 에 해당하지 않는다. 딱 한 번이다.
23
- // · ECONNREFUSED 는 서버가 꺼진 것이고, 시간 초과는 5분을 기다릴 일이 아니다.
24
- // 둘 다 안 부른다.
23
+ // · ECONNREFUSED 는 서버가 꺼진 것이고, 주소가 틀린 것(ENOTFOUND)은 물어도
24
+ // 틀리다. 둘 다 안 부른다.
25
+ // · **머리말 시계(5분)** 가 끝난 것도 안 부른다. 서버가 받아 놓고 답을 안 한
26
+ // 자리라, 5분을 또 기다릴 일이 아니다.
27
+ // · 다만 **아직 붙지도 못한 것**은 부른다 (아래 못붙은코드). 연결 시계는 10초라
28
+ // 다시 해 보는 값이 싸고, 소켓이 없었으니 서버에는 아무것도 안 갔다 — 다시
29
+ // 불러도 두 벌이 될 것이 없다. 무선이 끊겼다 붙는 동안이 전부 여기다.
25
30
  //
26
31
  // 얼마나 기다리나:
27
32
  // 서버가 Retry-After 로 말해 주면 그것(60초에서 자른다 — 그 이상은 사람이 결정할
@@ -42,14 +47,80 @@ import { Aborted } from './http.js';
42
47
  * 여기서 멎는다 — 얼마나 기다릴지 모르는 채로 붙드는 일이 없어야 한다.
43
48
  */
44
49
  export function 기본정책() {
45
- return { 최대: 3, 막힘최대: null, base: [1000, 2000, 4000], 흔들림: 0.3, 상한: 60000, 총상한: 300000 };
50
+ return {
51
+ 최대: 3, 막힘최대: null, base: [1000, 2000, 4000],
52
+ 막힘base: [5000, 15000, 30000],
53
+ 흔들림: 0.3, 상한: 60000, 총상한: 300000,
54
+ };
46
55
  }
47
56
 
57
+ /*
58
+ * ── 429 는 사다리가 따로여야 한다 ───────────────────────────────────────
59
+ *
60
+ * 502·503 은 「서버가 딸꾹질했다」 다. 1초 뒤에 다시 보내면 대개 된다.
61
+ * 429 는 **「네 몫을 다 썼다」** 다. 1초 뒤에 보내면 거의 확실히 또 막히고,
62
+ * 게이트웨이 상당수는 **막아서 돌려보낸 요청도 한도에 센다** — 빨리 두드릴수록
63
+ * 구덩이가 깊어진다. 같은 사다리를 쓰면 안 되는 까닭이 이것이다.
64
+ *
65
+ * 실제로 본 것(사내 LiteLLM → Bedrock):
66
+ *
67
+ * litellm.RateLimitError: BedrockException - Too many requests sent to
68
+ * ApplyGuardrail: On-demand ApplyGuardrail sensitive information policy
69
+ * text units per second limit exceeded.
70
+ * ... LiteLLM Retried: 2 times, LiteLLM Max Retries: 2
71
+ *
72
+ * 두 가지가 보인다.
73
+ *
74
+ * · 한도가 **초당**이다. 분당이 아니라 초당이라, 프롬프트가 크면 한 번의
75
+ * 요청만으로도 그 초의 몫을 다 쓴다.
76
+ * · **게이트웨이가 이미 두 번 다시 불렀다.** 우리가 그 답을 보기 전에 벌써
77
+ * 세 번 나간 것이다. 거기에 1초·2초·4초를 얹으면 7초 안에 여섯 번이다.
78
+ *
79
+ * 그래서 5초 · 15초 · 30초로 시작한다. 흔한 한도 창이 1분이라, 세 번째면
80
+ * 50초를 기다린 셈이 되어 창 하나를 거의 넘긴다. 서버가 `Retry-After` 로
81
+ * 말해 주면 **그 말이 언제나 이긴다** — 아래 기다릴시간 의 첫 줄이다.
82
+ */
83
+
48
84
  // 잠깐 막힌 것으로 보는 상태 코드. 529 는 Anthropic 계열 게이트웨이의 '과부하' 다.
49
85
  const 다시부를상태 = new Set([408, 429, 500, 502, 503, 504, 529]);
50
86
  // 머리말도 못 받고 끊긴 것. undici 는 상대가 닫으면 UND_ERR_SOCKET 으로 온다.
51
87
  const 다시부를코드 = new Set(['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNABORTED']);
52
88
 
89
+ /*
90
+ * ── 아직 **붙지도 못한** 것 ─────────────────────────────────────────────
91
+ *
92
+ * 위의 것들은 붙었다가 끊긴 자리다. 여기는 소켓이 아예 안 생긴 자리 —
93
+ * 손이 닿기 전이라 서버에는 아무것도 안 갔다. 그래서 **다시 부르는 것이
94
+ * 제일 안전한 종류**다. 두 벌이 될 답도, 반쯤 흘러간 글자도 없다.
95
+ *
96
+ * 왜 따로 두나: 이 파일은 여태 「시간 초과는 다시 안 부른다」 였고, 그건
97
+ * **머리말 시계(5분)** 를 두고 한 말로는 옳았다. 5분을 기다렸다가 또 5분을
98
+ * 기다리는 것은 사람을 붙드는 짓이다.
99
+ *
100
+ * 그런데 그 규칙이 **붙지도 못한 것**까지 같이 덮고 있었다. 연결 시계는
101
+ * 10초다(undici 의 UND_ERR_CONNECT_TIMEOUT · backend/http.js 의 연결기본).
102
+ * 10초 만에 못 붙은 것을 두 번 더 해 보는 값은 1초·2초 기다림이지 5분이
103
+ * 아니다. 그런데도 여태 턴이 통째로 죽었다 — 사람은 같은 말을 다시 치고,
104
+ * 그 턴에 읽어 둔 것은 날아갔다.
105
+ *
106
+ * 무선이 잠깐 끊기거나, 노트북이 깨어나거나, VPN 이 다시 붙는 동안이 전부
107
+ * 여기다. 몇 초 뒤에는 되는 것들이라 **한 세션이 거기서 끝날 이유가 없다.**
108
+ *
109
+ * `ENOTFOUND` 는 여기 없다. 이름이 틀린 것은 백 번 물어도 틀리다. 반대로
110
+ * `EAI_AGAIN` 은 규격이 「지금은 못 하겠다, 다시 물어라」 라고 정해 둔
111
+ * 코드라 여기 있다 — 이름이 같아도 뜻이 정반대다.
112
+ */
113
+ const 못붙은코드 = new Set([
114
+ 'UND_ERR_CONNECT_TIMEOUT', // undici 가 10초 안에 소켓을 못 얻음
115
+ 'ETIMEDOUT', // OS 가 TCP 연결에서 손을 뗌
116
+ 'EAI_AGAIN', // 이름 풀이가 **잠깐** 안 됨 (ENOTFOUND 와 다르다)
117
+ 'ENETUNREACH', // 망이 잠깐 없음 (깨어나는 중·VPN 이 붙는 중)
118
+ 'EHOSTUNREACH',
119
+ 'ENETDOWN',
120
+ 'ENETRESET',
121
+ 'CONNECT_TIMEOUT', // 우리가 노드 길에서 붙인 이름 (backend/http.js)
122
+ ]);
123
+
53
124
  /**
54
125
  * 이 실패를 두고 다시 불러도 되나.
55
126
  * @param {{status?: number, code?: string|null, attempt?: number}} 실패 attempt 는 방금 실패한 것이 몇 번째였나 (1부터)
@@ -60,20 +131,30 @@ export function 다시부를까({ status = 0, code = null, attempt = 1 } = {},
60
131
  const 한도 = Number(status) === 429 ? (정책.막힘최대 ?? 정책.최대) : 정책.최대;
61
132
  if (attempt > 한도) return false;
62
133
  if (status) return 다시부를상태.has(Number(status));
63
- return !!code && 다시부를코드.has(String(code));
134
+ const c = String(code ?? '');
135
+ return !!code && (다시부를코드.has(c) || 못붙은코드.has(c));
136
+ }
137
+
138
+ /** 아직 붙지도 못한 실패인가. 화면이 무슨 말을 할지 여기서 갈린다. */
139
+ export function 못붙은것인가(code) {
140
+ return 못붙은코드.has(String(code ?? ''));
64
141
  }
65
142
 
66
143
  /**
67
144
  * 몇 ms 기다릴까. 서버가 말해 준 것이 있으면 그것, 없으면 사다리.
68
145
  * @param {{attempt?: number, retryAfter?: string|number|null}} 자리
69
146
  */
70
- export function 기다릴시간({ attempt = 1, retryAfter = null } = {}, 정책 = 기본정책()) {
147
+ export function 기다릴시간({ attempt = 1, retryAfter = null, status = 0 } = {}, 정책 = 기본정책()) {
71
148
  const 서버말 = retryAfter읽기(retryAfter);
72
149
  if (서버말 !== null) return Math.min(서버말, 정책.상한);
150
+ // 429 는 제 사다리를 쓴다. 안 정해 뒀으면 기본 사다리로 떨어진다 — 예전
151
+ // 설정을 그대로 쓰는 사람에게 갑자기 다른 박자가 생기지는 않는다.
152
+ const 막힘인가 = Number(status) === 429;
153
+ const 고른것 = 막힘인가 ? (정책.막힘base ?? 정책.base) : 정책.base;
73
154
  // 사다리가 비었으면(설정이 이상하면) 기본 사다리로 — NaN 초를 기다릴 수는 없다.
74
- const 사다리 = Array.isArray(정책.base) && 정책.base.some(Number.isFinite)
75
- ? 정책.base.filter(Number.isFinite)
76
- : 기본정책().base;
155
+ const 사다리 = Array.isArray(고른것) && 고른것.some(Number.isFinite)
156
+ ? 고른것.filter(Number.isFinite)
157
+ : (막힘인가 ? 기본정책().막힘base : 기본정책().base);
77
158
  const 칸 = 사다리[Math.min(attempt, 사다리.length) - 1] ?? 사다리[사다리.length - 1];
78
159
  return Math.min(정책.상한, Math.round(칸 * (1 + Math.random() * 정책.흔들림)));
79
160
  }
@@ -118,7 +199,7 @@ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0)
118
199
  const code = r?.code ?? null;
119
200
  if (!다시부를까({ status, code, attempt }, 정책)) return null;
120
201
  const retryAfter = r?.headers?.get?.('retry-after') ?? r?.res?.headers?.get?.('retry-after') ?? null;
121
- const wait = 기다릴시간({ attempt, retryAfter }, 정책);
202
+ const wait = 기다릴시간({ attempt, retryAfter, status }, 정책);
122
203
  /*
123
204
  * 한 요청에서 기다린 것을 다 더해 울타리를 친다.
124
205
  *
@@ -143,6 +224,26 @@ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0)
143
224
  * 알림 한 덩이를 화면 말(i18n 의 loop.backoff)에 끼울 자리로 바꾼다.
144
225
  * 세 화면(repl · deel run · acp)이 같은 것을 본다 — 한 군데만 고치면 셋이 어긋난다.
145
226
  */
227
+ /**
228
+ * 이 알림에 쓸 화면 말 열쇠.
229
+ *
230
+ * 세 가지가 다른 말이라 세 열쇠다.
231
+ *
232
+ * loop.backoff 서버가 밀어냈다 — 기다렸다 **다시** 부른다 (n/max 가 있다)
233
+ * loop.quotaAhead 남은 것이 0 이라고 **서버가 말해 줬다** — 보내기 전에 비킨다
234
+ * loop.limitAhead 방금 429 를 맞았다. 남은 수는 모른다 — 우리 박자를 늦춘다
235
+ *
236
+ * 뒤의 둘을 한 말로 합치면 화면이 거짓말을 한다. 「할당량이 바닥났다」 는 서버가
237
+ * 그렇게 말해 줬을 때만 할 수 있는 말이다.
238
+ *
239
+ * 다섯 자리(repl · deel run · acp · consult 둘)가 이걸 같이 쓴다. 각자 갈라 놓으면
240
+ * 언젠가 하나가 빠지고, 빠진 자리는 `(0/0)` 을 찍는다 — 실제로 그랬다.
241
+ */
242
+ export function 알림말(ev) {
243
+ if (!ev?.미리) return 'loop.backoff';
244
+ return ev.왜 === '막힘' ? 'loop.limitAhead' : 'loop.quotaAhead';
245
+ }
246
+
146
247
  export function 알림채움(ev) {
147
248
  const 초 = (ev?.wait ?? 0) / 1000;
148
249
  return {
package/src/commands.js CHANGED
@@ -32,7 +32,7 @@ import { allowTemporarily } from './safety/network.js';
32
32
  import { chat, 규격이름, 더할머리 } from './backend/adapter.js';
33
33
  import { 잠잠기본, 무소식기본 } from './backend/http.js';
34
34
  import { 인증서설정, 인증서말 } from './backend/clientcert.js';
35
- import { 알림채움 } from './backend/retry.js';
35
+ import { 알림채움, 알림말 } from './backend/retry.js';
36
36
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
37
37
  import { 정한셸 } from './tools/shell.js';
38
38
  import { TOOLS, 영어설명 } from './tools/index.js';
@@ -277,7 +277,9 @@ export async function handle(line, session, ctx) {
277
277
  temperature: 0.3,
278
278
  // 서버가 잠깐 막으면 돌림표 뒤에 숨기지 않고 말한다 — 왜 오래 걸리는지 보여야 한다.
279
279
  onBackoff: (알림) => {
280
- 돌림.stop(` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`);
280
+ 돌림.stop((알림.미리
281
+ ? ` ${c.yellow('⏸')} ${c.gray(말(알림말(알림), { 초: 알림채움(알림).초 }))}`
282
+ : ` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`));
281
283
  돌림 = spin(말('consult.asking', { 모델: 새conn.model }));
282
284
  },
283
285
  });
@@ -644,7 +646,9 @@ export async function handle(line, session, ctx) {
644
646
  let s = spin('앞선 대화를 요약해 접는 중…');
645
647
  const r = await compact(session, {
646
648
  onBackoff: (알림) => {
647
- s.stop(` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`);
649
+ s.stop((알림.미리
650
+ ? ` ${c.yellow('⏸')} ${c.gray(말(알림말(알림), { 초: 알림채움(알림).초 }))}`
651
+ : ` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`));
648
652
  s = spin('앞선 대화를 요약해 접는 중…');
649
653
  },
650
654
  });
@@ -2289,7 +2293,9 @@ async function 커밋명령(session, ctx, arg = '') {
2289
2293
  전부,
2290
2294
  제목: 준제목,
2291
2295
  onBackoff: (알림) => {
2292
- 돌림.stop(` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`);
2296
+ 돌림.stop((알림.미리
2297
+ ? ` ${c.yellow('⏸')} ${c.gray(말(알림말(알림), { 초: 알림채움(알림).초 }))}`
2298
+ : ` ${c.yellow('↻')} ${c.gray(말('loop.backoff', 알림채움(알림)))}`));
2293
2299
  돌림 = spin(도는말);
2294
2300
  },
2295
2301
  });
package/src/i18n/en.js CHANGED
@@ -558,6 +558,9 @@ export const en = {
558
558
  'think.wireNoOff': 'this endpoint never told us its off value — the field is omitted, so the server default applies',
559
559
  'loop.wire': 'The server does not take that field — retrying with it adjusted ({무엇})',
560
560
  'loop.quotaAhead': 'Quota is empty — waiting {초}s before sending',
561
+ 'loop.limitAhead': 'Just rate-limited — spacing this out by {초}s',
562
+ 'net.limit.guardrail': 'This is a guardrail limit on the gateway, not a model token limit. It counts text scanned per second (text units, roughly 1,000 characters each), so as the conversation grows every request gets bigger and past some point it fails every time. Prompt caching does not help here — the guardrail scans the full text every time regardless. Raising the model per-minute request/token limits (RPM/TPM) does nothing to it: different API, different quota. /compact shrinks the conversation for immediate relief; the real fix is for whoever runs the gateway to raise the guardrail quota or stop applying it to input.',
563
+ 'net.limit.perMinute': 'This is a per-minute limit — it clears itself once the window rolls over. Waiting is the right answer here.',
561
564
  'cost.quota': 'Server quota',
562
565
  'cost.quotaAge': 'as of {초}s ago',
563
566
 
package/src/i18n/ja.js CHANGED
@@ -515,6 +515,9 @@ export const ja = {
515
515
  'think.wireNoOff': 'この窓口は切る値を教えてくれませんでした — 項目を送らないので、サーバー既定値で動きます',
516
516
  'loop.wire': 'サーバーが受け取らない項目があるため、直して呼び直します ({무엇})',
517
517
  'loop.quotaAhead': '残量が尽きたので {초} 秒待ってから送ります',
518
+ 'loop.limitAhead': '直前に制限に当たったので {초} 秒あけてから送ります',
519
+ 'net.limit.guardrail': 'ゲートウェイに付いたガードレールの上限です — モデルのトークン上限ではありません。数えているのは毎秒走査する文字量(text unit・1 TU はおよそ 1,000 文字)なので、会話が伸びるほど一回の要求が大きくなり、ある時点から毎回引っかかります。プレフィックスキャッシュはここでは効きません — ガードレールはキャッシュと無関係に毎回全文を走査します。モデルの毎分の要求・トークン上限(RPM・TPM)を上げても動きません。別の API の別の割当です。/compact で会話を縮めると当座はしのげます。根本はゲートウェイ担当者がガードレールの割当を上げるか、入力には掛けないようにすることです。',
520
+ 'net.limit.perMinute': '毎分の上限です — 窓が変われば自然に解けます。ここでは待つのが正解です。',
518
521
  'cost.quota': 'サーバー残量',
519
522
  'cost.quotaAge': '{초}秒前の応答基準',
520
523
 
package/src/i18n/ko.js CHANGED
@@ -605,6 +605,9 @@ export const ko = {
605
605
  'think.wireNoOff': '이 창구는 끄는 말을 안 알려 줬습니다 — 칸을 안 싣고, 서버 기본값으로 돕니다',
606
606
  'loop.wire': '서버가 안 받는 칸이 있어 맞춰 다시 부릅니다 ({무엇})',
607
607
  'loop.quotaAhead': '할당량이 바닥나서 {초}초 기다렸다 보냅니다',
608
+ 'loop.limitAhead': '방금 한도에 걸려서 {초}초 띄웠다 보냅니다',
609
+ 'net.limit.guardrail': '게이트웨이에 붙은 가드레일 한도입니다 — 모델 토큰 한도가 아닙니다. 재는 것이 초당 훑는 글의 양(text unit, 1 TU 가 1,000자쯤)이라, 대화가 자랄수록 매 요청이 커져 어느 순간부터 계속 걸립니다. 프리픽스 캐시는 여기서 안 듣습니다 — 가드레일은 캐시와 무관하게 매번 전체 글을 훑습니다. 모델의 분당 요청·토큰 한도(RPM·TPM)를 올려도 이건 안 움직입니다: 다른 API 의 다른 할당량입니다. /compact 로 대화를 줄이면 당장 낫고, 근본은 게이트웨이 담당자가 가드레일 할당량을 올리거나 입력에는 안 걸도록 바꾸는 것입니다.',
610
+ 'net.limit.perMinute': '분당 한도입니다 — 창이 새로 열리면 저절로 풀립니다. 여기서는 기다리는 것이 맞습니다.',
608
611
  'cost.quota': '서버 할당량',
609
612
  'cost.quotaAge': '{초}초 전 응답 기준',
610
613
 
package/src/i18n/zh.js CHANGED
@@ -515,6 +515,9 @@ export const zh = {
515
515
  'think.wireNoOff': '该端点未告知关闭值 — 不发送该字段,因此按服务器默认值运行',
516
516
  'loop.wire': '服务端不接受该字段,已调整后重试 ({무엇})',
517
517
  'loop.quotaAhead': '额度用尽,等待 {초} 秒后再发送',
518
+ 'loop.limitAhead': '刚被限流,间隔 {초} 秒后再发送',
519
+ 'net.limit.guardrail': '这是网关上护栏的限额,不是模型的令牌限额。它按每秒扫描的文本量计数(text unit,1 TU 约 1,000 个字符),所以对话越长每次请求越大,过了某个点就会每次都被拦。前缀缓存在这里没有用 — 护栏与缓存无关,每次都会扫描全文。提高模型的每分钟请求/令牌限额(RPM/TPM)不会有任何作用:那是另一个 API 的另一份配额。用 /compact 缩短对话可以先缓解;根本办法是让网关维护者提高护栏配额,或不要对输入应用它。',
520
+ 'net.limit.perMinute': '这是每分钟限额 — 窗口滚动后会自行解除,这里等待就是正确做法。',
518
521
  'cost.quota': '服务器额度',
519
522
  'cost.quotaAge': '基于{초}秒前的响应',
520
523
 
package/src/oneshot.js CHANGED
@@ -26,7 +26,7 @@ import { History } from './safety/undo.js';
26
26
  import { Audit, 열쇠묻기 } from './safety/audit.js';
27
27
  import { activeProfile, load, resolveKey, 잠금소식, 열쇠탈소식, 프로젝트설정소식 } from './config.js';
28
28
  import { 말 as 옮긴말 } from './i18n/index.js';
29
- import { 알림채움 } from './backend/retry.js';
29
+ import { 알림채움, 알림말 } from './backend/retry.js';
30
30
  import { 전선붙이기, 세션이름짓기 } from './backend/wire.js';
31
31
  import { newId } from './agent/store.js';
32
32
  import { discover, loadCommand } from './skills/discover.js';
@@ -605,7 +605,7 @@ export async function runOnce(opts = {}) {
605
605
  // 맞고 물러난 것과 맞기 전에 비킨 것은 다른 일이다. 같은 말로 적으면
606
606
  // 있지도 않은 429 가 배치 기록에 남는다.
607
607
  곁(ev.미리
608
- ? ` ${c.yellow('⏸')} ${c.gray(옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 }))}`
608
+ ? ` ${c.yellow('⏸')} ${c.gray(옮긴말(알림말(ev), { 초: 알림채움(ev).초 }))}`
609
609
  : ` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
610
610
  break;
611
611
 
package/src/repl.js CHANGED
@@ -12,7 +12,7 @@ import { headerLines } from './ui/status.js';
12
12
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
13
13
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
14
14
  import { 언어잡기, 말 as 옮긴말, 세말 } from './i18n/index.js';
15
- import { 알림채움 } from './backend/retry.js';
15
+ import { 알림채움, 알림말 } from './backend/retry.js';
16
16
  import { 전선붙이기, 세션이름짓기 } from './backend/wire.js';
17
17
  import { 화면고르기 } from './ui/screen.js';
18
18
  import { STAGES } from './agent/effort.js';
@@ -1877,7 +1877,7 @@ export async function chatLoop(opts = {}) {
1877
1877
  case 'backoff':
1878
1878
  clearThinking();
1879
1879
  say(ev.미리
1880
- ? ` ${c.yellow('⏸')} ${c.gray(옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 }))}`
1880
+ ? ` ${c.yellow('⏸')} ${c.gray(옮긴말(알림말(ev), { 초: 알림채움(ev).초 }))}`
1881
1881
  : ` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
1882
1882
  break;
1883
1883