deel-local-cli 1.17.8 → 1.18.0

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.
@@ -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}`; } },
@@ -679,7 +680,18 @@ function 오류코드(err) {
679
680
 
680
681
  // fetch 는 무슨 일이든 'fetch failed' 라고만 말하고 까닭은 cause 에 숨긴다. 그래서
681
682
  // 말(m)만 보지 않고 코드도 본다 — 안 그러면 DNS 실패도 "주소·포트·프록시를 확인하세요" 가 된다.
682
- function normalizeError(err) {
683
+ /*
684
+ * 내보내는 까닭은 **검사 때문**이다.
685
+ *
686
+ * 이 사다리는 서버가 준 글을 정규식으로 읽어 사람 말로 갈아 끼운다. 그 중
687
+ * 한 줄은 열쇠가 화면과 진단 보고서에 평문으로 흘러나가는 것을 막는 줄이다.
688
+ * 그런 줄이 조용히 죽어도 아무 데서도 안 터진다 — 그냥 원문이 그대로 나가고,
689
+ * 그 원문 안에 열쇠가 들어 있다.
690
+ *
691
+ * 부르는 길이 `req()` 하나뿐이라 검사판에서 갈래마다 재려면 진짜 통신을
692
+ * 실패시켜야 했다. 그래서 안 재고 있었다. 재지는 조각으로 내놓는다.
693
+ */
694
+ export function normalizeError(err) {
683
695
  const m = String(err?.message ?? err);
684
696
  const 코드 = String(오류코드(err) ?? '');
685
697
  const 기본 = (() => {
@@ -768,6 +780,41 @@ export function 프록시힌트(말) {
768
780
  return `게이트웨이 앞단 프록시가 '${도구}' 를 돌리다 실패했습니다 — 모델이 아니라 프록시 쪽 문제입니다.`;
769
781
  }
770
782
 
783
+ /**
784
+ * 429 본문을 보고 **무슨 한도인지** 알아본다.
785
+ *
786
+ * ── 왜 이게 필요한가 ────────────────────────────────────────────────────
787
+ *
788
+ * 화면에 「서버가 잠시 막았습니다 (HTTP 429)」 만 뜨면, 사람이 할 수 있는 것이
789
+ * 기다리는 것밖에 없다. 그런데 429 는 **한 가지가 아니다.** 어떤 것은 1분 뒤에
790
+ * 저절로 풀리고, 어떤 것은 관리자가 할당량을 올려 주기 전까지 안 풀린다. 그
791
+ * 둘에 같은 말을 하면 사람은 30분을 기다리다 포기한다.
792
+ *
793
+ * 실제로 이런 것이 왔다.
794
+ *
795
+ * Too many requests sent to ApplyGuardrail: On-demand ApplyGuardrail
796
+ * sensitive information policy text units per second limit exceeded.
797
+ *
798
+ * 이건 모델 토큰 한도가 아니다. **가드레일이 훑는 글의 양**이고, 단위가 초당
799
+ * text unit(1 TU 는 1,000자쯤)이다. 그래서 대화가 자랄수록 매 요청이 커지고,
800
+ * 어느 순간부터 **매번** 걸린다 — 「잘하다가 갑자기」 가 이 모양이다.
801
+ *
802
+ * 그리고 프리픽스 캐시가 여기서는 하나도 안 듣는다. 캐시는 모델 쪽 이야기고,
803
+ * 가드레일은 캐시와 무관하게 매번 전체 글을 훑는다. 이걸 모르면 「캐시가 걸리는데
804
+ * 왜 한도에 걸리지」 에서 막힌다.
805
+ *
806
+ * 원문은 지우지 않고 뒤에 남긴다 — 사내 담당자에게 그대로 보여 줘야 할 때가
807
+ * 있다. 프록시힌트 와 같은 자세다.
808
+ */
809
+ export function 막힘힌트(글) {
810
+ const s = String(글 ?? '');
811
+ // 가드레일: 초당 **글의 양**. 기다린다고 안 풀리고, 프롬프트를 줄이거나 한도를 올려야 한다.
812
+ if (/guardrail/i.test(s) && /text\s*units?/i.test(s)) return 옮긴말('net.limit.guardrail');
813
+ // 분당 한도: 창이 새로 열린다. 여기서는 기다리는 것이 맞는 답이다.
814
+ if (/per\s*minute|\bTPM\b|\bRPM\b/i.test(s)) return 옮긴말('net.limit.perMinute');
815
+ return null;
816
+ }
817
+
771
818
  // 서버가 준 오류 본문에서 사람이 읽을 문장만 뽑는다.
772
819
  export function serverMessage(r) {
773
820
  if (r.error) return r.error;
@@ -780,6 +827,6 @@ export function serverMessage(r) {
780
827
  else return `HTTP ${r.status}`;
781
828
  // 알아본 것이 있으면 원문 대신 그것을 앞에 세운다. 원문은 뒤에 한 줄로 남긴다 —
782
829
  // 사내 담당자에게 그대로 보여 줘야 할 때가 있다.
783
- const 힌트 = 프록시힌트(말);
830
+ const 힌트 = 프록시힌트(말) ?? (Number(r.status) === 429 ? 막힘힌트(말) : null);
784
831
  return 힌트 ? `${힌트}\n원문: ${말.slice(0, 160)}` : 말;
785
832
  }
@@ -28,6 +28,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
28
  import { createHash } from 'node:crypto';
29
29
  import { join } from 'node:path';
30
30
  import { VERSION } from '../version.js';
31
+ // 남의 저장소에 딸려 온 mcp.json 으로 남의 프로그램을 띄우지 않는다 (다붙이기 머리말).
32
+ import { 믿나 } from '../safety/trust.js';
31
33
 
32
34
  // 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
33
35
  export const 붙기제한 = 8000;
@@ -494,6 +496,40 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
494
496
  if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
495
497
  if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
496
498
 
499
+ /*
500
+ * ── 믿는 폴더에서만 띄운다 ──────────────────────────────────────────
501
+ *
502
+ * 이 파일 머리말은 네 가지를 약속하는데(기본 꺼짐 · 자물쇠면 안 띄움 ·
503
+ * 감사기록 · 범위 밖이라고 말함) **정작 제일 앞 문은 없었다.**
504
+ *
505
+ * `mcp.json` 은 프로젝트 폴더에 있고, 그러니 **저장소에 같이 딸려 온다.**
506
+ * 남의 저장소를 clone 하고 그 안에서 deel 을 켜면, 거기 적힌 `command` 가
507
+ * 이 계정 권한으로 자식 프로세스가 된다. 도구 승인 화면은 안 거친다 —
508
+ * 모델이 부른 것이 아니라 우리가 「서버를 띄우려고」 부른 것이기 때문이다.
509
+ *
510
+ * { "mcpServers": { "sync": { "command": "cmd", "args": ["/c", "…"] } } }
511
+ *
512
+ * 훅(safety/hooks.js)은 정확히 이 위협에 대해 `믿나(root)` 를 강제하고,
513
+ * 그 파일 머리말은 「MCP 와 같은 무게로 다룬다」 고 적어 두었다. 그런데
514
+ * 무게를 견주던 쪽에 그 문이 없었다. 프로젝트 설정(config.js)도 같은 문을
515
+ * 지나간다 — 남의 프로그램을 띄우는 이 자리가 셋 중 제일 무거운데 혼자
516
+ * 그냥 열려 있었다.
517
+ *
518
+ * 안 믿으면 **조용히 넘어가지 않는다.** 못 붙였다고 화면에 그대로 말하고,
519
+ * 어떻게 하면 되는지(deel trust)까지 같이 말한다.
520
+ */
521
+ if (!믿나(root, { env })) {
522
+ return {
523
+ 서버들: [],
524
+ 못한것: 설정.서버들.map((s) => ({
525
+ 이름: s.이름,
526
+ 왜: '믿는 폴더가 아닙니다 — 남의 저장소에 딸려 온 설정일 수 있어 안 띄웁니다 (deel trust)',
527
+ })),
528
+ 설정,
529
+ 안믿음: true,
530
+ };
531
+ }
532
+
497
533
  // 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
498
534
  // 우리는 못 막는다 — 막을 수 없는 것을 막았다고 말하지 않는다.
499
535
  if (offline) {
@@ -41,7 +41,7 @@ const READ_TOOL = {
41
41
  * · 도구가 {type:'function', function:{…}} 모양으로 간다
42
42
  * · 답을 choices[0].message 에서 찾는다 (실제로는 content 블록 배열)
43
43
  *
44
- * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 일곱 칸은 전부 「확인 불가」 로
44
+ * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 여덟 칸은 전부 「확인 불가」 로
45
45
  * 건너뛴다. 그 결과 `deel setup` 이 저장하는 프로필에 streaming·tools·json·
46
46
  * vision 이 **다 false** 로 적힌다 — Claude 를 붙였는데 도구를 아예 안 쓰는
47
47
  * 연결이 만들어진다. 붙기는 붙으니 아무도 고장이라고 생각하지 않는다.
@@ -140,7 +140,7 @@ export async function probe(conn, onStep = () => {}) {
140
140
  * 붙어 있어서, 그대로 이으면
141
141
  * `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 된다.
142
142
  * 그러면 **설치 화면이 제 검사에 통째로 실패한다** — 기본 대화가 안 되니
143
- * 나머지 일곱 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
143
+ * 나머지 여덟 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
144
144
  * 도구 호출도 안 된다고 적힌다. 붙기는 붙는데 반쪽짜리로 붙는다.
145
145
  */
146
146
  const url = (p) => 주소붙이기(base, p);
@@ -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
  }
@@ -47,9 +47,40 @@ import { Aborted } from './http.js';
47
47
  * 여기서 멎는다 — 얼마나 기다릴지 모르는 채로 붙드는 일이 없어야 한다.
48
48
  */
49
49
  export function 기본정책() {
50
- 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
+ };
51
55
  }
52
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
+
53
84
  // 잠깐 막힌 것으로 보는 상태 코드. 529 는 Anthropic 계열 게이트웨이의 '과부하' 다.
54
85
  const 다시부를상태 = new Set([408, 429, 500, 502, 503, 504, 529]);
55
86
  // 머리말도 못 받고 끊긴 것. undici 는 상대가 닫으면 UND_ERR_SOCKET 으로 온다.
@@ -113,13 +144,17 @@ export function 못붙은것인가(code) {
113
144
  * 몇 ms 기다릴까. 서버가 말해 준 것이 있으면 그것, 없으면 사다리.
114
145
  * @param {{attempt?: number, retryAfter?: string|number|null}} 자리
115
146
  */
116
- export function 기다릴시간({ attempt = 1, retryAfter = null } = {}, 정책 = 기본정책()) {
147
+ export function 기다릴시간({ attempt = 1, retryAfter = null, status = 0 } = {}, 정책 = 기본정책()) {
117
148
  const 서버말 = retryAfter읽기(retryAfter);
118
149
  if (서버말 !== null) return Math.min(서버말, 정책.상한);
150
+ // 429 는 제 사다리를 쓴다. 안 정해 뒀으면 기본 사다리로 떨어진다 — 예전
151
+ // 설정을 그대로 쓰는 사람에게 갑자기 다른 박자가 생기지는 않는다.
152
+ const 막힘인가 = Number(status) === 429;
153
+ const 고른것 = 막힘인가 ? (정책.막힘base ?? 정책.base) : 정책.base;
119
154
  // 사다리가 비었으면(설정이 이상하면) 기본 사다리로 — NaN 초를 기다릴 수는 없다.
120
- const 사다리 = Array.isArray(정책.base) && 정책.base.some(Number.isFinite)
121
- ? 정책.base.filter(Number.isFinite)
122
- : 기본정책().base;
155
+ const 사다리 = Array.isArray(고른것) && 고른것.some(Number.isFinite)
156
+ ? 고른것.filter(Number.isFinite)
157
+ : (막힘인가 ? 기본정책().막힘base : 기본정책().base);
123
158
  const 칸 = 사다리[Math.min(attempt, 사다리.length) - 1] ?? 사다리[사다리.length - 1];
124
159
  return Math.min(정책.상한, Math.round(칸 * (1 + Math.random() * 정책.흔들림)));
125
160
  }
@@ -164,7 +199,7 @@ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0)
164
199
  const code = r?.code ?? null;
165
200
  if (!다시부를까({ status, code, attempt }, 정책)) return null;
166
201
  const retryAfter = r?.headers?.get?.('retry-after') ?? r?.res?.headers?.get?.('retry-after') ?? null;
167
- const wait = 기다릴시간({ attempt, retryAfter }, 정책);
202
+ const wait = 기다릴시간({ attempt, retryAfter, status }, 정책);
168
203
  /*
169
204
  * 한 요청에서 기다린 것을 다 더해 울타리를 친다.
170
205
  *
@@ -189,6 +224,26 @@ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0)
189
224
  * 알림 한 덩이를 화면 말(i18n 의 loop.backoff)에 끼울 자리로 바꾼다.
190
225
  * 세 화면(repl · deel run · acp)이 같은 것을 본다 — 한 군데만 고치면 셋이 어긋난다.
191
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
+
192
247
  export function 알림채움(ev) {
193
248
  const 초 = (ev?.wait ?? 0) / 1000;
194
249
  return {
@@ -29,6 +29,31 @@ const 확장자 = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
29
29
  /** 한 장 최대 크기. 창 크기와 상관없이 이 위로는 안 싣는다. */
30
30
  export const 기본한도 = 4 * 1024 * 1024;
31
31
 
32
+ /**
33
+ * 한 변의 최대 픽셀. 이 위로는 서버가 400 을 준다.
34
+ *
35
+ * ── 왜 바이트만으로는 안 되나 ───────────────────────────────────────────
36
+ *
37
+ * 여태 크기만 보고 있었다. 그런데 **바이트와 픽셀은 따로 논다** — 화면을 길게
38
+ * 찍은 스크린샷은 잘 압축돼서 12000×3000 이어도 4MB 를 한참 밑돈다. 그래서
39
+ * 크기 검사를 멀쩡히 통과하고, 게이트웨이에서 이렇게 튕겼다.
40
+ *
41
+ * messages.8.content.1.image.source.bytes:
42
+ * At least one of the image dimensions exceed max allowed size: 8000 pixels
43
+ *
44
+ * ── 그리고 이건 그 턴 하나로 안 끝난다 ──────────────────────────────────
45
+ *
46
+ * 튕긴 뒤에도 그 그림은 **대화에 남는다.** 다음에 무슨 말을 걸어도 같은
47
+ * 메시지가 같이 나가서 같은 400 을 받는다. 「대시보드 실행해줘」 처럼 그림과
48
+ * 아무 상관 없는 말까지 안 된다 — 세션이 통째로 죽는다. 사람이 보기에는
49
+ * 프로그램이 갑자기 고장 난 것이고, 원인은 아홉 번째 메시지에 있다.
50
+ *
51
+ * 그러니 **들어오기 전에 막아야 한다.** 한 번 실려 들어가면 그 뒤는 늦다.
52
+ *
53
+ * 8000 은 Bedrock 이 거절한 값이고 Anthropic 규격이 적어 둔 값과 같다.
54
+ */
55
+ export const 픽셀한도 = 8000;
56
+
32
57
  /** 경로만 보고 그림인지. 실제로 그림인지는 그림읽기() 가 속을 보고 정한다. */
33
58
  export function 그림인가(경로) {
34
59
  const s = String(경로 ?? '').toLowerCase();
@@ -107,7 +132,91 @@ export function 그림읽기(abs, { 한도 = 기본한도 } = {}) {
107
132
  + ' 내려받다 만 파일이거나, 로그인 화면 HTML 이 그림 이름으로 저장된 것일 수 있습니다.',
108
133
  };
109
134
  }
110
- return { ok: true, b64: buf.toString('base64'), mime, bytes };
135
+
136
+ /*
137
+ * 치수를 본다. 못 읽으면 **막지 않는다** — 우리가 못 읽는 모양이라고 서버도
138
+ * 못 읽는다는 뜻은 아니다. 모르는 것을 이유로 막으면 멀쩡한 그림이 안 실린다.
139
+ */
140
+ const 잰것 = 치수읽기(buf, mime);
141
+ if (잰것 && (잰것.가로 > 픽셀한도 || 잰것.세로 > 픽셀한도)) {
142
+ return {
143
+ ok: false,
144
+ bytes,
145
+ 가로: 잰것.가로,
146
+ 세로: 잰것.세로,
147
+ 왜: `그림의 한 변이 깁니다 (${잰것.가로}×${잰것.세로} · 한 변 한도 ${픽셀한도}px) —`
148
+ + ' 잘라서 저장한 뒤 다시 주세요. 화면 전체를 길게 찍은 사진이 흔히 여기 걸립니다.'
149
+ + ' 여기서는 크기를 줄이지 않습니다. 줄이려면 다른 프로그램이 필요한데,'
150
+ + ' 이 도구는 아무것도 안 깔고 도는 것이 규칙입니다.',
151
+ };
152
+ }
153
+ return { ok: true, b64: buf.toString('base64'), mime, bytes, ...(잰것 ?? {}) };
154
+ }
155
+
156
+ /**
157
+ * 그림의 가로·세로를 **머리말만 읽어서** 잰다. 못 알아보면 null.
158
+ *
159
+ * 파일을 통째로 해석하지 않는다. 각 형식이 앞쪽에 치수를 적어 두므로 그 자리만
160
+ * 본다 — 의존성 0개를 지키면서 픽셀을 아는 유일한 길이다.
161
+ */
162
+ export function 치수읽기(buf, mime = null) {
163
+ // 10 은 형식들 가운데 제일 짧은 머리말(GIF)이다. 그보다 크게 잡으면 GIF 가
164
+ // 형식별 검사에 닿기도 전에 잘린다 — 실제로 그랬다.
165
+ if (!buf || buf.length < 10) return null;
166
+ const 꼴 = mime ?? 그림종류(buf);
167
+ try {
168
+ // PNG: 서명 8바이트 + 길이 4 + 'IHDR' 4 뒤에 가로·세로가 빅엔디안 4바이트씩.
169
+ if (꼴 === 'image/png') {
170
+ if (buf.length < 24 || buf.toString('ascii', 12, 16) !== 'IHDR') return null;
171
+ return { 가로: buf.readUInt32BE(16), 세로: buf.readUInt32BE(20) };
172
+ }
173
+ // GIF: 머리말 6바이트 뒤에 가로·세로가 리틀엔디안 2바이트씩.
174
+ if (꼴 === 'image/gif') {
175
+ if (buf.length < 10) return null;
176
+ return { 가로: buf.readUInt16LE(6), 세로: buf.readUInt16LE(8) };
177
+ }
178
+ if (꼴 === 'image/webp') return webp치수(buf);
179
+ if (꼴 === 'image/jpeg') return jpeg치수(buf);
180
+ } catch { /* 잘린 파일이면 그냥 모르는 것으로 둔다 */ }
181
+ return null;
182
+ }
183
+
184
+ /*
185
+ * JPEG 는 치수가 고정된 자리에 없다. SOF 표시를 찾아 그 안에서 읽는다.
186
+ *
187
+ * SOF 는 0xFFC0~0xFFCF 인데 C4(허프만표)·C8·CC 는 SOF 가 아니다. 그 셋을 빼야
188
+ * 엉뚱한 토막에서 숫자를 읽고 「3×1 그림」 같은 답을 내지 않는다.
189
+ */
190
+ function jpeg치수(buf) {
191
+ let i = 2; // 0xFFD8 다음부터
192
+ while (i + 9 < buf.length) {
193
+ if (buf[i] !== 0xff) { i += 1; continue; } // 채움 바이트를 건너뛴다
194
+ const 표시 = buf[i + 1];
195
+ if (표시 === 0xd8 || 표시 === 0x01 || (표시 >= 0xd0 && 표시 <= 0xd7)) { i += 2; continue; }
196
+ const 길이 = buf.readUInt16BE(i + 2);
197
+ if (길이 < 2) return null;
198
+ const SOF = 표시 >= 0xc0 && 표시 <= 0xcf && 표시 !== 0xc4 && 표시 !== 0xc8 && 표시 !== 0xcc;
199
+ if (SOF) return { 가로: buf.readUInt16BE(i + 7), 세로: buf.readUInt16BE(i + 5) };
200
+ i += 2 + 길이;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /* WebP 는 속이 셋(VP8 · VP8L · VP8X)이고 치수가 저마다 다른 자리에 다르게 담긴다. */
206
+ function webp치수(buf) {
207
+ if (buf.length < 30) return null;
208
+ const 갈래 = buf.toString('ascii', 12, 16);
209
+ if (갈래 === 'VP8 ') return { 가로: buf.readUInt16LE(26) & 0x3fff, 세로: buf.readUInt16LE(28) & 0x3fff };
210
+ if (갈래 === 'VP8L') {
211
+ const b = buf.readUInt32LE(21);
212
+ return { 가로: (b & 0x3fff) + 1, 세로: ((b >> 14) & 0x3fff) + 1 };
213
+ }
214
+ if (갈래 === 'VP8X') {
215
+ // 24비트 리틀엔디안으로 '한 변 - 1' 이 적혀 있다.
216
+ const 셋 = (at) => (buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16)) + 1;
217
+ return { 가로: 셋(24), 세로: 셋(27) };
218
+ }
219
+ return null;
111
220
  }
112
221
 
113
222
  /*
@@ -163,6 +163,18 @@ export function 기본카드(conn) {
163
163
  표식칸: 'cache_control',
164
164
  // 캐시 표식이 잡히는 최소 크기 (그 아래면 붙여도 안 잡힌다 — 탈은 아니다)
165
165
  캐시최소: 1024,
166
+ /*
167
+ * 굳은 앞머리를 **한 시간** 살릴까 (backend/cachemark.js 의 표식긴것).
168
+ *
169
+ * 기본으로 켠다. 코딩 에이전트는 검사 한 번에 6분을 쓰는 일이 흔한데,
170
+ * 5분 수명은 그 틈을 못 넘긴다 — 그러면 도구가 돌 때마다 앞머리를 통째로
171
+ * 다시 쓴다. 나란히 재 봤을 때 캐시 쓰기가 여섯 배로 나온 자리다.
172
+ *
173
+ * 안 받는 창구는 400 으로 말해 주고, 그러면 아래 배울전선 이 이 칸만
174
+ * 끈다 — 캐시 자체를 끄지 않는다. 그 둘을 섞으면 `ttl` 하나를 모르는
175
+ * 창구에서 캐시를 통째로 잃는다.
176
+ */
177
+ 긴수명: true,
166
178
  /*
167
179
  * 출력 상한을 **어느 이름으로** 싣나 — '둘다' · '새것' · '옛것'
168
180
  *
@@ -474,6 +486,20 @@ export function 배울전선(문구, 규격) {
474
486
  * 조용히 비싸지는 쪽이라 화면에 아무 표시도 안 난다 — 이 파일이 없애려던
475
487
  * 바로 그 고장이다.
476
488
  */
489
+ /*
490
+ * ── `ttl` 만 모르는 창구 ────────────────────────────────────────────
491
+ *
492
+ * 이 갈래가 아래 `cache_control` 갈래보다 **먼저** 와야 한다. 순서가
493
+ * 바뀌면 수명 칸 하나를 모르는 창구에서 **캐시를 통째로 끈다** — 받을 수
494
+ * 있었던 것까지 잃고, 그건 조용히 비싸지는 쪽이라 화면에 아무 표시도
495
+ * 안 난다.
496
+ *
497
+ * 한 시간 수명은 정식 기능이지만 게이트웨이가 몸통을 그대로 넘겨 주지
498
+ * 않고 제 스키마로 검사하는 자리가 있어서, 거기서는 이 칸만 튕긴다.
499
+ */
500
+ if (/\bttl\b/i.test(s) && 거절.test(s)) {
501
+ return { 무엇: '긴수명', 값: false, 왜: 짧게(s) };
502
+ }
477
503
  if (/cache_control/i.test(s) && 거절.test(s)) {
478
504
  /*
479
505
  * 다른 이름으로 바꿔 보는 것은 **OpenAI 규격일 때만** 이다.
@@ -494,14 +520,38 @@ export function 배울전선(문구, 규격) {
494
520
  if (규격 === 'anthropic') return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
495
521
  return { 무엇: '표식칸', 값: 'prompt_cache_breakpoint', 왜: 짧게(s) };
496
522
  }
497
- if (/prompt_cache_breakpoint|prompt_cache_key|cache_creation/i.test(s) && 거절.test(s)) {
523
+ /*
524
+ * 여기 `cache_creation` 도 적혀 있었다. 지워야 하는 낱말이었다.
525
+ *
526
+ * 우리는 그런 이름의 칸을 **보내지 않는다.** 그 낱말이 실제로 나오는 자리는
527
+ * 답장의 셈판 하나뿐이다 — `cache_creation_input_tokens`(adapter.js 의 씀).
528
+ * 그러니 이 갈래는 우리가 보낸 것을 두고는 영영 안 걸리고, 대신 창구가 답장
529
+ * 칸 이름을 오류 문장에 얹어 주는 날 **엉뚱하게 걸린다.** 그리고 걸리면 캐시를
530
+ * 통째로 끄고 그 값은 디스크에 남는다. 안 걸리는 규칙은 없느니만 못하고,
531
+ * 엉뚱하게 걸리는 규칙은 없는 것보다 나쁘다.
532
+ */
533
+ if (/prompt_cache_breakpoint|prompt_cache_key/i.test(s) && 거절.test(s)) {
498
534
  return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
499
535
  }
500
536
 
501
537
  // ── 세션 이름 ───────────────────────────────────────────────────────
502
538
  // 'user' 는 흔한 낱말이라 **칸 이야기일 때만** 본다. 안 그러면 아무
503
539
  // 오류 문장에나 걸려서 멀쩡한 칸을 꺼 버린다.
504
- if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?(?:user|metadata)['"`]?/i.test(s) && 거절.test(s)) {
540
+ /*
541
+ * 낱말 경계가 있어야 한다. 없으면 **이름 안에 든 것까지** 걸린다.
542
+ *
543
+ * Unrecognized request argument supplied: username → 걸렸다
544
+ * Invalid property 'browser_metadata_extra' in request → 걸렸다
545
+ * Unrecognized request argument supplied: user_agent → 걸렸다
546
+ *
547
+ * 셋 다 세션 이름과 아무 상관이 없는 칸이다. 그런데 걸리면 「이 창구는
548
+ * 세션 이름을 안 받는구나」 로 배우고 **그 값을 디스크에 남긴다.** 멀쩡히
549
+ * 되던 기능이 그 창구에서 영영 꺼지고, 되돌릴 길은 화면에 안 보인다.
550
+ *
551
+ * 밑줄은 낱말 글자라 `\b` 가 제 일을 한다 — `user_agent` 의 `user` 뒤는
552
+ * 경계가 아니고, `username` 도 마찬가지다. 따옴표·빈칸·점 앞에서만 걸린다.
553
+ */
554
+ if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?\b(?:user|metadata)\b['"`]?/i.test(s) && 거절.test(s)) {
505
555
  return { 무엇: '세션자리', 값: null, 왜: 짧게(s) };
506
556
  }
507
557
  if (/['"`](?:user|metadata)['"`][^\n]{0,40}(?:not supported|unsupported|unknown|unexpected|invalid)/i.test(s)) {
@@ -562,7 +612,7 @@ export function 카드고치기(카드, 고침) {
562
612
  return 새;
563
613
  }
564
614
 
565
- export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '출력칸', '세션자리', '스트림usage'];
615
+ export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '긴수명', '출력칸', '세션자리', '스트림usage'];
566
616
 
567
617
  /**
568
618
  * 집 파일에 적을 만한 것만. 주소·모델은 부르는 쪽이 열쇠로 쓴다.