deel-local-cli 1.8.0 → 1.12.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.
- package/README.ko.md +10 -25
- package/README.md +43 -51
- package/bin/deel.js +56 -13
- package/package.json +60 -60
- package/src/acp/serve.js +810 -732
- package/src/agent/budget.js +156 -153
- package/src/agent/compact.js +420 -296
- package/src/agent/effort.js +308 -177
- package/src/agent/evidence.js +2 -0
- package/src/agent/evolve.js +263 -213
- package/src/agent/filemem.js +155 -0
- package/src/agent/grade.js +51 -1
- package/src/agent/loop.js +1707 -1349
- package/src/agent/memory.js +156 -152
- package/src/agent/mention.js +210 -210
- package/src/agent/session.js +1049 -832
- package/src/agent/store.js +186 -9
- package/src/agent/threads.js +26 -1
- package/src/backend/adapter.js +1159 -615
- package/src/backend/cachemark.js +150 -0
- package/src/backend/detect.js +327 -288
- package/src/backend/learn.js +24 -0
- package/src/backend/mcp.js +96 -5
- package/src/backend/probe.js +131 -61
- package/src/backend/quota.js +250 -133
- package/src/backend/retry.js +30 -6
- package/src/backend/tokens.js +37 -0
- package/src/backend/toolfit.js +352 -0
- package/src/backend/wire.js +600 -0
- package/src/commands.js +3135 -2918
- package/src/config.js +47 -2
- package/src/i18n/en.js +544 -466
- package/src/i18n/index.js +18 -0
- package/src/i18n/ja.js +497 -417
- package/src/i18n/ko.js +591 -466
- package/src/i18n/zh.js +497 -417
- package/src/lsp/client.js +49 -5
- package/src/oneshot.js +620 -471
- package/src/pack/sbom.js +30 -4
- package/src/pack/selfpack.js +25 -9
- package/src/pack/sheet.en.js +288 -0
- package/src/pack/tar.js +65 -2
- package/src/plugins/manage.js +46 -9
- package/src/providers/bedrock.js +17 -0
- package/src/repl.js +2602 -2286
- package/src/safety/audit.js +92 -8
- package/src/safety/authcmd.js +14 -3
- package/src/safety/guard.js +143 -0
- package/src/safety/keystore.js +62 -39
- package/src/safety/undo.js +24 -6
- package/src/tools/fsutil.js +265 -250
- package/src/tools/index.js +239 -34
- package/src/tools/jobs.js +158 -29
- package/src/tools/verify.js +358 -328
- package/src/tools/webfetch.js +92 -9
- package/src/ui/md.js +201 -5
- package/src/ui/motion.js +0 -1
- package/src/ui/pastechip.js +50 -3
- package/src/ui/pick.js +115 -0
- package/src/ui/screen.js +23 -3
- package/src/ui/status.js +613 -610
package/src/backend/adapter.js
CHANGED
|
@@ -1,615 +1,1159 @@
|
|
|
1
|
-
// 규격 차이(OpenAI 호환 / Ollama / Anthropic)를 여기 한 곳에서만 흡수한다.
|
|
2
|
-
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
export function
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
*
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
function
|
|
605
|
-
if (!
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1
|
+
// 규격 차이(OpenAI 호환 / Ollama / Anthropic)를 여기 한 곳에서만 흡수한다.
|
|
2
|
+
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
6
|
+
import { 할당량기억, 미리기다릴까, 마지막할당량, 할당량자리 } from './quota.js';
|
|
7
|
+
import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
|
|
8
|
+
import { 말 } from '../i18n/index.js';
|
|
9
|
+
import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
|
|
10
|
+
import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
|
|
11
|
+
import { 눈금맞추기 } from './wire.js';
|
|
12
|
+
import { 시스템블록, 메시지표식, 잡힐만한가, 조각표 } from './cachemark.js';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Anthropic 규격의 판 이름.
|
|
16
|
+
*
|
|
17
|
+
* 이 머리 하나가 없으면 400 이다. 열쇠가 멀쩡해도 그렇다. 날짜처럼 생겼지만
|
|
18
|
+
* 「오늘 날짜」 가 아니라 **규격 판 이름**이라, 새 날짜를 넣는다고 새 기능이
|
|
19
|
+
* 켜지지 않는다. 회사가 새 판을 내놓기 전에는 이 값이 바뀔 일이 없다.
|
|
20
|
+
*/
|
|
21
|
+
export const ANTHROPIC_VERSION = '2023-06-01';
|
|
22
|
+
|
|
23
|
+
export function endpoint(shape) {
|
|
24
|
+
if (shape === 'ollama') return '/api/chat';
|
|
25
|
+
if (shape === 'anthropic') return '/messages';
|
|
26
|
+
return '/chat/completions';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 이 규격이 더 요구하는 머리. 없으면 빈 것. */
|
|
30
|
+
export function 더할머리(shape) {
|
|
31
|
+
return shape === 'anthropic' ? { 'anthropic-version': ANTHROPIC_VERSION } : {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 이 규격을 사람에게 뭐라고 부를까.
|
|
36
|
+
*
|
|
37
|
+
* 한 곳에 모아 둔다. 화면 여러 자리가 각자 `kind === 'ollama' ? … : 'OpenAI
|
|
38
|
+
* 호환'` 로 갈라 놓고 있었는데, 규격이 셋이 되는 순간 그 자리들이 전부
|
|
39
|
+
* Anthropic 연결을 **「OpenAI 호환」 이라고 잘못 적는다.** 붙는 데는 아무
|
|
40
|
+
* 지장이 없어서 아무도 안 고치고, 그 화면을 믿고 남에게 설명하게 된다.
|
|
41
|
+
*/
|
|
42
|
+
export function 규격이름(shape) {
|
|
43
|
+
if (shape === 'ollama') return 말('head.spec.ollama');
|
|
44
|
+
if (shape === 'anthropic') return 말('head.spec.anthropic');
|
|
45
|
+
return 말('head.spec.openai');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 실제로 두드릴 주소. 물음표 뒤는 **끝에 그대로 남긴다.**
|
|
50
|
+
*
|
|
51
|
+
* Azure 주소에는 `?api-version=2024-10-21` 이 붙어 있고, 그게 없으면 400 이다.
|
|
52
|
+
* 예전처럼 `base + '/chat/completions'` 로 이으면
|
|
53
|
+
* `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 되어
|
|
54
|
+
* 경로도 판도 다 망가진다. 물음표가 없는 보통 주소는 하던 그대로다.
|
|
55
|
+
*/
|
|
56
|
+
export function 주소붙이기(base, 길) {
|
|
57
|
+
const b2 = String(base ?? '');
|
|
58
|
+
const i = b2.indexOf('?');
|
|
59
|
+
if (i < 0) return b2 + 길;
|
|
60
|
+
return b2.slice(0, i).replace(/\/+$/, '') + 길 + b2.slice(i);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function 요청주소(conn) {
|
|
64
|
+
return 주소붙이기(conn?.base, endpoint(conn?.kind));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildBody(shape, { model, messages, tools, stream, json, think, maxTokens = 4096, ctx = null, 회사 = null, 카드 = null, 세션이름 = null }) {
|
|
68
|
+
if (shape === 'ollama') {
|
|
69
|
+
const body = { model, messages, stream: !!stream, options: { num_predict: maxTokens } };
|
|
70
|
+
/*
|
|
71
|
+
* 원하는 컨텍스트를 **지시한다.** 읽기만 하는 게 아니라 정해 준다.
|
|
72
|
+
*
|
|
73
|
+
* Ollama 는 num_ctx 를 안 보내면 제 기본값(대개 4,096 또는 8,192)으로 올린다.
|
|
74
|
+
* 모델이 131,072 까지 되더라도 그렇다. 그런데 /api/show 는 131,072 라고 답한다 —
|
|
75
|
+
* 그 말을 믿고 긴 대화를 보내면 앞부분이 조용히 잘려 나간다. 오류도 안 난다.
|
|
76
|
+
* 모델이 앞을 잊을 뿐이라, 왜 이상한지 알아낼 방법이 없다.
|
|
77
|
+
*
|
|
78
|
+
* 지금까지 이 값을 **한 번도 안 보냈다.** 보내면 그 길이로 올려 준다.
|
|
79
|
+
*/
|
|
80
|
+
if (ctx) body.options.num_ctx = ctx;
|
|
81
|
+
/*
|
|
82
|
+
* 모델을 내리지 말라고 말해 둔다.
|
|
83
|
+
*
|
|
84
|
+
* Ollama 는 5분 동안 조용하면 모델을 내린다. 다음 말을 걸면 다시 올리고
|
|
85
|
+
* **대화 전체를 다시 계산한다** — 프리픽스 캐시가 통째로 사라진 것과 같다.
|
|
86
|
+
* 로컬 대화는 사람이 생각하고 다른 창을 보다 돌아오는 것이라, 5분 넘게
|
|
87
|
+
* 조용한 것이 오히려 보통이다. 그 복귀 첫 마디가 제일 오래 걸리는 이유가
|
|
88
|
+
* 바로 이것이었다.
|
|
89
|
+
*
|
|
90
|
+
* -1(영원히)은 안 쓴다. 모델을 갈아탄 뒤에도 이전 모델이 램을 물고 있게
|
|
91
|
+
* 되는데, 8GB 램에서는 다음 모델이 못 올라온다는 뜻이다. 한 시간이면
|
|
92
|
+
* 일하는 호흡은 다 덮고, 퇴근하면 놓아 준다. DEEL_KEEP_ALIVE 로 바꾼다.
|
|
93
|
+
*/
|
|
94
|
+
body.keep_alive = process.env.DEEL_KEEP_ALIVE || '60m';
|
|
95
|
+
if (tools?.length) body.tools = tools;
|
|
96
|
+
if (json) body.format = json;
|
|
97
|
+
// 참·거짓은 그대로(생각을 켜고 끄는 말이다). 단계말은 이 규격이 아는
|
|
98
|
+
// 말로 옮긴다 — 아래 강도말() 머리말 참고.
|
|
99
|
+
if (typeof think === 'boolean') body.think = think;
|
|
100
|
+
else if (think !== undefined) {
|
|
101
|
+
const 눈금 = 강도말(think);
|
|
102
|
+
if (눈금) body.think = 눈금;
|
|
103
|
+
}
|
|
104
|
+
return body;
|
|
105
|
+
}
|
|
106
|
+
if (shape === 'anthropic') return anthropic몸(
|
|
107
|
+
{ model, messages, tools, stream, json, think, maxTokens, 카드, 세션이름 },
|
|
108
|
+
);
|
|
109
|
+
// 출력 상한을 **두 이름으로 같이** 보낸다.
|
|
110
|
+
//
|
|
111
|
+
// 옛 규격은 max_tokens 하나였다. 그런데 GPT-5 계열을 붙여 놓은 게이트웨이는
|
|
112
|
+
// 그 이름을 아예 안 본다 — max_completion_tokens 만 본다. 그런 서버에
|
|
113
|
+
// max_tokens 만 보내면 상한이 안 걸린 것처럼 제 기본값으로 답하고, 우리가
|
|
114
|
+
// 셈해 둔 자리와 어긋난다. 사용자 게이트웨이가 바로 그 경우였다.
|
|
115
|
+
//
|
|
116
|
+
// 둘 다 보내도 탈이 없다 — **한 곳만 빼고.** 옛 서버는 모르는 이름을 무시하고,
|
|
117
|
+
// 새 서버는 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가
|
|
118
|
+
// 없어진다.
|
|
119
|
+
//
|
|
120
|
+
// ── 그 한 곳: OpenAI 직통 ──────────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// 여기 추론 모델(o 계열·GPT-5 계열)은 옛 이름을 **무시하지 않고 튕긴다** —
|
|
123
|
+
// "Unsupported parameter: 'max_tokens' is not supported with this model."
|
|
124
|
+
// 그러면 첫 요청부터 400 이고, 화면에서는 열쇠가 틀린 것과 구별이 안 된다.
|
|
125
|
+
// 모델 이름으로 가르지 않는다(게이트웨이 뒤에 무엇이 있는지 우리는 모른다).
|
|
126
|
+
// **주소로** 가른다 — 그 규칙은 toolfit.js 의 벤더() 한 곳에서만 정한다.
|
|
127
|
+
//
|
|
128
|
+
// Azure 는 여기 안 넣는다. 옛 판(api-version)이 아직 많고 그쪽은 옛 이름만
|
|
129
|
+
// 본다 — 같이 묶으면 멀쩡히 쓰던 사내 Azure 연결이 이 줄 하나로 끊긴다.
|
|
130
|
+
const 옛이름도 = 회사 !== 'openai';
|
|
131
|
+
const body = { model, messages, stream: !!stream, max_completion_tokens: maxTokens };
|
|
132
|
+
if (옛이름도) body.max_tokens = maxTokens;
|
|
133
|
+
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
|
|
134
|
+
if (json) {
|
|
135
|
+
body.response_format = { type: 'json_schema', json_schema: { name: 'out', schema: json, strict: true } };
|
|
136
|
+
}
|
|
137
|
+
if (think !== undefined && think !== false) {
|
|
138
|
+
/*
|
|
139
|
+
* 눈금은 **이 전선이 받는 말**로 옮긴다 (backend/wire.js).
|
|
140
|
+
*
|
|
141
|
+
* 여태 여기는 max 를 high 로 뭉갰다. 우리 눈금이 다섯인데 받는 곳이 넷뿐인
|
|
142
|
+
* 자리가 있어서였다. 그런데 그러면 Claude 처럼 xhigh·max 를 진짜로 받는
|
|
143
|
+
* 전선에서도 high 밖에 못 나간다 — 화면에는 max 라고 떠 있는 채로.
|
|
144
|
+
* 카드가 있으면 카드가 아는 눈금으로, 없으면 여태처럼 좁은 쪽으로 맞춘다.
|
|
145
|
+
*/
|
|
146
|
+
const 눈금 = 카드 ? (카드.생각형식 === 'effort' ? 눈금맞추기(카드, think) : null) : 강도말(think);
|
|
147
|
+
if (눈금) body.reasoning_effort = 눈금;
|
|
148
|
+
}
|
|
149
|
+
/*
|
|
150
|
+
* 이 대화가 한 덩어리라고 알려 준다.
|
|
151
|
+
*
|
|
152
|
+
* 안 보내면 게이트웨이는 요청마다 새 세션을 연다 — 대시보드에 한 대화가
|
|
153
|
+
* 열 줄로 흩어지고, 세션에 묶어 두는 캐시가 있다면 그것도 매번 새로 엮인다.
|
|
154
|
+
* 아는 칸에만 싣고, 서버가 거절하면 카드가 그것을 배워 다음부터 안 싣는다.
|
|
155
|
+
*/
|
|
156
|
+
if (세션이름 && 카드?.세션자리 === 'user') body.user = 세션이름;
|
|
157
|
+
if (세션이름 && 카드?.캐시 === 'key') body.prompt_cache_key = 세션이름;
|
|
158
|
+
/*
|
|
159
|
+
* 흘려받을 때도 usage 를 달라고 한다.
|
|
160
|
+
*
|
|
161
|
+
* 이 칸이 없으면 흘려받기에서는 usage 가 **아예 안 온다.** 그러면 캐시가
|
|
162
|
+
* 얼마나 맞았는지도, 우리 추정이 얼마나 틀렸는지도(session.배운다) 영영
|
|
163
|
+
* 못 배운다. 아는 창구에만 보낸다 — 모르는 게이트웨이에 지어낸 칸을
|
|
164
|
+
* 실어 보내면 그 400 이 열쇠 문제처럼 보인다.
|
|
165
|
+
*/
|
|
166
|
+
if (stream && 카드?.스트림usage) body.stream_options = { include_usage: true };
|
|
167
|
+
return body;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* ── Anthropic 규격은 어디가 다른가 ─────────────────────────────────────
|
|
172
|
+
*
|
|
173
|
+
* OpenAI 호환 서버는 문 이름(/chat/completions)만 같으면 대충 통했다. 이쪽은
|
|
174
|
+
* 통하지 않는 자리가 넷이다. 그래서 여기 한 곳에 몰아 둔다.
|
|
175
|
+
*
|
|
176
|
+
* 1. 시킴말(system)이 messages 안에 못 들어간다. 몸의 딴 칸이다.
|
|
177
|
+
* 2. 차례가 사람·모델로 **번갈아** 와야 한다. 도구를 한 턴에 둘 부르면
|
|
178
|
+
* 결과가 둘인데, 그대로 보내면 거절당한다.
|
|
179
|
+
* 3. 도구 모양이 { name, description, input_schema } 다.
|
|
180
|
+
* OpenAI 의 { type:'function', function:{...} } 가 아니다.
|
|
181
|
+
* 4. 답이 글 한 덩어리가 아니라 **블록 배열**이다.
|
|
182
|
+
*
|
|
183
|
+
* 안 보내는 것도 적어 둔다. 확인 못 한 것은 안 보낸다는 뜻이지, 없다는 뜻이
|
|
184
|
+
* 아니다 — 짐작으로 보낸 칸 하나가 400 을 만들면 열쇠가 틀린 줄 알게 된다.
|
|
185
|
+
*
|
|
186
|
+
* · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
|
|
187
|
+
* · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
|
|
188
|
+
*/
|
|
189
|
+
/*
|
|
190
|
+
* ── 이 규격의 추론 강도 ────────────────────────────────────────────────
|
|
191
|
+
*
|
|
192
|
+
* OpenAI 호환 쪽은 `reasoning_effort: 'high'` 처럼 **말**로 준다. 이쪽은
|
|
193
|
+
* **토큰 수**로 준다 — `thinking: { type:'enabled', budget_tokens: 12000 }`.
|
|
194
|
+
* 그래서 우리 단계말(low·medium·high·max)을 숫자로 옮겨야 한다.
|
|
195
|
+
*
|
|
196
|
+
* 여태 이 자리가 비어 있었다. anthropic몸() 이 think 를 인자로 받아 놓고 한
|
|
197
|
+
* 번도 안 썼다. 그래서 Claude 를 직접 붙이면 상태줄에는 `◇ medium` 이 뜨는데
|
|
198
|
+
* 요청에는 아무것도 안 실렸다 — **화면과 전선이 다른 말을 하고 있었다.**
|
|
199
|
+
* 아무 일도 안 하는 것보다 나쁘다. 사람은 조절했다고 믿기 때문이다.
|
|
200
|
+
*
|
|
201
|
+
* 지키는 선 둘(둘 다 서버가 거절하는 자리다):
|
|
202
|
+
* · 최소 1,024. 그보다 작게 주면 요청이 통째로 튕긴다.
|
|
203
|
+
* · max_tokens 보다 작아야 한다. 생각도 그 예산에서 나가기 때문이다.
|
|
204
|
+
* 그래서 답이 설 자리를 남겨 두고 깎는다. 그러고도 1,024 가 안 되면
|
|
205
|
+
* 생각을 아예 안 켠다 — 켤 수 없는 자리에서 켜면 그 턴이 죽는다.
|
|
206
|
+
*/
|
|
207
|
+
const 생각최소 = 1024;
|
|
208
|
+
const 답에남길것 = 1024;
|
|
209
|
+
const 강도별예산 = { low: 2048, medium: 6144, high: 16384, xhigh: 24576, max: 32768 };
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* ── 우리 눈금은 여섯, 이 표가 옮기는 창구의 눈금은 넷 ──────────────────
|
|
213
|
+
*
|
|
214
|
+
* agent/effort.js 의 눈금은 off·low·medium·high·**xhigh·max** 다. 그런데 이
|
|
215
|
+
* 표가 상대하는 창구 중에 `xhigh` 나 `max` 를 받는 곳은 하나도 없다 — OpenAI 는 minimal·low·medium·high, Gemini 의
|
|
216
|
+
* OpenAI 호환 창구는 none·low·medium·high, Ollama 는 참·거짓이거나
|
|
217
|
+
* low·medium·high 다. 그 말을 그대로 실어 보내면 400 이고, 그 400 은 화면에서
|
|
218
|
+
* 열쇠가 틀린 것과 구별이 안 된다.
|
|
219
|
+
*
|
|
220
|
+
* 여태 그대로 흘려보내고 있었다. 그리고 이 자리는 `/think max` 를 친
|
|
221
|
+
* 사람만 밟는 것이 아니다 — `깊게` 배분은 첫 판단과 막혔을 때를 한 칸씩
|
|
222
|
+
* 올리므로, `/think high` 만 해도 그 두 자리가 `max` 가 된다. 즉 **가장
|
|
223
|
+
* 세게 생각하라고 시킨 턴만 골라서 죽는다.**
|
|
224
|
+
*
|
|
225
|
+
* 그래서 여기서 전선이 아는 말로 옮긴다. `max` 는 그 규격이 낼 수 있는 제일
|
|
226
|
+
* 센 말(high)이 된다 — 없는 칸을 지어내지 않고, 있는 칸 중 가장 위에 선다.
|
|
227
|
+
*
|
|
228
|
+
* Anthropic 규격은 여기 안 온다. 거기는 말이 아니라 **숫자 예산**으로 주므로
|
|
229
|
+
* (아래 생각예산) `max` 가 32,768 이라는 진짜 값이 된다. 눈금이 모자라지 않는다.
|
|
230
|
+
*
|
|
231
|
+
* 모르는 말은 **안 보낸다.** 짐작으로 실은 칸 하나가 그 턴을 죽인다.
|
|
232
|
+
*
|
|
233
|
+
* 받은 값을 담는 자리를 `말` 이라고 부르지 않는다. 이 파일에서 `말` 은
|
|
234
|
+
* i18n 의 그 함수라, 같은 이름으로 가리면 그 블록 안에서는 화면에 말을 걸
|
|
235
|
+
* 수가 없어진다 — 나중에 한 줄 더 적으려는 사람이 거기서 넘어진다.
|
|
236
|
+
*/
|
|
237
|
+
const 전선눈금 = { low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' };
|
|
238
|
+
|
|
239
|
+
export function 강도말(강도) {
|
|
240
|
+
return 전선눈금[String(강도)] ?? null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function 생각예산(강도, maxTokens) {
|
|
244
|
+
const 바라는것 = 강도별예산[String(강도)];
|
|
245
|
+
if (!바라는것) return 0;
|
|
246
|
+
const 쓸수있는 = Math.floor(Number(maxTokens) || 0) - 답에남길것;
|
|
247
|
+
const 예산 = Math.min(바라는것, 쓸수있는);
|
|
248
|
+
return 예산 >= 생각최소 ? 예산 : 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function anthropic몸({ model, messages, tools, stream, json, think, maxTokens, 카드 = null, 세션이름 = null }) {
|
|
252
|
+
const 머리말 = [];
|
|
253
|
+
// 시스템 글을 「굳은 부분 / 매 턴 바뀌는 부분」 으로 나눠 받았으면 그대로 쓴다.
|
|
254
|
+
// 나눠 받은 조각은 이어 붙이면 원래 글과 **한 글자도 안 다르다**(agent/session.js).
|
|
255
|
+
let 조각들 = null;
|
|
256
|
+
const 나머지 = [];
|
|
257
|
+
for (const m of messages ?? []) {
|
|
258
|
+
if (m?.role === 'system') {
|
|
259
|
+
const 나눔 = m[조각표];
|
|
260
|
+
if (Array.isArray(나눔) && 나눔.length > 1) 조각들 = 나눔;
|
|
261
|
+
머리말.push(typeof m.content === 'string' ? m.content : String(m.content ?? ''));
|
|
262
|
+
} else 나머지.push(m);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const 표식쓰나 = 카드?.캐시 === 'explicit';
|
|
266
|
+
let 대화 = 차례합치기(나머지);
|
|
267
|
+
/*
|
|
268
|
+
* 자라는 대화에 옮겨 가는 표식을 박는다 (backend/cachemark.js).
|
|
269
|
+
*
|
|
270
|
+
* 이것이 없으면 캐시는 정적 앞머리에서 멈춘다 — 대화가 60k 로 자라도
|
|
271
|
+
* 읽히는 것은 5.9k 뿐이고, 나머지는 걸음마다 전액 다시 나간다.
|
|
272
|
+
*/
|
|
273
|
+
if (표식쓰나 && 잡힐만한가(대화, 카드?.캐시최소 ?? 1024)) 대화 = 메시지표식(대화);
|
|
274
|
+
|
|
275
|
+
const body = { model, messages: 대화, stream: !!stream, max_tokens: maxTokens };
|
|
276
|
+
if (머리말.length) {
|
|
277
|
+
body.system = 표식쓰나
|
|
278
|
+
? 시스템블록(조각들 ?? 머리말, true)
|
|
279
|
+
: 머리말.join('\n\n');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/*
|
|
283
|
+
* ── 생각을 어떻게 켜나 ────────────────────────────────────────────────
|
|
284
|
+
*
|
|
285
|
+
* 판마다 다르다. 4.6 판부터는 `adaptive` 하나로 켜고 세기는 output_config
|
|
286
|
+
* 로 준다. 그 전 판은 토큰 예산(budget_tokens)이다. **섞으면 400 이다** —
|
|
287
|
+
* Opus 5 에 budget_tokens 를 보내면 거절당하고, 그 400 은 화면에서 열쇠가
|
|
288
|
+
* 틀린 것과 구별이 안 된다. 어느 쪽인지는 카드가 안다(backend/wire.js).
|
|
289
|
+
*
|
|
290
|
+
* 카드가 없으면 여태 하던 대로 예산으로 간다 — 이 파일을 직접 부르는
|
|
291
|
+
* 자리(검사·진단)가 있어서, 없다고 모양이 달라지면 안 된다.
|
|
292
|
+
*/
|
|
293
|
+
const 형식 = 카드?.생각형식 ?? 'budget';
|
|
294
|
+
if (형식 === 'adaptive') {
|
|
295
|
+
if (think !== undefined && think !== false && think !== 'off') {
|
|
296
|
+
body.thinking = { type: 'adaptive' };
|
|
297
|
+
const 눈금 = 눈금맞추기(카드, think);
|
|
298
|
+
if (눈금 && 카드?.효력칸 === 'output_config') body.output_config = { effort: 눈금 };
|
|
299
|
+
}
|
|
300
|
+
} else if (형식 === 'budget') {
|
|
301
|
+
const 예산 = 생각예산(think, maxTokens);
|
|
302
|
+
if (예산) body.thinking = { type: 'enabled', budget_tokens: 예산 };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 이 대화가 한 덩어리라고 알려 준다. 규격이 정한 칸이다.
|
|
306
|
+
if (세션이름 && 카드?.세션자리 === 'metadata') body.metadata = { user_id: 세션이름 };
|
|
307
|
+
|
|
308
|
+
if (tools?.length) {
|
|
309
|
+
body.tools = tools.map((t) => {
|
|
310
|
+
const f = t.function ?? t;
|
|
311
|
+
return {
|
|
312
|
+
name: f.name,
|
|
313
|
+
description: f.description ?? '',
|
|
314
|
+
input_schema: f.parameters ?? f.input_schema ?? { type: 'object', properties: {} },
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
body.tool_choice = { type: 'auto' };
|
|
318
|
+
}
|
|
319
|
+
return body;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 같은 차례가 연달아 오면 하나로 합친다.
|
|
324
|
+
*
|
|
325
|
+
* 도구를 한 턴에 둘 부르면 결과 메시지가 둘이고, 이 규격에서 그 둘은 다
|
|
326
|
+
* 사람 차례다. 번갈아 오지 않으면 서버가 통째로 거절한다 — 그러면 도구를
|
|
327
|
+
* 하나만 부를 때는 되고 둘 부를 때만 안 되는, 제일 알아내기 어려운 고장이 된다.
|
|
328
|
+
*/
|
|
329
|
+
export function 차례합치기(messages) {
|
|
330
|
+
const 덩이 = (c) => (Array.isArray(c) ? c : [{ type: 'text', text: String(c ?? '') }]);
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const m of messages ?? []) {
|
|
333
|
+
const 앞 = out[out.length - 1];
|
|
334
|
+
if (앞 && 앞.role === m?.role) {
|
|
335
|
+
앞.content = [...덩이(앞.content), ...덩이(m.content)];
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
out.push({ ...m });
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* ── 캐시가 얼마나 맞았나 ────────────────────────────────────────────────
|
|
345
|
+
*
|
|
346
|
+
* 여태 이 프로그램은 **캐시에 눈이 없었다.** usage 에서 들어온 토큰과 나간
|
|
347
|
+
* 토큰만 읽었다. 그래서 캐시가 통째로 안 맞고 있어도 화면에는 아무 표시가
|
|
348
|
+
* 없었고, 고쳐도 나아졌는지 스스로 확인할 방법이 없었다.
|
|
349
|
+
*
|
|
350
|
+
* 이름이 규격마다 다르다. 아는 이름을 다 훑고, 없으면 0 이다 —
|
|
351
|
+
* 응답에 더 있는 칸을 읽는 것은 아무 위험이 없다(보내는 것과 다르다).
|
|
352
|
+
*
|
|
353
|
+
* Anthropic cache_read_input_tokens · cache_creation_input_tokens
|
|
354
|
+
* OpenAI prompt_tokens_details.cached_tokens
|
|
355
|
+
* Bedrock input_tokens_details.cached_tokens · .cache_write_tokens
|
|
356
|
+
*
|
|
357
|
+
* @returns {{읽음:number, 씀:number}}
|
|
358
|
+
*/
|
|
359
|
+
export function 캐시읽기(u) {
|
|
360
|
+
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
|
|
361
|
+
const 자세히 = u?.prompt_tokens_details ?? u?.input_tokens_details ?? null;
|
|
362
|
+
return {
|
|
363
|
+
읽음: n(u?.cache_read_input_tokens) || n(자세히?.cached_tokens) || n(u?.cached_tokens),
|
|
364
|
+
씀: n(u?.cache_creation_input_tokens) || n(자세히?.cache_write_tokens) || n(u?.cache_write_tokens),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/*
|
|
369
|
+
* ── 이번에 **실제로 보낸** 프롬프트가 몇 토큰이었나 ─────────────────────
|
|
370
|
+
*
|
|
371
|
+
* 규격마다 `in` 이 세는 것이 다르다. 여태는 캐시가 한 번도 안 걸려서 둘이
|
|
372
|
+
* 같은 값이었고, 그래서 이 차이가 안 보였다. 캐시를 붙이는 순간 갈라진다.
|
|
373
|
+
*
|
|
374
|
+
* OpenAI prompt_tokens 가 **합계**다. cached·cache_write 가 그 안에 있다.
|
|
375
|
+
* (조직 사용량 표: 1000 = 500 uncached + 400 cached + 100 write)
|
|
376
|
+
* Anthropic input_tokens 는 **마지막 표식 뒤쪽만**이다. 합계는 셋을 더한 값이다.
|
|
377
|
+
* (docs: total = cache_read + cache_creation + input_tokens)
|
|
378
|
+
*
|
|
379
|
+
* 왜 이걸 가려야 하나 — session.배운다() 가 이 값을 「우리 추정」 과 견줘서
|
|
380
|
+
* 토큰 배수를 배우고, 그 배수를 디스크에 남긴다. Anthropic 창구에서 절반이
|
|
381
|
+
* 캐시에 맞으면 `in` 이 절반으로 줄고, 그 절반이 0.5~2배 믿는 구간 안에
|
|
382
|
+
* 들어와서 **배수가 조용히 아래로 끌려간다.** 그러면 남은 자리를 실제보다
|
|
383
|
+
* 넉넉히 보고, 답 상한을 크게 잡고, 접기를 늦게 시작한다. 게다가 그 잘못된
|
|
384
|
+
* 배수가 다음에 켤 때도 그대로 살아난다.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} shape 전선 규격 (conn.kind)
|
|
387
|
+
* @param {object} u 정규화된 usage ({in, cacheRead, cacheWrite})
|
|
388
|
+
* @returns {number} 이번 요청에 들어간 프롬프트 토큰 합계
|
|
389
|
+
*/
|
|
390
|
+
export function 보낸토큰(shape, u) {
|
|
391
|
+
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
|
|
392
|
+
const 들어온것 = n(u?.in);
|
|
393
|
+
if (shape !== 'anthropic') return 들어온것;
|
|
394
|
+
return 들어온것 + n(u?.cacheRead) + n(u?.cacheWrite);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function extractMessage(shape, json) {
|
|
398
|
+
if (shape === 'anthropic') {
|
|
399
|
+
// 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
|
|
400
|
+
let content = '';
|
|
401
|
+
let thinking = '';
|
|
402
|
+
/*
|
|
403
|
+
* 생각 블록은 **받은 그대로** 따로 챙긴다.
|
|
404
|
+
*
|
|
405
|
+
* 글자만 이어 붙이면 안 된다. 이 규격의 생각 블록에는 서명(signature)이
|
|
406
|
+
* 딸려 있고, 도구를 쓰는 턴에서는 그 블록을 서명째 돌려보내야 서버가
|
|
407
|
+
* 받는다. 서명 없이 지어서 보내면 그 턴이 통째로 거절된다.
|
|
408
|
+
* redacted_thinking 은 속을 우리가 못 읽는 블록인데, 그것도 그대로
|
|
409
|
+
* 돌려보내야 한다 — 읽지 말고 나르라는 뜻이다.
|
|
410
|
+
*/
|
|
411
|
+
const 생각블록 = [];
|
|
412
|
+
const 부름들 = [];
|
|
413
|
+
for (const b of Array.isArray(json?.content) ? json.content : []) {
|
|
414
|
+
if (b?.type === 'text') content += b.text ?? '';
|
|
415
|
+
else if (b?.type === 'thinking') {
|
|
416
|
+
thinking += b.thinking ?? '';
|
|
417
|
+
생각블록.push({ type: 'thinking', thinking: b.thinking ?? '', signature: b.signature ?? '' });
|
|
418
|
+
} else if (b?.type === 'redacted_thinking') 생각블록.push({ type: 'redacted_thinking', data: b.data });
|
|
419
|
+
else if (b?.type === 'tool_use') 부름들.push({ id: b.id, name: b.name, args: b.input ?? {} });
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
content,
|
|
423
|
+
thinking,
|
|
424
|
+
생각블록,
|
|
425
|
+
toolCalls: normalizeCalls(부름들),
|
|
426
|
+
// 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
|
|
427
|
+
// 「토큰을 하나도 안 썼다」 로 뜬다.
|
|
428
|
+
usage: {
|
|
429
|
+
in: json?.usage?.input_tokens ?? 0,
|
|
430
|
+
out: json?.usage?.output_tokens ?? 0,
|
|
431
|
+
...(() => { const c = 캐시읽기(json?.usage); return { cacheRead: c.읽음, cacheWrite: c.씀 }; })(),
|
|
432
|
+
},
|
|
433
|
+
stopped: json?.stop_reason ?? null,
|
|
434
|
+
// 안전 판정으로 거절당한 것. 빈 답과 섞으면 엉뚱한 곳을 고치게 된다.
|
|
435
|
+
거절: json?.stop_reason === 'refusal' ? (json?.stop_details ?? { type: 'refusal' }) : null,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (shape === 'ollama') {
|
|
439
|
+
const m = json?.message ?? {};
|
|
440
|
+
return {
|
|
441
|
+
content: m.content ?? '',
|
|
442
|
+
thinking: m.thinking ?? '',
|
|
443
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
444
|
+
usage: { in: json?.prompt_eval_count ?? 0, out: json?.eval_count ?? 0 },
|
|
445
|
+
stopped: json?.done_reason ?? null,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const m = json?.choices?.[0]?.message ?? {};
|
|
449
|
+
// 생각에 쓴 토큰. 이것도 출력 예산에서 나간다 — 상한이 8,000인데 생각에 6,000을
|
|
450
|
+
// 쓰면 실제로 쓸 수 있는 답은 2,000뿐이다. 잘리는 이유가 여기 있을 때가 많은데,
|
|
451
|
+
// 전에는 이 숫자를 읽지도 않아서 화면에도 셈에도 안 나타났다.
|
|
452
|
+
const 생각 = json?.usage?.completion_tokens_details?.reasoning_tokens
|
|
453
|
+
?? json?.usage?.reasoning_tokens ?? 0;
|
|
454
|
+
/*
|
|
455
|
+
* ── 안 하겠다고 한 것인가 ────────────────────────────────────────────
|
|
456
|
+
*
|
|
457
|
+
* 이 규격은 두 가지 모양으로 말한다.
|
|
458
|
+
*
|
|
459
|
+
* message.refusal 모델이 스스로 거절한 글
|
|
460
|
+
* finish_reason 'content_filter' 앞단 필터가 잘라낸 것 (Azure 가 이쪽이다)
|
|
461
|
+
*
|
|
462
|
+
* 둘 다 **빈 답과 겉모습이 같다.** 그래서 예전에는 「읽기만 하고 끝내려
|
|
463
|
+
* 한다」 로 읽고 한 번 더 밀었다. 밀어도 판정은 같아서 또 거절이고, 그게
|
|
464
|
+
* 걸음 수만큼 되풀이됐다 — 한 번 거절당할 요청이 열 번 나갔다.
|
|
465
|
+
*/
|
|
466
|
+
const 끝난까닭 = json?.choices?.[0]?.finish_reason ?? null;
|
|
467
|
+
const 거절글 = typeof m.refusal === 'string' && m.refusal.trim() ? m.refusal.trim() : null;
|
|
468
|
+
const 거절 = 거절글
|
|
469
|
+
? { type: 'refusal', message: 거절글 }
|
|
470
|
+
: (끝난까닭 === 'content_filter' ? { type: 'content_filter' } : null);
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
// 거절 글은 답이 비어 있을 때만 답 자리에 넣는다. 사람이 화면에서
|
|
474
|
+
// 무슨 일이 있었는지 읽을 수 있어야 한다.
|
|
475
|
+
// `??` 로 두면 빈 글자를 못 잡는다. 거절한 답은 `content: ""` 로 오는 쪽이
|
|
476
|
+
// 오히려 흔해서, 그 경우 거절 글이 통째로 사라졌다 — 흘려받기 쪽은
|
|
477
|
+
// `if (!acc.content …)` 로 이미 빈 글자를 챙기고 있었다. 두 길을 맞춘다.
|
|
478
|
+
content: (typeof m.content === 'string' && m.content) ? m.content : (거절글 ?? m.content ?? ''),
|
|
479
|
+
thinking: m.reasoning_content ?? '',
|
|
480
|
+
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
481
|
+
usage: {
|
|
482
|
+
in: json?.usage?.prompt_tokens ?? 0,
|
|
483
|
+
out: json?.usage?.completion_tokens ?? 0,
|
|
484
|
+
reasoning: 생각,
|
|
485
|
+
...(() => { const c = 캐시읽기(json?.usage); return { cacheRead: c.읽음, cacheWrite: c.씀 }; })(),
|
|
486
|
+
},
|
|
487
|
+
stopped: 끝난까닭,
|
|
488
|
+
거절,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 도구 호출을 한 가지 모양으로 맞춘다: { id, name, args(객체) }
|
|
494
|
+
*
|
|
495
|
+
* 인자 JSON 이 안 읽히면 **읽혔다고 치지 않는다.**
|
|
496
|
+
*
|
|
497
|
+
* 예전에는 조용히 { _raw: '...' } 로 바꿔 넘겼다. 그러면 도구는 file_path 가
|
|
498
|
+
* 없다고 "경로가 비었습니다" 라고 답한다 — 원인과 아무 상관 없는 말이다.
|
|
499
|
+
* 모델은 경로를 안 빠뜨렸다. 인자를 쓰다가 출력 한도에서 잘렸을 뿐이다.
|
|
500
|
+
* 그러니 고칠 게 없다고 보고 똑같이 다시 시도하고, 또 잘린다.
|
|
501
|
+
*
|
|
502
|
+
* 실제로 그렇게 됐다 — "Write 경로가 비었습니다" 가 아홉 번 찍히고, 71초 동안
|
|
503
|
+
* 도구를 열세 번 부르고, 컨텍스트가 다 차서 대화를 접었고, 파일은 안 생겼다.
|
|
504
|
+
* 조용히 삼킨 값 하나가 그 전부를 만들었다.
|
|
505
|
+
*/
|
|
506
|
+
export function normalizeCalls(list) {
|
|
507
|
+
return list.map((tc, i) => {
|
|
508
|
+
const fn = tc.function ?? tc;
|
|
509
|
+
let args = fn.arguments ?? fn.args ?? {};
|
|
510
|
+
let 깨짐 = false;
|
|
511
|
+
let 원문 = null;
|
|
512
|
+
if (typeof args === 'string') {
|
|
513
|
+
const s = args.trim();
|
|
514
|
+
// 인자가 아예 없는 도구도 있다. 빈 것은 깨진 것이 아니다.
|
|
515
|
+
if (!s) args = {};
|
|
516
|
+
else {
|
|
517
|
+
try { args = JSON.parse(s); }
|
|
518
|
+
catch { 깨짐 = true; 원문 = args; args = {}; }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const call = { id: tc.id ?? `call_${i + 1}`, name: fn.name, args };
|
|
522
|
+
if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; }
|
|
523
|
+
return call;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
|
|
528
|
+
export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [], 생각블록 = null }) {
|
|
529
|
+
if (shape === 'anthropic') {
|
|
530
|
+
/*
|
|
531
|
+
* ── 생각 블록을 **맨 앞에, 받은 그대로** 돌려보낸다 ──────────────────
|
|
532
|
+
*
|
|
533
|
+
* 여기는 오래 비어 있던 자리다. 예전 주석은 「서명 조각을 제대로 모으는
|
|
534
|
+
* 것까지 확인하지 못했으므로 뺀다」 였다. 이제 모은다(흘려받기의
|
|
535
|
+
* signature_delta). 그래서 실을 수 있다.
|
|
536
|
+
*
|
|
537
|
+
* 왜 실어야 하나 — 생각을 켜고 도구를 쓰면, 서버는 그 도구 부름을 낳은
|
|
538
|
+
* 생각 블록이 **같이 돌아오기를** 요구한다. 안 보내면 그 턴이 거절된다.
|
|
539
|
+
* 즉 생각을 켜는 것과 이 블록을 나르는 것은 한 몸이다.
|
|
540
|
+
*
|
|
541
|
+
* 서명은 우리가 읽거나 고칠 것이 아니다. 받은 문자열 그대로 나른다.
|
|
542
|
+
* 서명이 빈 블록은 아예 안 싣는다 — 지어낸 서명은 거절당하고, 그러면
|
|
543
|
+
* 왜 안 되는지가 화면에서 안 보인다.
|
|
544
|
+
*/
|
|
545
|
+
const 블록 = [];
|
|
546
|
+
for (const b of 생각블록 ?? []) {
|
|
547
|
+
if (b?.type === 'thinking' && b.signature) {
|
|
548
|
+
블록.push({ type: 'thinking', thinking: b.thinking ?? '', signature: b.signature });
|
|
549
|
+
} else if (b?.type === 'redacted_thinking' && b.data) {
|
|
550
|
+
블록.push({ type: 'redacted_thinking', data: b.data });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (content) 블록.push({ type: 'text', text: content });
|
|
554
|
+
for (const t of toolCalls) 블록.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args ?? {} });
|
|
555
|
+
/*
|
|
556
|
+
* 블록이 하나도 없으면 이 규격은 거절한다. 빈 답이 오는 일은 드물지만
|
|
557
|
+
* 그때 대화 전체가 죽으면 안 되니 자리표시를 하나 넣는다.
|
|
558
|
+
*
|
|
559
|
+
* 생각 블록**만** 있는 경우도 여기 걸리지 않게 한다 — 생각만 하고 아무
|
|
560
|
+
* 말도 안 한 턴은 실제로 있고, 그때 생각 블록은 살아 있어야 한다.
|
|
561
|
+
*/
|
|
562
|
+
if (!블록.length) 블록.push({ type: 'text', text: '(빈 답)' });
|
|
563
|
+
return { role: 'assistant', content: 블록 };
|
|
564
|
+
}
|
|
565
|
+
if (shape === 'ollama') {
|
|
566
|
+
const m = { role: 'assistant', content };
|
|
567
|
+
if (thinking) m.thinking = thinking;
|
|
568
|
+
if (toolCalls.length) m.tool_calls = toolCalls.map((t) => ({ function: { name: t.name, arguments: t.args } }));
|
|
569
|
+
return m;
|
|
570
|
+
}
|
|
571
|
+
const m = { role: 'assistant', content: content || null };
|
|
572
|
+
if (toolCalls.length) {
|
|
573
|
+
m.tool_calls = toolCalls.map((t) => ({
|
|
574
|
+
id: t.id, type: 'function',
|
|
575
|
+
function: { name: t.name, arguments: JSON.stringify(t.args) },
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
return m;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/*
|
|
582
|
+
* ── 이 메시지가 무엇을 담고 있나 — 규격 셋을 한자리에서 읽는다 ──────────
|
|
583
|
+
*
|
|
584
|
+
* 도구 부름과 도구 결과가 규격마다 **다른 자리**에 담긴다. 바로 위
|
|
585
|
+
* assistantMessage·toolMessage 가 그렇게 적기 때문이다.
|
|
586
|
+
*
|
|
587
|
+
* OpenAI 부름 assistant.tool_calls[] 결과 {role:'tool', tool_call_id}
|
|
588
|
+
* Ollama 부름 assistant.tool_calls[] 결과 {role:'tool', tool_name}
|
|
589
|
+
* Anthropic 부름 assistant.content 의 tool_use 결과 {role:'user'} content 의 tool_result
|
|
590
|
+
*
|
|
591
|
+
* 이 사실이 여태 **여섯 군데에 흩어져** 있었고, 그중 다섯이 OpenAI 꼴만
|
|
592
|
+
* 알았다. 그래서 Anthropic 창구에서는 접기도, 이력 고치기도, 자르는 자리
|
|
593
|
+
* 고르기도 조용히 아무 일도 안 했다 — 「고친 것 0」 은 화면에서 「고칠 것이
|
|
594
|
+
* 없었다」 와 구별이 안 된다. 안 되는 줄도 모르고 쓰게 되는 종류의 고장이다.
|
|
595
|
+
*
|
|
596
|
+
* 그중 하나(짝맞추기)만 고쳤더니 나머지 다섯이 남았다. 그래서 **적는 자리
|
|
597
|
+
* 바로 옆에 읽는 자리를 둔다.** 규격이 하나 더 늘면 고칠 데도 여기 하나다.
|
|
598
|
+
*/
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 이 메시지가 부른 도구들. 부름이 아니면 빈 배열.
|
|
602
|
+
* @returns {Array<{id:string|null, name:string, args:object}>}
|
|
603
|
+
*/
|
|
604
|
+
export function 부른것들(m) {
|
|
605
|
+
if (!m || m.role !== 'assistant') return [];
|
|
606
|
+
if (Array.isArray(m.content)) {
|
|
607
|
+
return m.content
|
|
608
|
+
.filter((b) => b?.type === 'tool_use')
|
|
609
|
+
.map((b) => ({ id: b.id ?? null, name: b.name ?? '?', args: b.input ?? {} }));
|
|
610
|
+
}
|
|
611
|
+
return (m.tool_calls ?? []).map((t) => {
|
|
612
|
+
const fn = t?.function ?? t ?? {};
|
|
613
|
+
let args = {};
|
|
614
|
+
// 인자는 규격마다 글이기도 하고 객체이기도 하다. 못 읽으면 빈 것으로 둔다 —
|
|
615
|
+
// 인자를 못 읽는 것과 부름이 없는 것은 다르다.
|
|
616
|
+
if (typeof fn.arguments === 'string') { try { args = JSON.parse(fn.arguments || '{}'); } catch { args = {}; } }
|
|
617
|
+
else if (fn.arguments && typeof fn.arguments === 'object') args = fn.arguments;
|
|
618
|
+
else if (t?.args && typeof t.args === 'object') args = t.args;
|
|
619
|
+
return { id: t?.id ?? null, name: fn.name ?? t?.name ?? '?', args };
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 이 메시지가 담고 있는 도구 결과들. 결과가 아니면 빈 배열.
|
|
625
|
+
* @returns {Array<{id:string|null, name:string|null, 글:string}>}
|
|
626
|
+
*/
|
|
627
|
+
export function 결과들(m) {
|
|
628
|
+
if (!m || typeof m !== 'object') return [];
|
|
629
|
+
if (Array.isArray(m.content)) {
|
|
630
|
+
/*
|
|
631
|
+
* 사람 차례에 tool_result 블록이 섞여 온다 — 이 규격에는 도구 차례가
|
|
632
|
+
* 없어서다(toolMessage). 사람이 친 글과 한 메시지에 같이 있을 수 있으니
|
|
633
|
+
* **블록 종류로** 가른다. role 만 보면 사람 말까지 도구 결과가 된다.
|
|
634
|
+
*/
|
|
635
|
+
return m.content
|
|
636
|
+
.filter((b) => b?.type === 'tool_result')
|
|
637
|
+
.map((b) => ({
|
|
638
|
+
id: b.tool_use_id ?? null,
|
|
639
|
+
name: null,
|
|
640
|
+
글: typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? ''),
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
if (m.role !== 'tool') return [];
|
|
644
|
+
return [{
|
|
645
|
+
id: m.tool_call_id ?? null,
|
|
646
|
+
name: m.tool_name ?? null,
|
|
647
|
+
글: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),
|
|
648
|
+
}];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 이 메시지가 **도구 결과만** 담고 있나. 자르는 자리를 고를 때 쓴다. */
|
|
652
|
+
export function 도구결과인가(m) {
|
|
653
|
+
if (!m || typeof m !== 'object') return false;
|
|
654
|
+
if (Array.isArray(m.content)) {
|
|
655
|
+
// 결과 블록이 하나라도 있으면 그렇다. 이 앞을 자르면 짝이 깨진다.
|
|
656
|
+
return m.content.some((b) => b?.type === 'tool_result');
|
|
657
|
+
}
|
|
658
|
+
return m.role === 'tool';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** 이 메시지가 도구를 불렀나. */
|
|
662
|
+
export function 도구불렀나(m) {
|
|
663
|
+
return 부른것들(m).length > 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function toolMessage(shape, { callId, name, content }) {
|
|
667
|
+
// 이 규격에는 도구 차례가 없다. 도구 결과도 **사람 차례**로 돌려준다.
|
|
668
|
+
// role:'tool' 로 보내면 「모르는 역할」 이라고 거절당한다.
|
|
669
|
+
if (shape === 'anthropic') {
|
|
670
|
+
return { role: 'user', content: [{ type: 'tool_result', tool_use_id: callId, content: String(content) }] };
|
|
671
|
+
}
|
|
672
|
+
return shape === 'ollama'
|
|
673
|
+
? { role: 'tool', tool_name: name, content: String(content) }
|
|
674
|
+
: { role: 'tool', tool_call_id: callId, content: String(content) };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/*
|
|
678
|
+
* 이번 요청에 실을 머리말.
|
|
679
|
+
*
|
|
680
|
+
* 열쇠받기가 걸려 있으면 여기서 받아 온다. 요청 **직전**에 받는 것이
|
|
681
|
+
* 중요하다 — 판을 켤 때 한 번 받아 두면 세 시간짜리 대화의 두 시간째에
|
|
682
|
+
* 죽어 있고, 그 401 은 「열쇠가 틀렸다」 와 화면에서 구별이 안 된다.
|
|
683
|
+
*
|
|
684
|
+
* 못 받으면 **던지지 않는다.** 원래 열쇠(있으면)로 그냥 간다. 여기서
|
|
685
|
+
* 막아 버리면 열쇠받기 설정 한 줄이 잘못된 것으로 멀쩡히 붙던 연결까지
|
|
686
|
+
* 안 붙는다. 못 받았다는 것은 부르는 쪽이 onAuth 로 듣고 화면에 적는다.
|
|
687
|
+
*/
|
|
688
|
+
async function 머리말짓기(conn, opts, { 다시 = false } = {}) {
|
|
689
|
+
const 설정 = conn.열쇠받기 ?? null;
|
|
690
|
+
const 판단 = 쓸수있나(설정, { auth: conn.auth });
|
|
691
|
+
if (!설정 || !판단.된다) {
|
|
692
|
+
if (설정 && 판단.왜) opts.onAuth?.({ ok: false, 왜: 판단.왜, 안부름: true });
|
|
693
|
+
return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
|
|
694
|
+
}
|
|
695
|
+
const r = await 열쇠받아오기(설정, {
|
|
696
|
+
다시, signal: opts.signal ?? null,
|
|
697
|
+
물어보기: opts.열쇠물어보기 ?? null,
|
|
698
|
+
알림: opts.onAuth ? (것) => opts.onAuth(것) : null,
|
|
699
|
+
});
|
|
700
|
+
if (!r.ok) {
|
|
701
|
+
opts.onAuth?.({ ...r, ok: false });
|
|
702
|
+
return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
|
|
703
|
+
}
|
|
704
|
+
if (!r.그대로) opts.onAuth?.({ ok: true, 만료: r.만료, ms: r.ms });
|
|
705
|
+
return headersFor(conn.auth, r.token, { ...더할머리(conn.kind), ...r.headers });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/*
|
|
709
|
+
* 401 을 맞았을 때 열쇠를 새로 받고 한 번만 다시 부를까.
|
|
710
|
+
*
|
|
711
|
+
* retry.js 는 401 을 안 다시 부른다 — 열쇠가 틀린 것은 백 번 불러도
|
|
712
|
+
* 같기 때문이다. 그 말은 지금도 맞다. 다른 것은 **열쇠를 바꿀 수 있을
|
|
713
|
+
* 때**뿐이다. 그때는 같은 열쇠로 다시 부르는 것이 아니라 새 열쇠로
|
|
714
|
+
* 부르는 것이라, 「불러 봐야 같다」 에 해당하지 않는다.
|
|
715
|
+
*
|
|
716
|
+
* 한 번만이다. 두 번째 401 은 진짜로 권한이 없는 것이고, 그때 더 부르면
|
|
717
|
+
* 로그인 명령만 되풀이해서 띄우게 된다.
|
|
718
|
+
*/
|
|
719
|
+
function 열쇠다시받을까(conn, status, 이미) {
|
|
720
|
+
return !이미 && Number(status) === 401 && !!conn.열쇠받기;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* 이번에 실제로 보낼 몸통을 만든다. chat 과 chatStream 이 같은 것을 쓴다.
|
|
725
|
+
*
|
|
726
|
+
* 전선 카드와 세션 이름은 **연결에 붙어 있다.** 부르는 자리마다 손으로
|
|
727
|
+
* 넘기게 두면 언젠가 한 곳이 빠지고, 그 한 곳만 캐시가 안 걸린다 —
|
|
728
|
+
* 요약을 만드는 부름(agent/compact.js)이 딱 그런 자리다.
|
|
729
|
+
*/
|
|
730
|
+
function 몸만들기(conn, opts, 맞춘것, 더할것 = {}) {
|
|
731
|
+
return buildBody(conn.kind, {
|
|
732
|
+
model: conn.model,
|
|
733
|
+
ctx: conn.ctx ?? null,
|
|
734
|
+
...opts,
|
|
735
|
+
tools: 맞춘것.tools,
|
|
736
|
+
회사: 벤더(conn),
|
|
737
|
+
카드: opts.카드 ?? conn.전선 ?? null,
|
|
738
|
+
세션이름: opts.세션이름 ?? conn.세션이름 ?? null,
|
|
739
|
+
...더할것,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/*
|
|
744
|
+
* ── 보낸 몸통을 파일로 떨어뜨린다 (DEEL_TRACE_BODY) ─────────────────────
|
|
745
|
+
*
|
|
746
|
+
* 캐시가 왜 안 맞는지는 **인접한 두 요청을 견줘야만** 알 수 있다. 자라는
|
|
747
|
+
* 대화에서 두 요청은 끝부분만 달라야 정상이고, 그보다 앞에서 갈리는 자리가
|
|
748
|
+
* 있으면 거기가 캐시를 깨는 자리다. 화면으로는 절대 안 보인다.
|
|
749
|
+
*
|
|
750
|
+
* 열쇠는 머리말에 있고 여기서는 몸통만 적으므로 열쇠가 새지 않는다. 그래도
|
|
751
|
+
* 대화 내용은 그대로 적히므로 **사람이 환경변수로 켤 때만** 돈다.
|
|
752
|
+
*/
|
|
753
|
+
let 덤프번호 = 0;
|
|
754
|
+
function 몸덤프(body) {
|
|
755
|
+
const 폴더 = process.env.DEEL_TRACE_BODY;
|
|
756
|
+
if (!폴더) return;
|
|
757
|
+
try {
|
|
758
|
+
mkdirSync(폴더, { recursive: true });
|
|
759
|
+
덤프번호 += 1;
|
|
760
|
+
const 이름 = `${String(덤프번호).padStart(4, '0')}.json`;
|
|
761
|
+
writeFileSync(join(폴더, 이름), JSON.stringify(body, null, 2), 'utf8');
|
|
762
|
+
} catch { /* 못 적어도 요청은 간다 — 이건 곁다리다 */ }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* 보내기 **전에** 할당량을 보고 비킨다 (backend/quota.js).
|
|
767
|
+
*
|
|
768
|
+
* 서버가 「남은 것 0, 몇 초 뒤 풀림」 이라고 알려 줬는데도 그대로 보내면
|
|
769
|
+
* 429 를 맞고 사다리를 태우고 턴이 죽는다. 알고 있으면 그냥 기다리면 된다.
|
|
770
|
+
*/
|
|
771
|
+
async function 미리비키기(opts, conn) {
|
|
772
|
+
// **이 창구** 것만 본다. 옆 창구가 바닥났다고 이쪽이 기다리면 안 된다.
|
|
773
|
+
const ms = 미리기다릴까(마지막할당량(할당량자리(conn)));
|
|
774
|
+
if (!ms) return 0;
|
|
775
|
+
opts.onBackoff?.({ type: 'backoff', status: 429, code: null, wait: ms, attempt: 0, max: 0, 미리: true });
|
|
776
|
+
await 기다리기(ms, opts.signal ?? null);
|
|
777
|
+
return ms;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// 한 번에 받기.
|
|
781
|
+
export async function chat(conn, opts) {
|
|
782
|
+
// 이 회사가 받는 모양으로 도구를 다듬는다 (backend/toolfit.js).
|
|
783
|
+
// 모르는 주소면 아무것도 안 바뀐다 — 지금까지와 똑같이 돈다.
|
|
784
|
+
const 맞춘것 = 도구맞추기(opts.tools, conn);
|
|
785
|
+
const body = 몸만들기(conn, opts, 맞춘것);
|
|
786
|
+
몸덤프(body);
|
|
787
|
+
const 정책 = 정책고르기(conn, opts);
|
|
788
|
+
let 열쇠다시받음 = false;
|
|
789
|
+
let 쌓인대기 = await 미리비키기(opts, conn);
|
|
790
|
+
for (let 시도 = 1; ; 시도++) {
|
|
791
|
+
const r = await req(요청주소(conn), {
|
|
792
|
+
method: 'POST',
|
|
793
|
+
headers: await 머리말짓기(conn, opts),
|
|
794
|
+
body,
|
|
795
|
+
timeout: opts.timeout ?? 300000,
|
|
796
|
+
signal: opts.signal ?? null,
|
|
797
|
+
});
|
|
798
|
+
// 서버가 남았다고 말해 준 할당량을 적어 둔다 (backend/quota.js).
|
|
799
|
+
// 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
|
|
800
|
+
할당량기억(r.headers, 할당량자리(conn));
|
|
801
|
+
// 다듬느라 이름을 고쳤으면 여기서 되돌린다. 밖에서는 그런 일이 있었는지
|
|
802
|
+
// 모른 채로 원래 이름을 받는다.
|
|
803
|
+
if (r.ok) return 이름되돌리기(extractMessage(conn.kind, r.json), 맞춘것.되돌림);
|
|
804
|
+
// 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
|
|
805
|
+
// 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
|
|
806
|
+
if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
|
|
807
|
+
열쇠다시받음 = true;
|
|
808
|
+
await 머리말짓기(conn, opts, { 다시: true });
|
|
809
|
+
시도 -= 1;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
// 잠깐 막힌 것이면 기다렸다 다시 부른다 (backend/retry.js 머리말).
|
|
813
|
+
// 한 번에 받는 길은 제너레이터가 아니라 화면에 말을 못 걸어서, 부르는 쪽이
|
|
814
|
+
// 준 onBackoff 로 알린다. 안 줬으면 조용히 기다린다.
|
|
815
|
+
const 다시 = 다시부를지(r, 시도, 정책, 쌓인대기);
|
|
816
|
+
if (!다시) throw 거절오류(r, 시도);
|
|
817
|
+
opts.onBackoff?.(다시);
|
|
818
|
+
쌓인대기 += 다시.wait;
|
|
819
|
+
await 기다리기(다시.wait, opts.signal ?? null);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/*
|
|
824
|
+
* 서버가 한 말을 그대로 달아 둔다. 루프가 이걸 읽고 한계를 배운다(backend/learn.js).
|
|
825
|
+
*
|
|
826
|
+
* 다시 불렀는데도 계속 막힌 것이면 **몇 번 불렀는지**도 적는다. `HTTP 429` 한 줄로는
|
|
827
|
+
* 한 번 막힌 것인지 계속 막히는 것인지 알 수 없고, 그 둘은 사람이 할 일이 다르다 —
|
|
828
|
+
* 앞은 그냥 다시 시키면 되고, 뒤는 할당량을 봐야 한다.
|
|
829
|
+
*/
|
|
830
|
+
function 거절오류(r, 시도) {
|
|
831
|
+
const 원문 = serverMessage(r);
|
|
832
|
+
let 말 = 원문;
|
|
833
|
+
if (시도 > 1) {
|
|
834
|
+
const 무엇 = r.status ? `HTTP ${r.status}` : (r.code ?? '연결 끊김');
|
|
835
|
+
말 += `\n ${시도}번 불렀지만 계속 막혔습니다 (${무엇}) — 잠시 뒤 다시 시키세요`;
|
|
836
|
+
}
|
|
837
|
+
const err = new Error(말);
|
|
838
|
+
err.status = r.status;
|
|
839
|
+
err.serverMessage = 원문;
|
|
840
|
+
err.attempts = 시도;
|
|
841
|
+
return err;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/*
|
|
845
|
+
* 흘려 받으려다 거절당한 응답을, 한 번에 받은 것과 같은 모양으로 바꾼다.
|
|
846
|
+
*
|
|
847
|
+
* 거절당했으면 **본문을 읽는다.** 전에는 여기서 `HTTP 400` 만 던졌다. 스트리밍이라
|
|
848
|
+
* 본문을 안 읽고 넘어간 것인데, 정작 그 본문에 답이 들어 있다 —
|
|
849
|
+
* "This model's maximum context length is 8192 tokens, however you requested 41003"
|
|
850
|
+
* 사용자를 구할 수 있었던 문장이 그 자리에서 사라졌다. 화면에는 ✗ HTTP 400 한 줄만
|
|
851
|
+
* 남고, 왜 그런지 알아낼 방법이 없었다.
|
|
852
|
+
*
|
|
853
|
+
* 실패한 응답은 흘려 받을 것도 없으니 통째로 읽어도 된다. 그리고 다시 부르기 전에
|
|
854
|
+
* 반드시 읽어야 한다 — 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
|
|
855
|
+
*/
|
|
856
|
+
async function 거절읽기(r) {
|
|
857
|
+
const 것 = {
|
|
858
|
+
ok: false, status: r.status, error: r.error ?? null, code: r.code ?? null,
|
|
859
|
+
json: null, text: '', ms: r.ms, headers: r.res?.headers ?? null,
|
|
860
|
+
};
|
|
861
|
+
if (r.res) {
|
|
862
|
+
try {
|
|
863
|
+
것.text = await r.res.text();
|
|
864
|
+
try { 것.json = JSON.parse(것.text); } catch { /* 글로만 오는 서버도 있다 */ }
|
|
865
|
+
} catch { /* 본문마저 못 읽으면 상태 코드만으로 간다 */ }
|
|
866
|
+
}
|
|
867
|
+
return 것;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// 흘려 받기. { type:'thinking'|'content', text } 를 내보내고 마지막에 { type:'done', message } 를 준다.
|
|
871
|
+
export async function* chatStream(conn, opts) {
|
|
872
|
+
const 맞춘것 = 도구맞추기(opts.tools, conn);
|
|
873
|
+
const body = 몸만들기(conn, opts, 맞춘것, { stream: true });
|
|
874
|
+
몸덤프(body);
|
|
875
|
+
const 정책 = 정책고르기(conn, opts);
|
|
876
|
+
let r;
|
|
877
|
+
let 열쇠다시받음 = false;
|
|
878
|
+
let 쌓인대기 = 0;
|
|
879
|
+
{
|
|
880
|
+
const 미리 = 미리기다릴까(마지막할당량(할당량자리(conn)));
|
|
881
|
+
if (미리) {
|
|
882
|
+
yield { type: 'backoff', status: 429, code: null, wait: 미리, attempt: 0, max: 0, 미리: true };
|
|
883
|
+
await 기다리기(미리, opts.signal ?? null);
|
|
884
|
+
쌓인대기 += 미리;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (let 시도 = 1; ; 시도++) {
|
|
888
|
+
r = await req(요청주소(conn), {
|
|
889
|
+
method: 'POST',
|
|
890
|
+
headers: await 머리말짓기(conn, opts),
|
|
891
|
+
body,
|
|
892
|
+
timeout: opts.timeout ?? 300000,
|
|
893
|
+
stream: true,
|
|
894
|
+
signal: opts.signal ?? null,
|
|
895
|
+
});
|
|
896
|
+
할당량기억(r.headers ?? r.res?.headers, 할당량자리(conn));
|
|
897
|
+
if (r.ok && r.res?.body) break;
|
|
898
|
+
const 거절 = await 거절읽기(r);
|
|
899
|
+
// 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
|
|
900
|
+
// 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
|
|
901
|
+
if (열쇠다시받을까(conn, 거절.status, 열쇠다시받음)) {
|
|
902
|
+
열쇠다시받음 = true;
|
|
903
|
+
await 머리말짓기(conn, opts, { 다시: true });
|
|
904
|
+
시도 -= 1;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
// 잠깐 막힌 것이면 알리고, 기다렸다, 다시 부른다. 머리말도 못 받은 자리라
|
|
908
|
+
// 화면에 흘러간 글이 없다 — 그래서 여기서만 다시 부르고, 아래 읽기 도중에
|
|
909
|
+
// 끊긴 것은 다시 안 부른다 (backend/retry.js 머리말).
|
|
910
|
+
const 다시 = 다시부를지(거절, 시도, 정책, 쌓인대기);
|
|
911
|
+
if (!다시) throw 거절오류(거절, 시도);
|
|
912
|
+
yield 다시;
|
|
913
|
+
쌓인대기 += 다시.wait;
|
|
914
|
+
await 기다리기(다시.wait, opts.signal ?? null);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const acc = {
|
|
918
|
+
content: '', thinking: '', toolCalls: [],
|
|
919
|
+
usage: { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
|
|
920
|
+
stopped: null,
|
|
921
|
+
};
|
|
922
|
+
const reader = r.res.body.getReader();
|
|
923
|
+
const dec = new TextDecoder();
|
|
924
|
+
let buf = '';
|
|
925
|
+
|
|
926
|
+
while (true) {
|
|
927
|
+
// 사용자가 끊었으면 흘러오는 것을 더 받지 않는다. 읽던 연결도 닫는다.
|
|
928
|
+
if (opts.signal?.aborted) {
|
|
929
|
+
try { await reader.cancel(); } catch {}
|
|
930
|
+
throw new Aborted();
|
|
931
|
+
}
|
|
932
|
+
let done;
|
|
933
|
+
let value;
|
|
934
|
+
try { ({ done, value } = await reader.read()); }
|
|
935
|
+
catch (err) { if (opts.signal?.aborted) throw new Aborted(); throw err; }
|
|
936
|
+
if (done) break;
|
|
937
|
+
buf += dec.decode(value, { stream: true });
|
|
938
|
+
|
|
939
|
+
// OpenAI 는 SSE(data: ...), Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
|
|
940
|
+
let nl;
|
|
941
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
942
|
+
const line = buf.slice(0, nl).trim();
|
|
943
|
+
buf = buf.slice(nl + 1);
|
|
944
|
+
if (!line) continue;
|
|
945
|
+
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
|
946
|
+
if (payload === '[DONE]') continue;
|
|
947
|
+
let obj;
|
|
948
|
+
try { obj = JSON.parse(payload); } catch { continue; }
|
|
949
|
+
for (const ev of absorb(conn.kind, obj, acc)) yield ev;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/*
|
|
953
|
+
* ── 끝을 안 알려 주고 끊긴 것은 '끝난 것' 이 아니다 ──────────────────────
|
|
954
|
+
*
|
|
955
|
+
* 규격대로면 끝을 알리는 조각이 반드시 하나 온다 —
|
|
956
|
+
* OpenAI 는 finish_reason, Anthropic 은 message_delta.stop_reason,
|
|
957
|
+
* Ollama 는 done:true. 그게 하나도 안 왔는데 흘러오던 것이 그냥 멎었으면
|
|
958
|
+
* **왜 끝났는지 모르는 것**이다. 중계 프록시가 몸통을 자르고 연결을 곱게
|
|
959
|
+
* 닫으면 이 모양이 된다(끊긴 티가 안 나서 read 가 던지지도 않는다).
|
|
960
|
+
*
|
|
961
|
+
* 여태 stopped 를 null 로 뒀는데, 위에서는 null 을 '정상 종료' 와 구별하지
|
|
962
|
+
* 못했다. 그래서 중간에서 잘린 답이 온전한 답으로 지나갔다 — 사람 눈에는
|
|
963
|
+
* 모델이 말을 하다 만 것으로 보이니 같은 것을 다시 시킨다.
|
|
964
|
+
*
|
|
965
|
+
* 다만 '상한에서 잘림(length)' 과 같이 취급하지는 않는다. 까닭이 다르므로
|
|
966
|
+
* 상한을 올려 다시 부르는 것은 답이 아니다. 이름만 따로 붙여서, 화면이
|
|
967
|
+
* 사실대로 말할 수 있게 한다. 아무것도 안 온 경우는 여기서 안 다룬다 —
|
|
968
|
+
* 그건 '빈 답' 쪽이 받는다.
|
|
969
|
+
*/
|
|
970
|
+
if (acc.stopped == null && (acc.content || acc.thinking || acc.toolCalls.length)) {
|
|
971
|
+
acc.stopped = 말없이끝남;
|
|
972
|
+
}
|
|
973
|
+
/*
|
|
974
|
+
* 거절 글밖에 안 온 경우, 그 글을 답 자리에 놓는다.
|
|
975
|
+
*
|
|
976
|
+
* 이 규격은 거절을 `content` 가 아니라 `refusal` 로 흘려보낸다. 그대로 두면
|
|
977
|
+
* 화면에는 **아무 글도 안 나오고**, 사람은 답이 비었다고 읽고 같은 말을 또
|
|
978
|
+
* 친다. 판정은 같으니 또 거절이고, 값만 두 배가 된다.
|
|
979
|
+
*/
|
|
980
|
+
if (!acc.content && acc.거절글?.trim()) acc.content = acc.거절글.trim();
|
|
981
|
+
delete acc.거절글;
|
|
982
|
+
yield { type: 'done', message: 이름되돌리기(acc, 맞춘것.되돌림) };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** 서버가 끝난 까닭을 안 주고 흘려보내기를 멈춘 것. 'stop' 과 구별해야 한다. */
|
|
986
|
+
export const 말없이끝남 = '말없이끝남';
|
|
987
|
+
|
|
988
|
+
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
989
|
+
function absorb(shape, obj, acc) {
|
|
990
|
+
const out = [];
|
|
991
|
+
if (shape === 'anthropic') return anthropic흡수(obj, acc, out);
|
|
992
|
+
if (shape === 'ollama') {
|
|
993
|
+
const m = obj.message ?? {};
|
|
994
|
+
if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
|
|
995
|
+
if (m.content) { acc.content += m.content; out.push({ type: 'content', text: m.content }); }
|
|
996
|
+
if (m.tool_calls?.length) acc.toolCalls.push(...normalizeCalls(m.tool_calls));
|
|
997
|
+
if (obj.done) {
|
|
998
|
+
acc.usage = { in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
|
|
999
|
+
acc.stopped = obj.done_reason ?? 'stop';
|
|
1000
|
+
}
|
|
1001
|
+
return out;
|
|
1002
|
+
}
|
|
1003
|
+
const d = obj.choices?.[0]?.delta ?? {};
|
|
1004
|
+
if (d.reasoning_content) { acc.thinking += d.reasoning_content; out.push({ type: 'thinking', text: d.reasoning_content }); }
|
|
1005
|
+
if (d.content) { acc.content += d.content; out.push({ type: 'content', text: d.content }); }
|
|
1006
|
+
if (d.tool_calls?.length) mergeDeltaCalls(acc, d.tool_calls);
|
|
1007
|
+
/*
|
|
1008
|
+
* 거절 글도 조각으로 흘러온다. 이어 붙여 둔다 — 한 조각만 보고 판단하면
|
|
1009
|
+
* 「Sorry」 한 마디로 끝난 답과 구별이 안 된다.
|
|
1010
|
+
*/
|
|
1011
|
+
if (typeof d.refusal === 'string' && d.refusal) acc.거절글 = (acc.거절글 ?? '') + d.refusal;
|
|
1012
|
+
if (obj.usage) {
|
|
1013
|
+
const c = 캐시읽기(obj.usage);
|
|
1014
|
+
acc.usage = {
|
|
1015
|
+
in: obj.usage.prompt_tokens ?? 0,
|
|
1016
|
+
out: obj.usage.completion_tokens ?? 0,
|
|
1017
|
+
// 한 번에 받는 길과 같은 자리를 본다. 여기만 빠지면 흘려받을 때
|
|
1018
|
+
// 생각 토큰이 늘 0 으로 보이는데, 그건 「생각을 안 했다」 로 읽힌다.
|
|
1019
|
+
reasoning: obj.usage.completion_tokens_details?.reasoning_tokens
|
|
1020
|
+
?? obj.usage.reasoning_tokens ?? 0,
|
|
1021
|
+
cacheRead: c.읽음,
|
|
1022
|
+
cacheWrite: c.씀,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
const fin = obj.choices?.[0]?.finish_reason;
|
|
1026
|
+
if (fin) acc.stopped = fin;
|
|
1027
|
+
// 흘려받는 길도 거절을 알아본다. 여기가 빠지면 흘려받기를 켠 사람에게만
|
|
1028
|
+
// 예전 그대로 되밀기가 남는다 — 그게 기본값이라 사실상 아무도 안 고쳐진다.
|
|
1029
|
+
if (acc.거절글?.trim()) acc.거절 = { type: 'refusal', message: acc.거절글.trim() };
|
|
1030
|
+
else if (fin === 'content_filter') acc.거절 = { type: 'content_filter' };
|
|
1031
|
+
return out;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/*
|
|
1035
|
+
* Anthropic 흘려받기.
|
|
1036
|
+
*
|
|
1037
|
+
* OpenAI 는 조각마다 같은 모양(choices[0].delta)이 오는데, 이쪽은 **사건 이름**
|
|
1038
|
+
* 으로 나뉜다. 하나씩 다르게 읽어야 한다.
|
|
1039
|
+
*
|
|
1040
|
+
* message_start — 시작. 여기 입력 토큰 수가 들어 있다.
|
|
1041
|
+
* content_block_start — 블록 하나 시작. 도구 부름이면 이름과 번호가 여기 있다.
|
|
1042
|
+
* content_block_delta — 알맹이 조각. 글·생각·도구 인자가 각각 딴 이름으로 온다.
|
|
1043
|
+
* message_delta — 끝난 까닭과 출력 토큰 수.
|
|
1044
|
+
* message_stop — 끝.
|
|
1045
|
+
*
|
|
1046
|
+
* 도구 인자는 글자 단위로 쪼개져 오므로 번호별로 이어 붙인다. 이 자리는
|
|
1047
|
+
* OpenAI 쪽과 같아서 마무리(도구마무리)를 같이 쓴다.
|
|
1048
|
+
*/
|
|
1049
|
+
function anthropic흡수(obj, acc, out) {
|
|
1050
|
+
const 종류 = obj?.type;
|
|
1051
|
+
const 번호 = obj?.index ?? 0;
|
|
1052
|
+
if (종류 === 'message_start') {
|
|
1053
|
+
const u = obj.message?.usage;
|
|
1054
|
+
if (u) {
|
|
1055
|
+
// 캐시 수치는 여기 한 번만 온다. message_delta 에는 안 실린다 —
|
|
1056
|
+
// 여기서 안 챙기면 흘려받기에서는 캐시가 영영 0 으로 보인다.
|
|
1057
|
+
const c = 캐시읽기(u);
|
|
1058
|
+
acc.usage = { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: c.읽음, cacheWrite: c.씀 };
|
|
1059
|
+
}
|
|
1060
|
+
return out;
|
|
1061
|
+
}
|
|
1062
|
+
if (종류 === 'content_block_start') {
|
|
1063
|
+
const b = obj.content_block ?? {};
|
|
1064
|
+
if (b.type === 'tool_use') {
|
|
1065
|
+
acc._raw ??= [];
|
|
1066
|
+
acc._raw[번호] = { id: b.id, name: b.name ?? '', args: '' };
|
|
1067
|
+
} else if (b.type === 'thinking') {
|
|
1068
|
+
/*
|
|
1069
|
+
* 생각 블록이 열렸다. 여기서는 속이 비어 있고(`thinking:''`,
|
|
1070
|
+
* `signature:''`), 글은 thinking_delta 로, 서명은 **블록이 닫히기
|
|
1071
|
+
* 직전** signature_delta 로 따로 온다. 그래서 자리를 먼저 잡아 두고
|
|
1072
|
+
* 번호로 찾아 채운다 — 한 답에 생각 블록이 여럿일 수 있다.
|
|
1073
|
+
*/
|
|
1074
|
+
acc.생각블록 ??= [];
|
|
1075
|
+
acc.생각블록[번호] = { type: 'thinking', thinking: b.thinking ?? '', signature: b.signature ?? '' };
|
|
1076
|
+
} else if (b.type === 'redacted_thinking') {
|
|
1077
|
+
// 속을 우리가 못 읽는 블록. 읽지 말고 그대로 나르라는 뜻이다.
|
|
1078
|
+
acc.생각블록 ??= [];
|
|
1079
|
+
acc.생각블록[번호] = { type: 'redacted_thinking', data: b.data };
|
|
1080
|
+
}
|
|
1081
|
+
return out;
|
|
1082
|
+
}
|
|
1083
|
+
if (종류 === 'content_block_delta') {
|
|
1084
|
+
const d = obj.delta ?? {};
|
|
1085
|
+
if (d.type === 'text_delta' && d.text) {
|
|
1086
|
+
acc.content += d.text;
|
|
1087
|
+
out.push({ type: 'content', text: d.text });
|
|
1088
|
+
} else if (d.type === 'thinking_delta' && d.thinking) {
|
|
1089
|
+
acc.thinking += d.thinking;
|
|
1090
|
+
// 화면으로 흘려보내는 것과 별개로, 돌려보낼 블록에도 그대로 쌓는다.
|
|
1091
|
+
acc.생각블록 ??= [];
|
|
1092
|
+
acc.생각블록[번호] ??= { type: 'thinking', thinking: '', signature: '' };
|
|
1093
|
+
acc.생각블록[번호].thinking += d.thinking;
|
|
1094
|
+
out.push({ type: 'thinking', text: d.thinking });
|
|
1095
|
+
} else if (d.type === 'signature_delta' && d.signature) {
|
|
1096
|
+
/*
|
|
1097
|
+
* 서명. 이것 하나가 없으면 그 생각 블록은 못 돌려보낸다 — 서버가
|
|
1098
|
+
* 서명 없는 생각 블록을 거절하기 때문이다. 여기를 빠뜨리면 생각을
|
|
1099
|
+
* 켠 채 도구를 쓰는 순간 그 턴이 죽고, 화면에는 왜인지 안 나온다.
|
|
1100
|
+
*
|
|
1101
|
+
* 조각으로 나뉘어 올 수 있으므로 이어 붙인다.
|
|
1102
|
+
*/
|
|
1103
|
+
acc.생각블록 ??= [];
|
|
1104
|
+
acc.생각블록[번호] ??= { type: 'thinking', thinking: '', signature: '' };
|
|
1105
|
+
acc.생각블록[번호].signature += d.signature;
|
|
1106
|
+
} else if (d.type === 'input_json_delta' && d.partial_json != null) {
|
|
1107
|
+
acc._raw ??= [];
|
|
1108
|
+
acc._raw[번호] ??= { id: null, name: '', args: '' };
|
|
1109
|
+
acc._raw[번호].args += d.partial_json;
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
if (종류 === 'message_delta') {
|
|
1114
|
+
/*
|
|
1115
|
+
* 출력 토큰은 **덮어쓴다.** 조각마다의 양이 아니라 여태 누적 총계라서,
|
|
1116
|
+
* 더하면 조각 수만큼 부풀어 오른다. 입력 쪽은 해당 없으면 아예 안 오므로
|
|
1117
|
+
* 왔을 때만 덮는다 — 없는 것을 0 으로 덮으면 message_start 에서 받아 둔
|
|
1118
|
+
* 진짜 값이 지워진다.
|
|
1119
|
+
*/
|
|
1120
|
+
if (obj.usage?.output_tokens != null) acc.usage.out = obj.usage.output_tokens;
|
|
1121
|
+
if (obj.usage?.input_tokens != null) acc.usage.in = obj.usage.input_tokens;
|
|
1122
|
+
// 캐시 수치는 대개 message_start 에 실리지만, 여기 싣는 판도 있다.
|
|
1123
|
+
const c = 캐시읽기(obj.usage);
|
|
1124
|
+
if (c.읽음) acc.usage.cacheRead = c.읽음;
|
|
1125
|
+
if (c.씀) acc.usage.cacheWrite = c.씀;
|
|
1126
|
+
if (obj.delta?.stop_reason) acc.stopped = obj.delta.stop_reason;
|
|
1127
|
+
if (obj.delta?.stop_reason === 'refusal') acc.거절 = obj.delta.stop_details ?? { type: 'refusal' };
|
|
1128
|
+
return out;
|
|
1129
|
+
}
|
|
1130
|
+
if (종류 === 'content_block_stop' || 종류 === 'message_stop') 도구마무리(acc);
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
|
|
1135
|
+
function mergeDeltaCalls(acc, deltas) {
|
|
1136
|
+
acc._raw ??= [];
|
|
1137
|
+
for (const d of deltas) {
|
|
1138
|
+
const i = d.index ?? 0;
|
|
1139
|
+
acc._raw[i] ??= { id: d.id, name: '', args: '' };
|
|
1140
|
+
if (d.id) acc._raw[i].id = d.id;
|
|
1141
|
+
if (d.function?.name) acc._raw[i].name += d.function.name;
|
|
1142
|
+
if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
|
|
1143
|
+
}
|
|
1144
|
+
도구마무리(acc);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// 글자로 쪼개져 온 도구 인자를 하나로 읽는다. 두 규격이 같이 쓴다.
|
|
1148
|
+
function 도구마무리(acc) {
|
|
1149
|
+
if (!acc._raw) return;
|
|
1150
|
+
// 인자가 안 읽히면 읽혔다고 치지 않는다 — normalizeCalls 머리말 참고.
|
|
1151
|
+
// 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
|
|
1152
|
+
acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
|
|
1153
|
+
const call = { id: c.id ?? `call_${i + 1}`, name: c.name, args: {} };
|
|
1154
|
+
if (!c.args) return call;
|
|
1155
|
+
try { call.args = JSON.parse(c.args); }
|
|
1156
|
+
catch { call.argsBroken = true; call.rawArgs = c.args; }
|
|
1157
|
+
return call;
|
|
1158
|
+
});
|
|
1159
|
+
}
|