deel-local-cli 1.10.0 → 1.13.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.
Files changed (55) hide show
  1. package/README.ko.md +1167 -1166
  2. package/README.md +1224 -1223
  3. package/bin/deel.js +1 -1
  4. package/package.json +2 -2
  5. package/src/acp/map.js +367 -362
  6. package/src/acp/serve.js +810 -769
  7. package/src/agent/budget.js +156 -153
  8. package/src/agent/commit.js +534 -511
  9. package/src/agent/compact.js +158 -31
  10. package/src/agent/effort.js +308 -197
  11. package/src/agent/evolve.js +263 -213
  12. package/src/agent/filemem.js +16 -2
  13. package/src/agent/loop.js +1728 -1516
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/recall.js +222 -209
  17. package/src/agent/session.js +1056 -948
  18. package/src/backend/adapter.js +1355 -798
  19. package/src/backend/cachemark.js +223 -0
  20. package/src/backend/detect.js +327 -288
  21. package/src/backend/http.js +460 -458
  22. package/src/backend/mcp.js +407 -395
  23. package/src/backend/price.js +260 -197
  24. package/src/backend/probe.js +487 -473
  25. package/src/backend/quota.js +274 -157
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +59 -0
  28. package/src/backend/toolfit.js +352 -325
  29. package/src/backend/wire.js +715 -0
  30. package/src/commands.js +3184 -2998
  31. package/src/config.js +283 -244
  32. package/src/i18n/en.js +545 -520
  33. package/src/i18n/ja.js +498 -473
  34. package/src/i18n/ko.js +592 -567
  35. package/src/i18n/zh.js +498 -473
  36. package/src/oneshot.js +625 -549
  37. package/src/pack/tar.js +154 -132
  38. package/src/pack/zip.js +235 -217
  39. package/src/plugins/manage.js +416 -336
  40. package/src/providers/bedrock.js +17 -0
  41. package/src/repl.js +2616 -2515
  42. package/src/safety/audit.js +148 -129
  43. package/src/safety/guard.js +641 -526
  44. package/src/safety/network.js +200 -157
  45. package/src/tools/fastgrep.js +229 -214
  46. package/src/tools/fsutil.js +291 -250
  47. package/src/tools/index.js +2560 -2523
  48. package/src/tools/jobs.js +876 -795
  49. package/src/tools/label.js +52 -0
  50. package/src/tools/spawn.js +213 -99
  51. package/src/tools/verify.js +358 -357
  52. package/src/tools/webfetch.js +418 -393
  53. package/src/ui/export.js +233 -217
  54. package/src/ui/pick.js +115 -115
  55. package/src/ui/status.js +617 -610
@@ -1,157 +1,274 @@
1
- // 게이트웨이가 남았다고 알려 주는 할당량.
2
- //
3
- // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
- //
5
- // 사내 게이트웨이는 사람마다 할당량을 건다. 그런데 지금까지 그걸 아는 방법은
6
- // **429 를 맞는 것뿐**이었다. 일하는 도중에 갑자기 막히고, 화면에는
7
- // "잠깐 막혔습니다" 가 뜨고, 사람은 언제 풀리는지 모른 채 기다린다.
8
- //
9
- // 그런데 서버는 매 응답에 남은 양을 실어 보내고 있었다. 우리가 안 읽었을
10
- // 뿐이다. 읽어서 보여 주면 사람은 막히기 전에 안다 — 큰 작업을 시작할지,
11
- // 오늘은 여기까지 할지 스스로 정할 수 있다.
12
- //
13
- // ── 이름이 제각각이다 ──────────────────────────────────────────────────
14
- //
15
- // 표준이 없다. OpenAI 계열은 `x-ratelimit-remaining-requests`, Azure 는
16
- // `x-ratelimit-remaining-tokens` 를 쓰기도 하고 아예 안 주기도 한다.
17
- // 그래서 **아는 이름만 읽고, 없으면 없다고 한다.** 없는 것을 0 으로 치면
18
- // 화면에 "0 남음" 이 떠서, 멀쩡한데 다 썼다고 믿게 된다.
19
- //
20
- // Anthropic 은 `anthropic-` 을 앞에 달고, 남은 것을 **뒤에** 적는다
21
- // (`anthropic-ratelimit-requests-remaining`). 그 이름을 몰라서, Claude 직통과
22
- // Bedrock 게이트웨이에서는 이 줄이 **언제나 비어 있었다** — 서버는 매 응답에
23
- // 남은 양을 실어 보내고 있었는데 우리만 못 읽었다. 그래서 그 두 자리에서는
24
- // 아직도 429 를 맞아야만 알 수 있었다. 이 파일이 없애려던 바로 그 상황이다.
25
-
26
- // 읽을 이름들. 앞에서부터 처음 있는 것 하나를 쓴다.
27
- const 요청남음 = [
28
- 'x-ratelimit-remaining-requests', 'ratelimit-remaining-requests', 'x-ratelimit-remaining',
29
- 'anthropic-ratelimit-requests-remaining',
30
- ];
31
- const 토큰남음 = [
32
- 'x-ratelimit-remaining-tokens', 'ratelimit-remaining-tokens',
33
- 'anthropic-ratelimit-tokens-remaining',
34
- // 입력·출력을 따로 세는 판도 있다. 먼저 바닥나는 쪽이 막히는 쪽이다.
35
- 'anthropic-ratelimit-input-tokens-remaining', 'anthropic-ratelimit-output-tokens-remaining',
36
- ];
37
- const 요청한도 = ['x-ratelimit-limit-requests', 'ratelimit-limit-requests', 'anthropic-ratelimit-requests-limit'];
38
- const 토큰한도 = [
39
- 'x-ratelimit-limit-tokens', 'ratelimit-limit-tokens', 'anthropic-ratelimit-tokens-limit',
40
- 'anthropic-ratelimit-input-tokens-limit', 'anthropic-ratelimit-output-tokens-limit',
41
- ];
42
- /*
43
- * 풀리는 때. Anthropic 초가 아니라 **날짜(RFC 3339)** 준다
44
- * `언제풀리나` Date.parse 받아 지금과의 차이를 초로 바꾼다.
45
- */
46
- const 다시언제 = [
47
- 'retry-after', 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens', 'ratelimit-reset',
48
- 'anthropic-ratelimit-requests-reset', 'anthropic-ratelimit-tokens-reset',
49
- ];
50
-
51
- function 골라(머리, 이름들) {
52
- if (!머리) return null;
53
- const 보기 = (k) => (typeof 머리.get === 'function' ? 머리.get(k) : 머리[k] ?? 머리[k.toLowerCase()]);
54
- for (const k of 이름들) {
55
- const v = 보기(k);
56
- if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
57
- }
58
- return null;
59
- }
60
-
61
- /*
62
- * 숫자로 읽는다. 못 읽으면 null — 0 이 아니다.
63
- *
64
- * 구분이 여기서 제일 중요하다. 못 읽은 것을 0 으로 치면 화면에
65
- * "남은 요청 0" 뜨고, 사람은 멀쩡한 할당량을 썼다고 믿는다.
66
- */
67
- function 숫자(v) {
68
- if (v === null) return null;
69
- const n = Number(String(v).replace(/[,_\s]/g, ''));
70
- return Number.isFinite(n) ? n : null;
71
- }
72
-
73
- /*
74
- * `retry-after` 초일 수도 날짜일 수도 있다 (RFC 9110).
75
- * 날짜면 지금과의 차이를 초로 바꾼다. 못 읽으면 null.
76
- */
77
- export function 언제풀리나(v) {
78
- if (v === null || v === undefined) return null;
79
- const s = String(v).trim();
80
- const n = Number(s);
81
- if (Number.isFinite(n)) return Math.max(0, Math.round(n));
82
- const t = Date.parse(s);
83
- if (Number.isFinite(t)) return Math.max(0, Math.round((t - Date.now()) / 1000));
84
- // `1m30s` 같은 꼴을 주는 게이트웨이가 있다.
85
- const m = /^(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(s);
86
- if (m && (m[1] || m[2])) return Math.round((Number(m[1] ?? 0) * 60) + Number(m[2] ?? 0));
87
- return null;
88
- }
89
-
90
- /**
91
- * 응답 머리에서 할당량을 읽는다.
92
- *
93
- * @returns {{요청:number|null, 요청한도:number|null, 토큰:number|null, 토큰한도:number|null, 풀림:number|null, 있나:boolean}}
94
- */
95
- export function 할당량읽기(머리) {
96
- const = {
97
- 요청: 숫자(골라(머리, 요청남음)),
98
- 요청한도: 숫자(골라(머리, 요청한도)),
99
- 토큰: 숫자(골라(머리, 토큰남음)),
100
- 토큰한도: 숫자(골라(머리, 토큰한도)),
101
- 풀림: 언제풀리나(골라(머리, 다시언제)),
102
- };
103
- 것.있나 = 것.요청 !== null || 것.토큰 !== null || 것.풀림 !== null;
104
- return 것;
105
- }
106
-
107
- /*
108
- * 얼마나 남았을 화면에 띄울까.
109
- *
110
- * 한도를 알면 비율로 본다(10% 아래). 한도를 안 알려주는 서버가 많아서,
111
- * 그때는 남은 수 자체로 본다 — 요청 20회 아래, 토큰 20,000 아래.
112
- * 넉넉할 때 자꾸 띄우면 사람이 그 줄을 안 읽게 된다.
113
- */
114
- export const 요청바닥 = 20;
115
- export const 토큰바닥 = 20000;
116
-
117
- export function 아슬아슬한가(것) {
118
- if (!것?.있나) return false;
119
- if (것.풀림 !== null && 것.풀림 > 0) return true;
120
- if (것.요청 !== null) {
121
- if (것.요청한도) { if (것.요청 / 것.요청한도 <= 0.1) return true; }
122
- else if (것.요청 <= 요청바닥) return true;
123
- }
124
- if (것.토큰 !== null) {
125
- if (것.토큰한도) { if (것.토큰 / 것.토큰한도 <= 0.1) return true; }
126
- else if (것.토큰 <= 토큰바닥) return true;
127
- }
128
- return false;
129
- }
130
-
131
- /** 화면 한 줄. 아는 것만 적는다 — 모르는 자리는 아예 안 적는다. */
132
- export function 할당량말(것) {
133
- if (!것?.있나) return '';
134
- const 조각 = [];
135
- if (것.요청 !== null) 조각.push(`요청 ${것.요청.toLocaleString()}${것.요청한도 ? `/${것.요청한도.toLocaleString()}` : ''}`);
136
- if (것.토큰 !== null) 조각.push(`토큰 ${것.토큰.toLocaleString()}${것.토큰한도 ? `/${것.토큰한도.toLocaleString()}` : ''}`);
137
- if (것.풀림 !== null) 조각.push(`${것.풀림}초 뒤 풀림`);
138
- return 조각.join(' · ');
139
- }
140
-
141
- /*
142
- * 마지막으로 본 할당량. 응답마다 덮어쓴다.
143
- *
144
- * 세션에 두고 모듈에 두는 까닭: 이걸 읽는 자리가 여럿인데(상태줄, /cost,
145
- * 시작 화면) 자리들이 세션을 들고 있지는 않다. 그리고 자체가
146
- * '지금 서버가 말한 것' 이라 한 벌이면 충분하다.
147
- */
148
- let 마지막 = null;
149
-
150
- export function 할당량기억(머리) {
151
- const 것 = 할당량읽기(머리);
152
- if (것.있나) 마지막 = { ...것, 때: Date.now() };
153
- return 것;
154
- }
155
-
156
- export function 마지막할당량() { return 마지막; }
157
- export function 할당량잊기() { 마지막 = null; return null; }
1
+ // 게이트웨이가 남았다고 알려 주는 할당량.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 사내 게이트웨이는 사람마다 할당량을 건다. 그런데 지금까지 그걸 아는 방법은
6
+ // **429 를 맞는 것뿐**이었다. 일하는 도중에 갑자기 막히고, 화면에는
7
+ // "잠깐 막혔습니다" 가 뜨고, 사람은 언제 풀리는지 모른 채 기다린다.
8
+ //
9
+ // 그런데 서버는 매 응답에 남은 양을 실어 보내고 있었다. 우리가 안 읽었을
10
+ // 뿐이다. 읽어서 보여 주면 사람은 막히기 전에 안다 — 큰 작업을 시작할지,
11
+ // 오늘은 여기까지 할지 스스로 정할 수 있다.
12
+ //
13
+ // ── 이름이 제각각이다 ──────────────────────────────────────────────────
14
+ //
15
+ // 표준이 없다. OpenAI 계열은 `x-ratelimit-remaining-requests`, Azure 는
16
+ // `x-ratelimit-remaining-tokens` 를 쓰기도 하고 아예 안 주기도 한다.
17
+ // 그래서 **아는 이름만 읽고, 없으면 없다고 한다.** 없는 것을 0 으로 치면
18
+ // 화면에 "0 남음" 이 떠서, 멀쩡한데 다 썼다고 믿게 된다.
19
+ //
20
+ // Anthropic 은 `anthropic-` 을 앞에 달고, 남은 것을 **뒤에** 적는다
21
+ // (`anthropic-ratelimit-requests-remaining`). 그 이름을 몰라서, Claude 직통과
22
+ // Bedrock 게이트웨이에서는 이 줄이 **언제나 비어 있었다** — 서버는 매 응답에
23
+ // 남은 양을 실어 보내고 있었는데 우리만 못 읽었다. 그래서 그 두 자리에서는
24
+ // 아직도 429 를 맞아야만 알 수 있었다. 이 파일이 없애려던 바로 그 상황이다.
25
+
26
+ // 읽을 이름들. 앞에서부터 처음 있는 것 하나를 쓴다.
27
+ const 요청남음 = [
28
+ 'x-ratelimit-remaining-requests', 'ratelimit-remaining-requests', 'x-ratelimit-remaining',
29
+ 'anthropic-ratelimit-requests-remaining',
30
+ ];
31
+ /*
32
+ * 토큰 통은 **여러 개일 수 있다.**
33
+ *
34
+ * Anthropic 입력과 출력을 따로 센다. 둘은 서로 다른 통이고,
35
+ * **먼저 바닥나는 쪽**이 곧 막히는 쪽이다.
36
+ *
37
+ * 여태 이걸 줄짜리 이름 목록으로 두고 골라() 로 「처음 있는 것」 을
38
+ * 집었다. 목록 차례상 입력이 앞이라, 입력 100,000 · 출력 0 인 응답에서
39
+ * 남은 양을 **100,000 으로 적었다.** 그러면 미리기다릴까() 가 「아직
40
+ * 넉넉하다」 고 보고 곧장 다음 요청을 보내 429 를 맞는다. 주석에는
41
+ * 「먼저 바닥나는 쪽」 이라고 적혀 있었는데 코드는 그 반대를 했다.
42
+ *
43
+ * 남음과 한도를 **짝으로** 묶어 두고, 남은 것이 가장 적은 짝을 쓴다.
44
+ * 짝으로 묶으면 화면이 「출력 0 / 입력 한도 100만」 같은 소리를 한다.
45
+ */
46
+ const 토큰통 = [
47
+ ['x-ratelimit-remaining-tokens', 'x-ratelimit-limit-tokens'],
48
+ ['ratelimit-remaining-tokens', 'ratelimit-limit-tokens'],
49
+ ['anthropic-ratelimit-tokens-remaining', 'anthropic-ratelimit-tokens-limit'],
50
+ ['anthropic-ratelimit-input-tokens-remaining', 'anthropic-ratelimit-input-tokens-limit'],
51
+ ['anthropic-ratelimit-output-tokens-remaining', 'anthropic-ratelimit-output-tokens-limit'],
52
+ ];
53
+ const 요청한도 = ['x-ratelimit-limit-requests', 'ratelimit-limit-requests', 'anthropic-ratelimit-requests-limit'];
54
+ /*
55
+ * 풀리는 때. Anthropic 은 초가 아니라 **날짜(RFC 3339)** 로 준다 —
56
+ * `언제풀리나` Date.parse 받아 지금과의 차이를 초로 바꾼다.
57
+ */
58
+ const 다시언제 = [
59
+ 'retry-after', 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens', 'ratelimit-reset',
60
+ 'anthropic-ratelimit-requests-reset', 'anthropic-ratelimit-tokens-reset',
61
+ ];
62
+
63
+ function 골라(머리, 이름들) {
64
+ if (!머리) return null;
65
+ const 보기 = (k) => (typeof 머리.get === 'function' ? 머리.get(k) : 머리[k] ?? 머리[k.toLowerCase()]);
66
+ for (const k of 이름들) {
67
+ const v = 보기(k);
68
+ if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /*
74
+ * 숫자로 읽는다. 읽으면 null 0 아니다.
75
+ *
76
+ * 이 구분이 여기서 제일 중요하다. 못 읽은 것을 0 으로 치면 화면에
77
+ * "남은 요청 0" 이 뜨고, 사람은 멀쩡한 할당량을 다 썼다고 믿는다.
78
+ */
79
+ function 숫자(v) {
80
+ if (v === null) return null;
81
+ const n = Number(String(v).replace(/[,_\s]/g, ''));
82
+ return Number.isFinite(n) ? n : null;
83
+ }
84
+
85
+ /*
86
+ * `retry-after` 초일 수도 날짜일 수도 있다 (RFC 9110).
87
+ * 날짜면 지금과의 차이를 초로 바꾼다. 못 읽으면 null.
88
+ */
89
+ export function 언제풀리나(v) {
90
+ if (v === null || v === undefined) return null;
91
+ const s = String(v).trim();
92
+ const n = Number(s);
93
+ if (Number.isFinite(n)) return Math.max(0, Math.round(n));
94
+ const t = Date.parse(s);
95
+ if (Number.isFinite(t)) return Math.max(0, Math.round((t - Date.now()) / 1000));
96
+ // `1m30s` 같은 꼴을 주는 게이트웨이가 있다.
97
+ const m = /^(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(s);
98
+ if (m && (m[1] || m[2])) return Math.round((Number(m[1] ?? 0) * 60) + Number(m[2] ?? 0));
99
+ return null;
100
+ }
101
+
102
+ /** 토큰 통 중 **가장 적게 남은** 짝. 없으면 둘 다 null. */
103
+ function 가장빠듯한통(머리) {
104
+ let 고른것 = { 토큰: null, 토큰한도: null };
105
+ for (const [남음이름, 한도이름] of 토큰통) {
106
+ const 남 = 숫자(골라(머리, [남음이름]));
107
+ if (남 === null) continue;
108
+ if (고른것.토큰 === null || < 고른것.토큰) {
109
+ 고른것 = { 토큰: 남, 토큰한도: 숫자(골라(머리, [한도이름])) };
110
+ }
111
+ }
112
+ return 고른것;
113
+ }
114
+
115
+ /**
116
+ * 응답 머리에서 할당량을 읽는다.
117
+ *
118
+ * @returns {{요청:number|null, 요청한도:number|null, 토큰:number|null, 토큰한도:number|null, 풀림:number|null, 있나:boolean}}
119
+ */
120
+ export function 할당량읽기(머리) {
121
+ const = {
122
+ 요청: 숫자(골라(머리, 요청남음)),
123
+ 요청한도: 숫자(골라(머리, 요청한도)),
124
+ ...가장빠듯한통(머리),
125
+ 풀림: 언제풀리나(골라(머리, 다시언제)),
126
+ };
127
+ 것.있나 = 것.요청 !== null || 것.토큰 !== null || 것.풀림 !== null;
128
+ return 것;
129
+ }
130
+
131
+ /*
132
+ * 얼마나 남았을 때 화면에 띄울까.
133
+ *
134
+ * 한도를 알면 비율로 본다(10% 아래). 한도를 안 알려주는 서버가 많아서,
135
+ * 그때는 남은 자체로 본다 요청 20회 아래, 토큰 20,000 아래.
136
+ * 넉넉할 자꾸 띄우면 사람이 줄을 안 읽게 된다.
137
+ */
138
+ export const 요청바닥 = 20;
139
+ export const 토큰바닥 = 20000;
140
+
141
+ export function 아슬아슬한가(것) {
142
+ if (!것?.있나) return false;
143
+ if (것.풀림 !== null && 것.풀림 > 0) return true;
144
+ if (것.요청 !== null) {
145
+ if (것.요청한도) { if (것.요청 / 것.요청한도 <= 0.1) return true; }
146
+ else if (것.요청 <= 요청바닥) return true;
147
+ }
148
+ if (것.토큰 !== null) {
149
+ if (것.토큰한도) { if (것.토큰 / 것.토큰한도 <= 0.1) return true; }
150
+ else if (것.토큰 <= 토큰바닥) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /** 화면 한 줄. 아는 것만 적는다 — 모르는 자리는 아예 안 적는다. */
156
+ export function 할당량말() {
157
+ if (!것?.있나) return '';
158
+ const 조각 = [];
159
+ if (것.요청 !== null) 조각.push(`요청 ${것.요청.toLocaleString()}${것.요청한도 ? `/${것.요청한도.toLocaleString()}` : ''}`);
160
+ if (것.토큰 !== null) 조각.push(`토큰 ${것.토큰.toLocaleString()}${것.토큰한도 ? `/${것.토큰한도.toLocaleString()}` : ''}`);
161
+ if (것.풀림 !== null) 조각.push(`${것.풀림}초 뒤 풀림`);
162
+ return 조각.join(' · ');
163
+ }
164
+
165
+ /*
166
+ * 마지막으로 본 할당량. 응답마다 덮어쓴다.
167
+ *
168
+ * 세션에 안 두고 모듈에 두는 까닭: 이걸 읽는 자리가 여럿인데(상태줄, /cost,
169
+ * 시작 화면) 그 자리들이 세션을 다 들고 있지는 않다. 그리고 값 자체가
170
+ * '지금 서버가 말한 것' 이라 한 벌이면 충분하다.
171
+ */
172
+ let 마지막 = null;
173
+
174
+ /*
175
+ * ── 창구마다 따로 센다 ──────────────────────────────────────────────────
176
+ *
177
+ * 값이 한 벌이면 **누구 것인지**가 없다. 그런데 이 프로그램은 한 번에 여러
178
+ * 창구를 부른다 — 본 모델, 하위 작업이 고른 모델(agent/loop.js), `/model` 로
179
+ * 물어보는 자리, 요약을 짓는 자리. 사내 게이트웨이가 「남은 것 0, 55초 뒤」
180
+ * 라고 답하면, 그 다음에 **전혀 다른 주소**로 나가는 요청까지 55초를 기다렸다.
181
+ * 옆에 켜 둔 로컬 모델이 남의 할당량 때문에 멎는 것이다.
182
+ *
183
+ * 그래서 창구별로 담아 두고, 보내기 전에 비킬 때는 **그 창구 것만** 본다.
184
+ * 화면(상태줄·/cost)은 여전히 마지막 것을 쓴다 — 사람이 보는 것은 지금 쓰는
185
+ * 창구 하나라, 그 자리에는 한 벌로 충분하다.
186
+ */
187
+ const 자리들 = new Map();
188
+ const 자리최대 = 24;
189
+
190
+ /** 이 연결을 가리키는 이름. 주소의 호스트와 모델까지만 쓴다 — 열쇠는 안 넣는다. */
191
+ export function 할당량자리(conn) {
192
+ if (!conn) return '';
193
+ let host = '';
194
+ try { host = new URL(String(conn.base ?? '')).host; } catch { host = ''; }
195
+ /*
196
+ * 규격까지 넣는다. 전선 카드 열쇠와 같은 까닭이다(agent/evolve.js) —
197
+ * mantle 은 `/openai/v1` 과 `/anthropic/v1` 이 **같은 호스트에 같은 모델
198
+ * 이름**으로 서 있어서, 규격을 빼면 한쪽이 바닥났다고 다른 쪽까지 기다린다.
199
+ */
200
+ const 꼴 = String(conn.kind ?? '').trim();
201
+ return `${host}|${String(conn.model ?? '').trim()}${꼴 ? `#${꼴}` : ''}`;
202
+ }
203
+
204
+ export function 할당량기억(머리, 어디 = '') {
205
+ const 것 = 할당량읽기(머리);
206
+ if (것.있나) {
207
+ const 적을것 = { ...것, 때: Date.now() };
208
+ 마지막 = 적을것;
209
+ if (어디) {
210
+ 자리들.delete(어디);
211
+ 자리들.set(어디, 적을것);
212
+ // 오래 안 쓴 자리부터 버린다. 창구를 옮겨 다녀도 표가 안 자란다.
213
+ while (자리들.size > 자리최대) 자리들.delete(자리들.keys().next().value);
214
+ }
215
+ }
216
+ return 것;
217
+ }
218
+
219
+ /** 마지막으로 본 할당량. 자리를 주면 **그 창구 것**, 안 주면 가장 최근 것. */
220
+ export function 마지막할당량(어디 = '') {
221
+ if (어디) return 자리들.get(어디) ?? null;
222
+ return 마지막;
223
+ }
224
+
225
+ export function 할당량잊기(어디 = '') {
226
+ if (어디) { 자리들.delete(어디); return null; }
227
+ 마지막 = null;
228
+ 자리들.clear();
229
+ return null;
230
+ }
231
+
232
+ /*
233
+ * ── 맞기 전에 비킨다 ────────────────────────────────────────────────────
234
+ *
235
+ * 서버는 매 응답에 「남은 것이 없다, 언제 풀린다」 를 실어 보낸다. 그런데
236
+ * 여태 그 값은 **화면에만** 썼다. 그래서 남은 것이 0 인 줄 알면서도 그대로
237
+ * 다음 요청을 보내고, 429 를 맞고, 다시 부르기 사다리를 태우고, 세 번째에
238
+ * 턴이 죽었다. 사람이 본 것은 「호출 한도 초과」 한 줄이다.
239
+ *
240
+ * 맞기 전에 아는 것과 맞고 나서 아는 것은 사람이 할 일이 다르다. 알고 있으면
241
+ * 그냥 기다렸다 보내면 된다 — 기다리는 것은 실패가 아니다.
242
+ *
243
+ * 조심한 것:
244
+ * · **모르면 안 기다린다.** 헤더를 안 주는 서버가 많고, 모르는 것을 0 으로
245
+ * 치면 멀쩡한 연결이 영영 기다린다.
246
+ * · 풀림 시각이 없으면 안 기다린다. 얼마나 기다릴지 모르는 채로 붙들면
247
+ * 화면이 멈춘 것과 구별이 안 된다.
248
+ * · 오래된 값으로는 안 정한다. 방금 응답이 아니면 그 사이 풀렸을 수 있다.
249
+ * · 상한을 둔다. 그보다 길면 사람이 정할 일이다 — 사실대로 말하고 보낸다.
250
+ *
251
+ * @returns {number|null} 기다릴 밀리초. 안 기다려도 되면 null
252
+ */
253
+ export const 낡은값 = 60000; // 이보다 오래된 할당량으로는 안 정한다
254
+ export const 미리기다림상한 = 60000;
255
+
256
+ /*
257
+ * 어느 창구 것인지는 **부르는 쪽이 정해서 준다.** 기본값을 두지 않는다.
258
+ *
259
+ * 전에는 `것 = 마지막` 이었다. 그 기본값이 곧 「아무 창구나 마지막에 답한
260
+ * 것」 이라, 빠뜨리고 부르면 옆 창구의 바닥난 할당량으로 이쪽이 기다린다 —
261
+ * 방금 고친 그 고장이다. 기본값을 없애면 다음에 빠뜨렸을 때 조용히 옛
262
+ * 동작으로 돌아가는 대신 눈에 보이게 어긋난다.
263
+ */
264
+ export function 미리기다릴까(것, 지금 = Date.now()) {
265
+ if (!것?.있나) return null;
266
+ if (!(것.때 > 0) || 지금 - 것.때 > 낡은값) return null;
267
+ 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, 미리기다림상한);
274
+ }
@@ -29,9 +29,20 @@
29
29
  // 쓰는 사람 여럿이 같은 박자로 다시 두드리면 그게 또 429 를 만든다.
30
30
  import { Aborted } from './http.js';
31
31
 
32
- /** 기본 정책. 검사는 base 를 짧게 바꿔 준다 — 모양은 같고 시간만 다르다. */
32
+ /**
33
+ * 기본 정책. 검사는 base 를 짧게 바꿔 준다 — 모양은 같고 시간만 다르다.
34
+ *
35
+ * `막힘최대` 는 **429 에만** 쓰는 횟수다. 기본은 `최대` 와 같게 둔다 —
36
+ * 사람이 안 시켰는데 프로그램이 오래 붙들고 있으면 화면이 멈춘 것과
37
+ * 구별이 안 되기 때문이다. 사내 게이트웨이처럼 할당량이 자주 차는 자리는
38
+ * 설정에서 올린다 (`retry.막힘최대`). 그 대신 **맞기 전에 비키는 것**은
39
+ * 기본으로 켜져 있다 (backend/quota.js 의 미리기다릴까).
40
+ *
41
+ * `총상한` 은 한 요청에서 기다린 시간을 다 더한 울타리다. 사다리가 길어져도
42
+ * 여기서 멎는다 — 얼마나 기다릴지 모르는 채로 붙드는 일이 없어야 한다.
43
+ */
33
44
  export function 기본정책() {
34
- return { 최대: 3, base: [1000, 2000, 4000], 흔들림: 0.3, 상한: 60000 };
45
+ return { 최대: 3, 막힘최대: null, base: [1000, 2000, 4000], 흔들림: 0.3, 상한: 60000, 총상한: 300000 };
35
46
  }
36
47
 
37
48
  // 잠깐 막힌 것으로 보는 상태 코드. 529 는 Anthropic 계열 게이트웨이의 '과부하' 다.
@@ -44,7 +55,10 @@ const 다시부를코드 = new Set(['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'EC
44
55
  * @param {{status?: number, code?: string|null, attempt?: number}} 실패 attempt 는 방금 실패한 것이 몇 번째였나 (1부터)
45
56
  */
46
57
  export function 다시부를까({ status = 0, code = null, attempt = 1 } = {}, 정책 = 기본정책()) {
47
- if (attempt > 정책.최대) return false;
58
+ // 429 「틀렸다」 아니라 「지금은 안 된다」 다. 참을 횟수를 따로 둘 수 있게
59
+ // 한다 — 안 정했으면 여태와 똑같이 최대 를 쓴다.
60
+ const 한도 = Number(status) === 429 ? (정책.막힘최대 ?? 정책.최대) : 정책.최대;
61
+ if (attempt > 한도) return false;
48
62
  if (status) return 다시부를상태.has(Number(status));
49
63
  return !!code && 다시부를코드.has(String(code));
50
64
  }
@@ -99,18 +113,28 @@ export function 기다리기(ms, signal = null) {
99
113
  * 실패한 응답 하나를 보고 "기다렸다 다시 부른다" 알림을 만든다. 안 부를 것이면 null.
100
114
  * 화면·기록이 이 한 덩이를 그대로 쓴다 — 여기 없는 숫자는 화면에도 없다.
101
115
  */
102
- export function 다시부를지(r, attempt, 정책 = 기본정책()) {
116
+ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0) {
103
117
  const status = r?.status ?? 0;
104
118
  const code = r?.code ?? null;
105
119
  if (!다시부를까({ status, code, attempt }, 정책)) return null;
106
120
  const retryAfter = r?.headers?.get?.('retry-after') ?? r?.res?.headers?.get?.('retry-after') ?? null;
121
+ const wait = 기다릴시간({ attempt, retryAfter }, 정책);
122
+ /*
123
+ * 한 요청에서 기다린 것을 다 더해 울타리를 친다.
124
+ *
125
+ * 참을 횟수를 올려 둔 자리(사내 게이트웨이)에서 사다리가 길어지면, 사람은
126
+ * 왜 멈춰 있는지 모르는 채로 몇 분을 본다. 횟수와 시간은 다른 울타리라
127
+ * 둘 다 있어야 한다.
128
+ */
129
+ const 총상한 = 정책.총상한 ?? Infinity;
130
+ if (Number.isFinite(총상한) && 쌓인 + wait > 총상한) return null;
107
131
  return {
108
132
  type: 'backoff',
109
133
  status,
110
134
  code,
111
- wait: 기다릴시간({ attempt, retryAfter }, 정책),
135
+ wait,
112
136
  attempt,
113
- max: 정책.최대,
137
+ max: Number(status) === 429 ? (정책.막힘최대 ?? 정책.최대) : 정책.최대,
114
138
  retryAfter: retryAfter ?? null,
115
139
  };
116
140
  }
@@ -0,0 +1,59 @@
1
+ /*
2
+ * 토큰을 대략 센다.
3
+ *
4
+ * ── 왜 여기 있나 ────────────────────────────────────────────────────────
5
+ *
6
+ * 이 함수는 agent/session.js 안에 있었다. 그런데 재는 자가 필요한 자리가
7
+ * 거기 하나가 아니다 — backend/cachemark.js 도 「이 대화가 캐시에 잡힐 만한
8
+ * 크기인가」 를 재야 하는데, cachemark 는 아무것도 안 들여오는 잎 모듈이라
9
+ * session.js 를 부르면 순환이 된다(session 이 cachemark 의 조각표를 쓴다).
10
+ *
11
+ * 그래서 cachemark 는 제 나름대로 **글자 수를 토큰의 두 배로** 쳤다. 영어와
12
+ * 코드에서만 맞는 셈이라, 실제로는 2,300토큰인 대화를 4,096토큰 문턱을 넘었다고
13
+ * 답했다. 안 잡힐 표식을 붙여 놓고 화면에는 「캐시 표식」 이라고 적혔다.
14
+ *
15
+ * 재는 자가 둘이면 언젠가 둘이 어긋난다. 그래서 자를 **잎으로 내려서** 한
16
+ * 벌만 둔다. session.js 는 이것을 그대로 다시 내보내므로 부르던 자리는 그대로다.
17
+ *
18
+ * ── 얼마나 맞나 ────────────────────────────────────────────────────────
19
+ *
20
+ * 정확한 토크나이저는 안 쓴다 — 의존성 0개가 이 프로그램의 약속이고, 창구마다
21
+ * 토크나이저가 다르므로 하나를 골라 박아도 다른 창구에서는 또 틀린다.
22
+ * 대신 서버가 실제 값을 알려 주면 그 값으로 배수를 배운다(session.js 의 배운다).
23
+ * 여기는 **배우기 전의 첫 짐작**이다.
24
+ *
25
+ * 한글(자모 포함)·일본어·한자 글자당 1토큰
26
+ * 그 밖(영문·코드) 3.6글자당 1토큰
27
+ */
28
+ /*
29
+ * ── 한글은 완성형만 한글이 아니다 ───────────────────────────────────────
30
+ *
31
+ * 여기가 완성형 음절(가–힣)만 셌다. 그래서 아래 것들이 **영문 취급**을 받아
32
+ * 3.6분의 1로 세어졌다:
33
+ *
34
+ * ㅋㅋㅋ ㅎㅎ ㅠㅠ ㅡㅡ 호환 자모 (U+3130–318F) — 대화에 제일 흔하다
35
+ * ᄀ ᅡ ᆨ 첫가끝 자모 (U+1100–11FF) — 맥에서 만든 파일
36
+ * 이름과 그 파일 내용이 이 꼴(NFD)로 온다
37
+ *
38
+ * 덜 센 값은 화면 숫자로 안 끝난다. 남은 자리를 넉넉히 보고 → 접기를 늦게
39
+ * 켜고 → 실제로는 넘친 채로 요청을 보낸다. 한국어를 쓰는 사람이 가장 자주
40
+ * 치는 글자가 하필 이 칸이었다.
41
+ */
42
+ const 자모인가 = (cp) => (
43
+ (cp >= 0x1100 && cp <= 0x11ff) // 첫가끝 (NFD)
44
+ || (cp >= 0x3130 && cp <= 0x318f) // 호환 자모 (ㅋ ㅎ ㅠ …)
45
+ || (cp >= 0xa960 && cp <= 0xa97f) // 확장 A
46
+ || (cp >= 0xd7b0 && cp <= 0xd7ff) // 확장 B
47
+ );
48
+
49
+ export function estimateTokens(text) {
50
+ const s = String(text ?? '');
51
+ let cjk = 0;
52
+ for (const ch of s) {
53
+ const cp = ch.codePointAt(0);
54
+ if ((cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0x3040 && cp <= 0x30ff) || (cp >= 0x4e00 && cp <= 0x9fff)
55
+ || 자모인가(cp)) cjk++;
56
+ }
57
+ const rest = s.length - cjk;
58
+ return Math.ceil(cjk + rest / 3.6);
59
+ }