deel-local-cli 1.12.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.
- package/README.ko.md +5 -5
- package/README.md +5 -5
- package/package.json +60 -60
- package/src/acp/map.js +367 -362
- package/src/agent/commit.js +534 -511
- package/src/agent/compact.js +36 -15
- package/src/agent/loop.js +25 -4
- package/src/agent/recall.js +222 -209
- package/src/agent/session.js +10 -3
- package/src/backend/adapter.js +219 -23
- package/src/backend/cachemark.js +82 -9
- package/src/backend/http.js +460 -458
- package/src/backend/mcp.js +407 -395
- package/src/backend/price.js +260 -197
- package/src/backend/probe.js +487 -473
- package/src/backend/quota.js +35 -11
- package/src/backend/tokens.js +59 -37
- package/src/backend/wire.js +121 -6
- package/src/commands.js +56 -7
- package/src/config.js +283 -244
- package/src/i18n/en.js +1 -0
- package/src/i18n/ja.js +1 -0
- package/src/i18n/ko.js +1 -0
- package/src/i18n/zh.js +1 -0
- package/src/oneshot.js +6 -1
- package/src/pack/tar.js +154 -132
- package/src/pack/zip.js +235 -217
- package/src/plugins/manage.js +416 -336
- package/src/repl.js +17 -3
- package/src/safety/audit.js +148 -129
- package/src/safety/guard.js +641 -526
- package/src/safety/network.js +200 -157
- package/src/tools/fastgrep.js +229 -214
- package/src/tools/fsutil.js +28 -2
- package/src/tools/index.js +2560 -2523
- package/src/tools/jobs.js +876 -795
- package/src/tools/label.js +52 -0
- package/src/tools/spawn.js +213 -99
- package/src/tools/webfetch.js +418 -393
- package/src/ui/export.js +233 -217
- package/src/ui/status.js +5 -1
package/src/backend/adapter.js
CHANGED
|
@@ -97,6 +97,18 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
97
97
|
// 참·거짓은 그대로(생각을 켜고 끄는 말이다). 단계말은 이 규격이 아는
|
|
98
98
|
// 말로 옮긴다 — 아래 강도말() 머리말 참고.
|
|
99
99
|
if (typeof think === 'boolean') body.think = think;
|
|
100
|
+
/*
|
|
101
|
+
* `off` 는 **끄라는 말이다.** 안 싣는 것과 다르다.
|
|
102
|
+
*
|
|
103
|
+
* 여기가 `강도말('off')` 로 떨어졌었다. 그 표에는 off 가 없어서 null 이
|
|
104
|
+
* 나오고, null 이면 `think` 칸을 아예 안 실었다. 그런데 이 규격에서 칸을
|
|
105
|
+
* 안 싣는 것은 **모델 기본값**이라는 뜻이고, 생각하는 모델의 기본값은
|
|
106
|
+
* 켜짐이다. 즉 사람이 `/think off` 를 치면 화면에는 off 가 뜨고 전선에는
|
|
107
|
+
* 아무것도 안 나가서 모델은 그대로 생각했다.
|
|
108
|
+
*
|
|
109
|
+
* 이 규격은 참·거짓을 그대로 받는다. 끄라고 했으면 끄라고 보낸다.
|
|
110
|
+
*/
|
|
111
|
+
else if (think === 'off') body.think = false;
|
|
100
112
|
else if (think !== undefined) {
|
|
101
113
|
const 눈금 = 강도말(think);
|
|
102
114
|
if (눈금) body.think = 눈금;
|
|
@@ -117,18 +129,70 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
117
129
|
// 새 서버는 제가 보는 이름을 골라 쓴다. 둘 중 무엇을 보는지 우리가 알 필요가
|
|
118
130
|
// 없어진다.
|
|
119
131
|
//
|
|
120
|
-
// ──
|
|
132
|
+
// ── 튕기는 서버가 있다 ────────────────────────────────────────────────
|
|
121
133
|
//
|
|
122
|
-
//
|
|
134
|
+
// 추론 모델(o 계열·GPT-5 계열)은 옛 이름을 **무시하지 않고 튕긴다** —
|
|
123
135
|
// "Unsupported parameter: 'max_tokens' is not supported with this model."
|
|
124
136
|
// 그러면 첫 요청부터 400 이고, 화면에서는 열쇠가 틀린 것과 구별이 안 된다.
|
|
125
|
-
// 모델 이름으로 가르지 않는다(게이트웨이 뒤에 무엇이 있는지 우리는 모른다).
|
|
126
|
-
// **주소로** 가른다 — 그 규칙은 toolfit.js 의 벤더() 한 곳에서만 정한다.
|
|
127
137
|
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
// 여기가 `회사 !== 'openai'` 였다. **주소로** 가른 것이다. 그래서 사내
|
|
139
|
+
// 게이트웨이 뒤에 GPT-5 를 물리면(회사 = null) 둘 다 나가서 첫 요청이
|
|
140
|
+
// 죽었다. 집안 규칙이 「업체 이름으로 갈래를 만들지 않는다」 인 까닭이
|
|
141
|
+
// 이것이다 — 주소는 뒤에 무엇이 있는지 말해 주지 않는다.
|
|
142
|
+
//
|
|
143
|
+
// 이제는 카드가 정한다(backend/wire.js 의 출력칸). 짐작은 **모델 이름**
|
|
144
|
+
// 으로 하고, 틀리면 400 한 번으로 배운다.
|
|
145
|
+
const 칸정하기 = 카드?.출력칸 ?? '둘다';
|
|
146
|
+
const 옛이름도 = 칸정하기 !== '새것';
|
|
147
|
+
const 새이름도 = 칸정하기 !== '옛것';
|
|
148
|
+
/*
|
|
149
|
+
* ── 자라는 대화에 옮겨 가는 표식을 박는다 ─────────────────────────────
|
|
150
|
+
*
|
|
151
|
+
* 여태 이 몸에는 표식이 **한 줄도** 없었다. Anthropic 몸에만 달아 놓고
|
|
152
|
+
* 여기는 「서버가 알아서 하겠지」 로 뒀는데, Claude 를 앞에 둔 창구에서
|
|
153
|
+
* 그게 틀린다 — 캐시가 처음 앞머리에서 멈추고, 늘어난 대화는 걸음마다
|
|
154
|
+
* 전액 다시 나간다. 붙일지 말지는 카드가 정한다(backend/wire.js).
|
|
155
|
+
*
|
|
156
|
+
* 꼬리에 붙이는 것이 핵심이다. 도구를 도는 턴에서는 마지막 자리가
|
|
157
|
+
* `role:'tool'` 인데, 그 자리에 글 조각 배열을 쓰는 것은 **OpenAI 규격
|
|
158
|
+
* 그대로다**(ChatCompletionRequestToolMessageContentPart). 지어낸 모양이
|
|
159
|
+
* 아니라서 이 칸을 모르는 서버도 조각 배열 자체는 읽는다.
|
|
160
|
+
*
|
|
161
|
+
* 시스템 글은 따로 다룬다. 굳은 부분과 매 턴 바뀌는 부분을 나눠 받았으면
|
|
162
|
+
* (agent/session.js) **굳은 부분 끝**에 박아야 한다. 통째로 끝에 박으면
|
|
163
|
+
* 매 턴 새로 쓰게 되어 표식이 있으나 마나가 된다.
|
|
164
|
+
*/
|
|
165
|
+
let 대화 = messages;
|
|
166
|
+
// 문턱을 잴 때 **실제로 앞머리에 나가는 것**을 같이 준다 — 도구 스키마와
|
|
167
|
+
// 시킴말. 메시지만 세면 도구가 큰 판에서 캐시가 조용히 안 켜진다.
|
|
168
|
+
if (카드?.캐시 === 'explicit'
|
|
169
|
+
&& 잡힐만한가(대화, 카드?.캐시최소 ?? 1024, { tools, system: 대화[0]?.role === 'system' ? 대화[0].content : null })) {
|
|
170
|
+
const 칸 = 카드?.표식칸 ?? 'cache_control';
|
|
171
|
+
/*
|
|
172
|
+
* 시킴말은 **따로** 다룬다 — 그리고 옮겨 가는 표식에서 빼 둔다.
|
|
173
|
+
*
|
|
174
|
+
* 둘 다 걸리면 같은 메시지를 두 번 손대게 된다. 먼저 옮겨 가는 표식이
|
|
175
|
+
* content 를 조각 배열로 바꿔 놓고, 그 뒤 시스템블록이 그 배열을 통째로
|
|
176
|
+
* 글 하나로 치면서 `[object Object]` 를 만든다. 대화가 시킴말 하나뿐일
|
|
177
|
+
* 때 실제로 그렇게 된다.
|
|
178
|
+
*
|
|
179
|
+
* 자리도 다르다. 옮겨 가는 표식은 **끝**에 박고, 시킴말은 **굳은 부분
|
|
180
|
+
* 끝**에 박아야 한다(agent/session.js 의 시스템조각). 통째로 끝에 박으면
|
|
181
|
+
* 모드가 바뀔 때마다 새로 쓰게 되어 표식이 있으나 마나가 된다.
|
|
182
|
+
*/
|
|
183
|
+
const 시킴 = 대화[0]?.role === 'system' ? 대화[0] : null;
|
|
184
|
+
const 나머지 = 시킴 ? 대화.slice(1) : 대화;
|
|
185
|
+
const 박은것 = 나머지.length ? 메시지표식(나머지, 칸) : 나머지;
|
|
186
|
+
if (시킴) {
|
|
187
|
+
const 나눔 = 시킴[조각표];
|
|
188
|
+
const 블록 = 시스템블록(Array.isArray(나눔) && 나눔.length > 1 ? 나눔 : [시킴.content], true, 칸);
|
|
189
|
+
대화 = [Array.isArray(블록) ? { ...시킴, content: 블록 } : 시킴, ...박은것];
|
|
190
|
+
} else {
|
|
191
|
+
대화 = 박은것;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const body = { model, messages: 대화, stream: !!stream };
|
|
195
|
+
if (새이름도) body.max_completion_tokens = maxTokens;
|
|
132
196
|
if (옛이름도) body.max_tokens = maxTokens;
|
|
133
197
|
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; }
|
|
134
198
|
if (json) {
|
|
@@ -270,12 +334,23 @@ function anthropic몸({ model, messages, tools, stream, json, think, maxTokens,
|
|
|
270
334
|
* 이것이 없으면 캐시는 정적 앞머리에서 멈춘다 — 대화가 60k 로 자라도
|
|
271
335
|
* 읽히는 것은 5.9k 뿐이고, 나머지는 걸음마다 전액 다시 나간다.
|
|
272
336
|
*/
|
|
273
|
-
|
|
337
|
+
/*
|
|
338
|
+
* 어느 칸에 적을지는 **카드가 안다** (backend/wire.js 의 표식칸).
|
|
339
|
+
*
|
|
340
|
+
* 여기가 카드를 안 보고 `cache_control` 을 박아 두고 있었다. 그러면
|
|
341
|
+
* 배움이 진행을 못 한다 — 서버가 그 칸을 거절해서 `배울전선` 이
|
|
342
|
+
* `prompt_cache_breakpoint` 로 바꾸라고 배우고 디스크에 남겨도, 다음
|
|
343
|
+
* 요청이 또 `cache_control` 을 보낸다. 같은 400 을 다시 맞고, 같은 것을
|
|
344
|
+
* 다시 배우고, 「그것도 안 되면 끈다」 에 **영영 못 닿는다.**
|
|
345
|
+
* 그 창구에서는 매 턴이 죽는데 화면에는 카드가 「캐시 표식」 이라고 뜬다.
|
|
346
|
+
*/
|
|
347
|
+
const 칸 = 카드?.표식칸 ?? 'cache_control';
|
|
348
|
+
if (표식쓰나 && 잡힐만한가(messages, 카드?.캐시최소 ?? 1024, { tools, system: 머리말 })) 대화 = 메시지표식(대화, 칸);
|
|
274
349
|
|
|
275
350
|
const body = { model, messages: 대화, stream: !!stream, max_tokens: maxTokens };
|
|
276
351
|
if (머리말.length) {
|
|
277
352
|
body.system = 표식쓰나
|
|
278
|
-
? 시스템블록(조각들 ?? 머리말, true)
|
|
353
|
+
? 시스템블록(조각들 ?? 머리말, true, 칸)
|
|
279
354
|
: 머리말.join('\n\n');
|
|
280
355
|
}
|
|
281
356
|
|
|
@@ -390,8 +465,25 @@ export function 캐시읽기(u) {
|
|
|
390
465
|
export function 보낸토큰(shape, u) {
|
|
391
466
|
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
|
|
392
467
|
const 들어온것 = n(u?.in);
|
|
393
|
-
|
|
394
|
-
|
|
468
|
+
const 캐시몫 = n(u?.cacheRead) + n(u?.cacheWrite);
|
|
469
|
+
if (shape === 'anthropic') return 들어온것 + 캐시몫;
|
|
470
|
+
/*
|
|
471
|
+
* OpenAI 규격은 `prompt_tokens` 가 **총계**라고 문서에 적혀 있다. 그러니
|
|
472
|
+
* 그대로 쓰면 된다 — 그 말을 지키는 창구에서는.
|
|
473
|
+
*
|
|
474
|
+
* 그런데 이 규격으로 말하는 창구가 이제 여럿이다(게이트웨이·프록시·
|
|
475
|
+
* 호환 창구). 그중 어딘가가 Anthropic 처럼 캐시를 빼고 셈해 준다면, 우리는
|
|
476
|
+
* 보낸 것을 **캐시 몫만큼 통째로 덜 세게 된다.** 그 값은 화면 숫자로만
|
|
477
|
+
* 끝나지 않는다 — session.배운다() 가 이걸로 토큰 추정 배수를 학습하므로,
|
|
478
|
+
* 덜 센 값이 배수를 아래로 끌고 가고, 그러면 남은 자리를 넉넉히 보고,
|
|
479
|
+
* 답 상한을 크게 잡고, 접기를 늦게 시작한다.
|
|
480
|
+
*
|
|
481
|
+
* 어느 쪽인지 창구마다 알아낼 방법은 없다. 대신 **셈으로 알 수 있는 것**이
|
|
482
|
+
* 하나 있다: 총계가 제 일부보다 작을 수는 없다. 그래서 바닥만 깐다.
|
|
483
|
+
* 문서를 지키는 창구에서는 값이 그대로고(총계 ≥ 캐시 몫), 안 지키는
|
|
484
|
+
* 창구에서는 최소한 캐시 몫만큼은 세어진다. 어느 쪽으로도 더 틀리지 않는다.
|
|
485
|
+
*/
|
|
486
|
+
return Math.max(들어온것, 캐시몫);
|
|
395
487
|
}
|
|
396
488
|
|
|
397
489
|
export function extractMessage(shape, json) {
|
|
@@ -426,6 +518,21 @@ export function extractMessage(shape, json) {
|
|
|
426
518
|
// 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
|
|
427
519
|
// 「토큰을 하나도 안 썼다」 로 뜬다.
|
|
428
520
|
usage: {
|
|
521
|
+
/*
|
|
522
|
+
* ── 「안 왔다」 는 「0」 이 아니다 ────────────────────────────
|
|
523
|
+
*
|
|
524
|
+
* usage 를 아예 안 주는 창구가 있다(호환 게이트웨이·프록시). 여기가
|
|
525
|
+
* `?? 0` 으로 메워 두고 있었고, 그러면 **유료로 부르고 나서**
|
|
526
|
+
* `/cost` 가 「입력 0 · 출력 0 · $0.00」 이라고 적는다. 사람은 그
|
|
527
|
+
* 숫자를 예산으로 잡는다.
|
|
528
|
+
*
|
|
529
|
+
* 이 프로그램의 집안 규칙이 「지어내지 않는다 — 모르면 모른다고
|
|
530
|
+
* 화면에 적는다」 이다. 0 은 지어낸 값이다.
|
|
531
|
+
*
|
|
532
|
+
* 그래서 값은 그대로 0 으로 두되(셈하는 쪽이 다 고치지 않아도 되게),
|
|
533
|
+
* **재긴 쟀나**를 따로 남긴다. 화면과 JSON 이 그것으로 가른다.
|
|
534
|
+
*/
|
|
535
|
+
잰것: json?.usage != null,
|
|
429
536
|
in: json?.usage?.input_tokens ?? 0,
|
|
430
537
|
out: json?.usage?.output_tokens ?? 0,
|
|
431
538
|
...(() => { const c = 캐시읽기(json?.usage); return { cacheRead: c.읽음, cacheWrite: c.씀 }; })(),
|
|
@@ -441,7 +548,11 @@ export function extractMessage(shape, json) {
|
|
|
441
548
|
content: m.content ?? '',
|
|
442
549
|
thinking: m.thinking ?? '',
|
|
443
550
|
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
444
|
-
usage: {
|
|
551
|
+
usage: {
|
|
552
|
+
잰것: json?.prompt_eval_count != null || json?.eval_count != null,
|
|
553
|
+
in: json?.prompt_eval_count ?? 0,
|
|
554
|
+
out: json?.eval_count ?? 0,
|
|
555
|
+
},
|
|
445
556
|
stopped: json?.done_reason ?? null,
|
|
446
557
|
};
|
|
447
558
|
}
|
|
@@ -479,6 +590,8 @@ export function extractMessage(shape, json) {
|
|
|
479
590
|
thinking: m.reasoning_content ?? '',
|
|
480
591
|
toolCalls: normalizeCalls(m.tool_calls ?? []),
|
|
481
592
|
usage: {
|
|
593
|
+
// 「안 왔다」 와 「0」 을 가른다 (위 anthropic 갈래의 머리말).
|
|
594
|
+
잰것: json?.usage != null,
|
|
482
595
|
in: json?.usage?.prompt_tokens ?? 0,
|
|
483
596
|
out: json?.usage?.completion_tokens ?? 0,
|
|
484
597
|
reasoning: 생각,
|
|
@@ -658,11 +771,68 @@ export function 도구결과인가(m) {
|
|
|
658
771
|
return m.role === 'tool';
|
|
659
772
|
}
|
|
660
773
|
|
|
774
|
+
/**
|
|
775
|
+
* 메시지에서 **사람이 읽을 글**만. 없으면 빈 글.
|
|
776
|
+
*
|
|
777
|
+
* 글이 어디 실리는지도 규격마다 다르다. OpenAI·Ollama 는 `content` 가 글
|
|
778
|
+
* 하나고, Anthropic 은 블록 배열이라 그 안의 `text` 조각만 글이다. 나머지
|
|
779
|
+
* 블록(tool_use·tool_result·생각)은 글이 아니다 — 섞어 뽑으면 화면에
|
|
780
|
+
* `[object Object]` 나 도구 인자 뭉치가 사람 말인 척 끼어든다.
|
|
781
|
+
*
|
|
782
|
+
* 이 규칙이 세 곳(acp/map.js·ui/export.js·agent/recall.js)에 따로 적혀
|
|
783
|
+
* 있었고 셋 다 OpenAI 꼴만 알았다. 그래서 Anthropic 으로 대화하면 ACP
|
|
784
|
+
* 화면·내보낸 보고서·`/recall` 에서 **모델이 한 말이 통째로 비었다.**
|
|
785
|
+
*/
|
|
786
|
+
export function 본문글(m) {
|
|
787
|
+
const c = m?.content;
|
|
788
|
+
if (typeof c === 'string') return c;
|
|
789
|
+
if (!Array.isArray(c)) return '';
|
|
790
|
+
return c
|
|
791
|
+
.map((b) => (typeof b === 'string' ? b : (b?.type === 'text' || b?.text != null ? String(b.text ?? '') : '')))
|
|
792
|
+
.filter(Boolean)
|
|
793
|
+
.join('\n');
|
|
794
|
+
}
|
|
795
|
+
|
|
661
796
|
/** 이 메시지가 도구를 불렀나. */
|
|
662
797
|
export function 도구불렀나(m) {
|
|
663
798
|
return 부른것들(m).length > 0;
|
|
664
799
|
}
|
|
665
800
|
|
|
801
|
+
/**
|
|
802
|
+
* 도구 결과의 **글만** 갈아 끼운 사본. 짝지어 주는 것(id)은 그대로 둔다.
|
|
803
|
+
*
|
|
804
|
+
* ── 왜 이 자가 있어야 하나 ────────────────────────────────────────────
|
|
805
|
+
*
|
|
806
|
+
* 접기(agent/compact.js)가 결과 내용을 짧은 한 줄로 바꾼다. 그때 여태
|
|
807
|
+
* `{ ...m, content: '접힘…' }` 로 **통째로** 덮어썼다. OpenAI 꼴에서는 그게
|
|
808
|
+
* 맞다 — 그 규격의 결과는 글 하나니까.
|
|
809
|
+
*
|
|
810
|
+
* Anthropic 꼴에서는 그 한 줄이 이력을 **깨뜨린다.** 그 규격의 결과는
|
|
811
|
+
* `[{ type:'tool_result', tool_use_id:'t3', content:'…' }]` 인데, 배열을 글로
|
|
812
|
+
* 덮으면 `tool_use_id` 가 통째로 없어진다. 앞 차례의 `tool_use` 는 그대로
|
|
813
|
+
* 남으므로 다음 요청이 `400` 이다 — "tool_use ids were found without
|
|
814
|
+
* tool_result blocks". 접힌 대화는 `/clear` 말고는 못 살린다.
|
|
815
|
+
*
|
|
816
|
+
* 전에는 이게 안 터졌다. 접는 쪽이 Anthropic 결과를 **찾지도 못해서** 아무
|
|
817
|
+
* 일도 안 했기 때문이다. 읽는 쪽을 고쳐 접기가 실제로 돌기 시작한 순간,
|
|
818
|
+
* 아무 일도 안 하던 자리가 망가뜨리는 자리가 됐다. 읽는 쪽만 고치고 쓰는
|
|
819
|
+
* 쪽을 안 고치면 이렇게 된다 — 그래서 이 자를 **읽는 자 바로 옆**에 둔다.
|
|
820
|
+
*/
|
|
821
|
+
export function 결과바꾸기(m, 새글) {
|
|
822
|
+
if (!m || typeof m !== 'object') return m;
|
|
823
|
+
if (Array.isArray(m.content)) {
|
|
824
|
+
let 바꿨나 = false;
|
|
825
|
+
const 새것 = m.content.map((b) => {
|
|
826
|
+
if (b?.type !== 'tool_result' || 바꿨나) return b;
|
|
827
|
+
바꿨나 = true; // 여럿이면 첫 자리에만 적고 나머지는 비운다
|
|
828
|
+
return { ...b, content: String(새글) };
|
|
829
|
+
}).map((b) => (b?.type === 'tool_result' && b.content !== String(새글) ? { ...b, content: '' } : b));
|
|
830
|
+
return 바꿨나 ? { ...m, content: 새것 } : m;
|
|
831
|
+
}
|
|
832
|
+
if (m.role !== 'tool') return m;
|
|
833
|
+
return { ...m, content: String(새글) };
|
|
834
|
+
}
|
|
835
|
+
|
|
666
836
|
export function toolMessage(shape, { callId, name, content }) {
|
|
667
837
|
// 이 규격에는 도구 차례가 없다. 도구 결과도 **사람 차례**로 돌려준다.
|
|
668
838
|
// role:'tool' 로 보내면 「모르는 역할」 이라고 거절당한다.
|
|
@@ -916,13 +1086,35 @@ export async function* chatStream(conn, opts) {
|
|
|
916
1086
|
|
|
917
1087
|
const acc = {
|
|
918
1088
|
content: '', thinking: '', toolCalls: [],
|
|
919
|
-
usage: { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1089
|
+
usage: { 잰것: false, in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
|
|
920
1090
|
stopped: null,
|
|
921
1091
|
};
|
|
922
1092
|
const reader = r.res.body.getReader();
|
|
923
1093
|
const dec = new TextDecoder();
|
|
924
1094
|
let buf = '';
|
|
925
1095
|
|
|
1096
|
+
/*
|
|
1097
|
+
* 한 줄을 읽는 규칙. 루프 안에서도, **끝난 뒤에도** 같은 것을 쓴다.
|
|
1098
|
+
*
|
|
1099
|
+
* 여태 이 규칙이 `while` 안에만 있었고, 스트림이 끝나면 남은 버퍼를 그냥
|
|
1100
|
+
* 버렸다. SSE 서버는 관례상 사건마다 개행을 붙이므로 보통은 남는 것이
|
|
1101
|
+
* 없다 — 하지만 그건 관례지 규격이 아니다. 마지막 JSON 뒤에 개행을 안
|
|
1102
|
+
* 붙이는 창구(Ollama NDJSON·일부 호환 서버)에서는 **마지막 내용·도구
|
|
1103
|
+
* 호출·usage·종료 사유가 통째로 버려진다.** 한 줄로 답하는 서버면 답이
|
|
1104
|
+
* 아예 비어서 온다.
|
|
1105
|
+
*
|
|
1106
|
+
* TextDecoder 도 마지막에 flush 해야 한다. 여러 바이트짜리 글자가 마지막
|
|
1107
|
+
* 조각에 걸쳐 있으면 그 글자가 사라진다 — 한국어에서 특히 잘 난다.
|
|
1108
|
+
*/
|
|
1109
|
+
const 한줄 = function* (line) {
|
|
1110
|
+
if (!line) return;
|
|
1111
|
+
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
|
1112
|
+
if (payload === '[DONE]') return;
|
|
1113
|
+
let obj;
|
|
1114
|
+
try { obj = JSON.parse(payload); } catch { return; }
|
|
1115
|
+
for (const ev of absorb(conn.kind, obj, acc)) yield ev;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
926
1118
|
while (true) {
|
|
927
1119
|
// 사용자가 끊었으면 흘러오는 것을 더 받지 않는다. 읽던 연결도 닫는다.
|
|
928
1120
|
if (opts.signal?.aborted) {
|
|
@@ -933,20 +1125,23 @@ export async function* chatStream(conn, opts) {
|
|
|
933
1125
|
let value;
|
|
934
1126
|
try { ({ done, value } = await reader.read()); }
|
|
935
1127
|
catch (err) { if (opts.signal?.aborted) throw new Aborted(); throw err; }
|
|
936
|
-
|
|
937
|
-
buf += dec.decode(value, { stream: true });
|
|
1128
|
+
// 끝났으면 decoder 에 걸쳐 있던 마지막 글자까지 뱉게 한다(flush).
|
|
1129
|
+
buf += done ? dec.decode() : dec.decode(value, { stream: true });
|
|
938
1130
|
|
|
939
1131
|
// OpenAI 는 SSE(data: ...), Ollama 는 줄바꿈 JSON. 둘 다 줄 단위로 처리된다.
|
|
940
1132
|
let nl;
|
|
941
1133
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
942
1134
|
const line = buf.slice(0, nl).trim();
|
|
943
1135
|
buf = buf.slice(nl + 1);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1136
|
+
yield* 한줄(line);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (done) {
|
|
1140
|
+
// 개행 없이 끝난 마지막 한 줄. 있으면 같은 길로 읽고 끝낸다.
|
|
1141
|
+
const 꼬리 = buf.trim();
|
|
1142
|
+
buf = '';
|
|
1143
|
+
yield* 한줄(꼬리);
|
|
1144
|
+
break;
|
|
950
1145
|
}
|
|
951
1146
|
}
|
|
952
1147
|
/*
|
|
@@ -995,7 +1190,7 @@ function absorb(shape, obj, acc) {
|
|
|
995
1190
|
if (m.content) { acc.content += m.content; out.push({ type: 'content', text: m.content }); }
|
|
996
1191
|
if (m.tool_calls?.length) acc.toolCalls.push(...normalizeCalls(m.tool_calls));
|
|
997
1192
|
if (obj.done) {
|
|
998
|
-
acc.usage = { in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
|
|
1193
|
+
acc.usage = { 잰것: true, in: obj.prompt_eval_count ?? 0, out: obj.eval_count ?? 0 };
|
|
999
1194
|
acc.stopped = obj.done_reason ?? 'stop';
|
|
1000
1195
|
}
|
|
1001
1196
|
return out;
|
|
@@ -1012,6 +1207,7 @@ function absorb(shape, obj, acc) {
|
|
|
1012
1207
|
if (obj.usage) {
|
|
1013
1208
|
const c = 캐시읽기(obj.usage);
|
|
1014
1209
|
acc.usage = {
|
|
1210
|
+
잰것: true,
|
|
1015
1211
|
in: obj.usage.prompt_tokens ?? 0,
|
|
1016
1212
|
out: obj.usage.completion_tokens ?? 0,
|
|
1017
1213
|
// 한 번에 받는 길과 같은 자리를 본다. 여기만 빠지면 흘려받을 때
|
package/src/backend/cachemark.js
CHANGED
|
@@ -44,6 +44,28 @@ import { estimateTokens } from './tokens.js';
|
|
|
44
44
|
/** 표식 한 개. 기본 수명(5분)을 쓴다 — 한 시간짜리는 쓰기 값이 두 배다. */
|
|
45
45
|
export const 표식 = { type: 'ephemeral' };
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 표식을 적는 **칸 이름**들.
|
|
49
|
+
*
|
|
50
|
+
* 같은 뜻을 두 규격이 다른 이름으로 적는다. 어느 쪽인지는 창구가 아니라
|
|
51
|
+
* **규격**이 정하므로, 여기서는 이름만 알고 고르는 것은 카드가 한다
|
|
52
|
+
* (backend/wire.js 의 표식칸).
|
|
53
|
+
*
|
|
54
|
+
* cache_control Anthropic 규격의 칸. Claude 를 앞에 둔 창구는
|
|
55
|
+
* 몸이 OpenAI 꼴이어도 이 칸으로 알아듣는다.
|
|
56
|
+
* prompt_cache_breakpoint OpenAI 규격의 칸. 규격 문서의 text 조각에
|
|
57
|
+
* 정식으로 들어 있다.
|
|
58
|
+
*
|
|
59
|
+
* 어느 것도 「업체별 예외」 가 아니다. 둘 다 문서에 있는 칸이고, 안 받는
|
|
60
|
+
* 서버는 400 으로 말해 준다 — 그러면 카드가 배운다(backend/wire.js).
|
|
61
|
+
*/
|
|
62
|
+
export const 표식칸들 = ['cache_control', 'prompt_cache_breakpoint'];
|
|
63
|
+
|
|
64
|
+
/** 그 칸에 적을 값. 칸마다 값의 모양이 다르다. */
|
|
65
|
+
const 표식값 = (칸) => (칸 === 'prompt_cache_breakpoint'
|
|
66
|
+
? { mode: 'explicit' }
|
|
67
|
+
: { ...표식 });
|
|
68
|
+
|
|
47
69
|
/*
|
|
48
70
|
* 시스템 글의 「굳은 부분 / 매 턴 바뀌는 부분」 을 나눠 담는 표.
|
|
49
71
|
*
|
|
@@ -74,7 +96,7 @@ const 닻거리 = 12;
|
|
|
74
96
|
* 메시지 하나의 **마지막 성한 블록**에 표식을 붙인 사본을 돌려준다.
|
|
75
97
|
* 붙일 자리가 없으면 원본 그대로.
|
|
76
98
|
*/
|
|
77
|
-
export function 블록에붙이기(msg) {
|
|
99
|
+
export function 블록에붙이기(msg, 칸 = 'cache_control') {
|
|
78
100
|
if (!msg) return msg;
|
|
79
101
|
const 본래 = Array.isArray(msg.content)
|
|
80
102
|
? msg.content
|
|
@@ -84,7 +106,7 @@ export function 블록에붙이기(msg) {
|
|
|
84
106
|
if (붙일수있나(본래[i])) { 자리 = i; break; }
|
|
85
107
|
}
|
|
86
108
|
if (자리 < 0) return msg;
|
|
87
|
-
const 새 = 본래.map((b, i) => (i === 자리 ? { ...b,
|
|
109
|
+
const 새 = 본래.map((b, i) => (i === 자리 ? { ...b, [칸]: 표식값(칸) } : b));
|
|
88
110
|
return { ...msg, content: 새 };
|
|
89
111
|
}
|
|
90
112
|
|
|
@@ -101,13 +123,13 @@ export function 블록에붙이기(msg) {
|
|
|
101
123
|
* @param {string[]} 조각들
|
|
102
124
|
* @param {boolean} 표식쓰나
|
|
103
125
|
*/
|
|
104
|
-
export function 시스템블록(조각들,
|
|
126
|
+
export function 시스템블록(조각들, 표식쓰나, 칸 = 'cache_control') {
|
|
105
127
|
const 있는것 = (조각들 ?? []).map((x) => String(x ?? '')).filter((x) => x.length);
|
|
106
128
|
if (!있는것.length) return null;
|
|
107
129
|
if (!표식쓰나) return 있는것.join('');
|
|
108
130
|
const 굳은끝 = 0; // 첫 조각이 굳은 부분이다
|
|
109
131
|
return 있는것.map((text, i) => (
|
|
110
|
-
i === 굳은끝 ? { type: 'text', text,
|
|
132
|
+
i === 굳은끝 ? { type: 'text', text, [칸]: 표식값(칸) } : { type: 'text', text }
|
|
111
133
|
));
|
|
112
134
|
}
|
|
113
135
|
|
|
@@ -117,13 +139,43 @@ export function 시스템블록(조각들, 표식쓰나) {
|
|
|
117
139
|
* @param {object[]} messages 차례합치기를 이미 거친 배열
|
|
118
140
|
* @returns {object[]}
|
|
119
141
|
*/
|
|
120
|
-
|
|
142
|
+
/**
|
|
143
|
+
* 그 자리에 표식을 **붙일 수 있나.** 없으면 한 칸씩 앞으로 물러선다.
|
|
144
|
+
*
|
|
145
|
+
* 도구만 부른 차례는 붙일 자리가 없다 — OpenAI 규격에서 그런 차례는
|
|
146
|
+
* `content: null` 이고(adapter.js 의 assistantMessage), 빈 글에는 표식을
|
|
147
|
+
* 못 붙인다(붙일수있나).
|
|
148
|
+
*
|
|
149
|
+
* 문제는 그때 `블록에붙이기` 가 **원본을 그대로 돌려준다**는 것이다. 아무
|
|
150
|
+
* 말도 안 하므로 그 요청은 닻 없이 나가고, 화면은 여전히 「캐시 표식」 이라고
|
|
151
|
+
* 적는다. 도구를 도는 턴은 `assistant(부름)` ↔ `tool(결과)` 이 번갈아 오므로
|
|
152
|
+
* **닻 자리가 절반 확률로 부름 차례에 떨어진다.** 하필 그 닻이 되돌아보기
|
|
153
|
+
* 창(스무 자리)을 넘기지 않으려고 박아 둔 것이라, 없어지면 앞이 통째로 다시 나간다.
|
|
154
|
+
*
|
|
155
|
+
* 그래서 못 붙이면 앞으로 물러서서 붙는 자리를 찾는다. 한 칸 앞이면 결과
|
|
156
|
+
* 차례라 거의 바로 걸린다.
|
|
157
|
+
*/
|
|
158
|
+
function 붙는자리(ms, 부터) {
|
|
159
|
+
for (let i = Math.min(부터, ms.length - 1); i >= 0; i--) {
|
|
160
|
+
const 그자리 = 블록에붙이기(ms[i], 'cache_control');
|
|
161
|
+
if (그자리 !== ms[i]) return i;
|
|
162
|
+
}
|
|
163
|
+
return -1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function 메시지표식(messages, 칸 = 'cache_control') {
|
|
121
167
|
const ms = messages ?? [];
|
|
122
168
|
if (!ms.length) return ms;
|
|
123
|
-
const 붙일자리 = new Set(
|
|
169
|
+
const 붙일자리 = new Set();
|
|
170
|
+
const 꼬리 = 붙는자리(ms, ms.length - 1);
|
|
171
|
+
if (꼬리 >= 0) 붙일자리.add(꼬리);
|
|
124
172
|
// 도구를 길게 늘어놓는 턴에서 되돌아보기 창을 넘기지 않게 닻을 하나 더.
|
|
125
|
-
if (ms.length > 닻문턱)
|
|
126
|
-
|
|
173
|
+
if (ms.length > 닻문턱) {
|
|
174
|
+
const 닻 = 붙는자리(ms, ms.length - 1 - 닻거리);
|
|
175
|
+
// 꼬리와 같은 자리면 닻이 아니다 — 그건 표식 하나짜리다.
|
|
176
|
+
if (닻 >= 0 && 닻 !== 꼬리) 붙일자리.add(닻);
|
|
177
|
+
}
|
|
178
|
+
return ms.map((m, i) => (붙일자리.has(i) ? 블록에붙이기(m, 칸) : m));
|
|
127
179
|
}
|
|
128
180
|
|
|
129
181
|
/**
|
|
@@ -140,8 +192,29 @@ export function 메시지표식(messages) {
|
|
|
140
192
|
* `/status` 가 「캐시 표식」 이라고 적는다 — 이 함수가 막겠다고 적어 둔 바로
|
|
141
193
|
* 그 거짓말이다.
|
|
142
194
|
*/
|
|
143
|
-
export function 잡힐만한가(messages, 최소토큰 = 1024) {
|
|
195
|
+
export function 잡힐만한가(messages, 최소토큰 = 1024, 곁 = null) {
|
|
144
196
|
let 토큰 = 0;
|
|
197
|
+
/*
|
|
198
|
+
* 도구 정의도 **앞머리에 나가는 것**이다.
|
|
199
|
+
*
|
|
200
|
+
* 이 자가 메시지만 세고 있었다. 그런데 전선에 실릴 때 순서는
|
|
201
|
+
* `tools → system → messages` 라, 도구 스키마가 캐시 앞머리의 **맨 앞**
|
|
202
|
+
* 이다. 내장 도구 스무 개의 스키마는 1,800토큰쯤 된다.
|
|
203
|
+
*
|
|
204
|
+
* 그래서 시킴말과 사람말이 합쳐 500토큰인 판에서 이 자가 「문턱을 못
|
|
205
|
+
* 넘는다」 고 답했고, 표식을 하나도 안 붙였다. 실제로는 2,300토큰이라
|
|
206
|
+
* 넘고도 남는다. **덜 세서 캐시를 안 켠 것**인데, 화면에는 아무 말도
|
|
207
|
+
* 안 난다 — 그냥 매 턴 값이 더 나온다.
|
|
208
|
+
*
|
|
209
|
+
* 세는 것과 보내는 것이 다르면, 세는 쪽이 틀린 것이다.
|
|
210
|
+
*/
|
|
211
|
+
if (곁) {
|
|
212
|
+
for (const 것 of [곁.tools, 곁.system]) {
|
|
213
|
+
if (것 == null) continue;
|
|
214
|
+
토큰 += estimateTokens(typeof 것 === 'string' ? 것 : JSON.stringify(것));
|
|
215
|
+
if (토큰 > 최소토큰) return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
145
218
|
for (const m of messages ?? []) {
|
|
146
219
|
토큰 += estimateTokens(typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content ?? ''));
|
|
147
220
|
if (토큰 > 최소토큰) return true;
|