deel-local-cli 1.6.2 → 1.7.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.en.md +115 -23
- package/README.md +114 -24
- package/bin/deel.js +21 -3
- package/package.json +2 -2
- package/src/acp/serve.js +25 -2
- package/src/agent/loop.js +68 -5
- package/src/backend/adapter.js +211 -4
- package/src/backend/ctxsize.js +16 -2
- package/src/backend/detect.js +104 -6
- package/src/backend/price.js +197 -0
- package/src/backend/scanui.js +2 -1
- package/src/backend/vision.js +26 -4
- package/src/commands.js +80 -6
- package/src/completion.js +1 -0
- package/src/i18n/en.js +6 -0
- package/src/i18n/ja.js +6 -0
- package/src/i18n/ko.js +6 -0
- package/src/i18n/zh.js +6 -0
- package/src/oneshot.js +28 -4
- package/src/providers/anthropic.js +48 -0
- package/src/providers/bedrock.js +102 -0
- package/src/providers/custom.js +65 -0
- package/src/providers/gemini.js +64 -0
- package/src/providers/index.js +121 -0
- package/src/providers/openai.js +68 -0
- package/src/repl.js +118 -7
- package/src/report.js +3 -2
- package/src/safety/runmode.js +123 -0
- package/src/safety/secrets.js +61 -0
- package/src/setup.js +215 -12
- package/src/tools/index.js +46 -2
- package/src/tools/todo.js +28 -0
- package/src/ui/histline.js +67 -0
- package/src/ui/status.js +88 -3
package/src/agent/loop.js
CHANGED
|
@@ -14,7 +14,8 @@ import { Session } from './session.js';
|
|
|
14
14
|
import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
|
|
15
15
|
import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
|
|
16
16
|
import { allowTemporarily, isOffline } from '../safety/network.js';
|
|
17
|
-
import { 가리기, 훑기, 가렸다는말, 봤다는말,
|
|
17
|
+
import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴까 } from '../safety/secrets.js';
|
|
18
|
+
import { 바깥인가 } from '../safety/runmode.js';
|
|
18
19
|
import { get as workMode } from './modes.js';
|
|
19
20
|
import { 지시말 } from '../i18n/index.js';
|
|
20
21
|
import { 빠진것, 빠졌다는말 } from './asks.js';
|
|
@@ -171,6 +172,23 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
171
172
|
|
|
172
173
|
const conn = session.conn;
|
|
173
174
|
|
|
175
|
+
/*
|
|
176
|
+
* ── 바깥 모델에게는 파일 속 비밀도 가린다 ───────────────────────────
|
|
177
|
+
*
|
|
178
|
+
* safety/secrets.js 는 「파일 내용은 안 가린다」 로 정해 두었다. 가리면
|
|
179
|
+
* 모델이 가려진 글을 보고 그대로 되돌려 써서 **사람의 열쇠가 지워지기**
|
|
180
|
+
* 때문이다. 로컬 모델만 쓰던 동안에는 그 손해가 이득보다 컸다 — 어차피
|
|
181
|
+
* 이 컴퓨터 밖으로 안 나가니까.
|
|
182
|
+
*
|
|
183
|
+
* 바깥으로 나가는 순간 저울이 뒤집힌다. Read 한 번이면 `.env` 가 통째로
|
|
184
|
+
* 남의 서버 로그에 남는다. 그건 되돌릴 수가 없다.
|
|
185
|
+
*
|
|
186
|
+
* 되돌려 쓰는 사고는 이제 다른 자리에서 막는다 — Write·Append·Edit 이
|
|
187
|
+
* 가린 표를 파일에 못 쓴다(tools/index.js 의 가린표되돌리나). 그 자물쇠가
|
|
188
|
+
* 생겼기 때문에 여기서 가릴 수 있게 된 것이다. 순서가 중요하다.
|
|
189
|
+
*/
|
|
190
|
+
const 바깥으로나감 = 바깥인가(conn.base);
|
|
191
|
+
|
|
174
192
|
/**
|
|
175
193
|
* 같은 실패가 되풀이되는지 센다.
|
|
176
194
|
*
|
|
@@ -247,6 +265,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
247
265
|
* 결과가 달라졌으면 그대로 싣는다. 고치고 나서 다시 읽는 것은 정상이다.
|
|
248
266
|
*/
|
|
249
267
|
const 부른것 = new Map();
|
|
268
|
+
/*
|
|
269
|
+
* 그 부름을 마지막으로 봤을 때, 이 턴이 파일을 몇 개나 손댔었나.
|
|
270
|
+
*
|
|
271
|
+
* 되풀이를 셀 때 이 값과 견준다. 그 사이에 파일이 늘었으면 일이 되고
|
|
272
|
+
* 있는 것이라 되풀이 셈을 처음부터 다시 센다 — 까닭은 세는 자리에.
|
|
273
|
+
*/
|
|
274
|
+
const 반복진척 = new Map();
|
|
250
275
|
/*
|
|
251
276
|
* 서명은 한 모양으로 맞춰 놓고 견준다 (NFC).
|
|
252
277
|
*
|
|
@@ -1139,6 +1164,14 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1139
1164
|
// 한 번에 여러 개를 쓴 경우. changed 하나만 보면 나머지가 조용히 빠져서,
|
|
1140
1165
|
// 턴 끝에 "만들어졌다" 고 확인해 주는 파일이 넷 중 하나만 나온다.
|
|
1141
1166
|
for (const f of result.여럿 ?? []) if (f.ok && f.path) 손댄파일.add(f.path);
|
|
1167
|
+
/*
|
|
1168
|
+
* 옮긴 것도 손댄 것이다.
|
|
1169
|
+
*
|
|
1170
|
+
* Move 는 파일을 고치는 게 아니라 자리를 바꾸므로 changed·여럿 이
|
|
1171
|
+
* 아니라 바뀐것들 로 온다. 이걸 안 세면 스물두 개를 옮겨 놓고도
|
|
1172
|
+
* 이 턴은 "아무것도 안 했다" 고 알고 있게 된다.
|
|
1173
|
+
*/
|
|
1174
|
+
for (const f of result.바뀐것들 ?? []) 손댄파일.add(f);
|
|
1142
1175
|
|
|
1143
1176
|
// 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
|
|
1144
1177
|
let 실을것 = 실을글(result);
|
|
@@ -1150,13 +1183,40 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1150
1183
|
const 앞것 = 부른것.get(서명);
|
|
1151
1184
|
if (앞것 !== undefined && 앞것 === 같은꼴) {
|
|
1152
1185
|
session.본것?.본것('되풀이');
|
|
1153
|
-
|
|
1186
|
+
/*
|
|
1187
|
+
* ── 되풀이와 「일하면서 장부를 다시 쓴 것」 은 다르다 ──────────
|
|
1188
|
+
*
|
|
1189
|
+
* 「폴더 정리 해줘」 에서 이 자리가 사람의 턴을 죽였다. 화면은
|
|
1190
|
+
* 이랬다.
|
|
1191
|
+
*
|
|
1192
|
+
* Move 7개 옮김 → TodoWrite → Move 22개 옮김 → TodoWrite
|
|
1193
|
+
* → ⊘ 같은 자리에서 헛돌고 있어 멈췄습니다
|
|
1194
|
+
*
|
|
1195
|
+
* 파일은 스물아홉 개가 실제로 움직이고 있었다. 헛돈 것은 할 일
|
|
1196
|
+
* 목록을 같은 값으로 다시 쓴 것뿐이다. 그런데 세는 자리는 그
|
|
1197
|
+
* 사이에 무슨 일이 있었는지를 안 봤다 — 같은 부름이 세 번이면
|
|
1198
|
+
* 그것으로 끝이었다.
|
|
1199
|
+
*
|
|
1200
|
+
* 이 파일 머리말에 스스로 적어 둔 원칙이 있다: 헛도는 것은
|
|
1201
|
+
* '잘 되고 있는 긴 작업' 과 구분해야 한다. 그 구분을 여기에
|
|
1202
|
+
* 넣는다 — **그 사이에 파일이 진짜로 바뀌었으면 처음부터 다시
|
|
1203
|
+
* 센다.** 아무것도 안 바뀐 채로 되풀이하는 것만 헛도는 것이다.
|
|
1204
|
+
*
|
|
1205
|
+
* 이러면 끝없이 돌 수 있지 않나: 아니다. 손댄파일 은 서로 다른
|
|
1206
|
+
* 파일만 세므로, 같은 자리를 맴돌면 안 늘어난다. 늘어난다는 것은
|
|
1207
|
+
* 매번 새 파일이 움직였다는 뜻이고, 그건 일이 되고 있는 것이다.
|
|
1208
|
+
* 그래도 걸음 수 상한이 마지막 울타리로 남아 있다.
|
|
1209
|
+
*/
|
|
1210
|
+
const 그새한일 = 손댄파일.size > (반복진척.get(서명) ?? 0);
|
|
1211
|
+
const n = 그새한일 ? 1 : (막힘.get(`반복|${서명}`) ?? 0) + 1;
|
|
1154
1212
|
막힘.set(`반복|${서명}`, n);
|
|
1213
|
+
반복진척.set(서명, 손댄파일.size);
|
|
1155
1214
|
실을것 = `앞에서 부른 ${call.name} 과 인자도 결과도 같습니다. 결과는 그대로라 다시 싣지 않습니다.`
|
|
1156
1215
|
+ ' 같은 것을 또 부르지 말고 다음 일로 넘어가세요.';
|
|
1157
1216
|
if (n + 1 >= MAX_SAME) 멈출까 = `${call.name} 을 같은 인자로 계속 부르고 있습니다 — 결과가 매번 같습니다`;
|
|
1158
1217
|
} else {
|
|
1159
1218
|
부른것.set(서명, 같은꼴);
|
|
1219
|
+
반복진척.set(서명, 손댄파일.size);
|
|
1160
1220
|
}
|
|
1161
1221
|
}
|
|
1162
1222
|
|
|
@@ -1176,7 +1236,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1176
1236
|
*/
|
|
1177
1237
|
const 아는열쇠들 = [conn.key, process.env.DEEL_API_KEY].filter(Boolean);
|
|
1178
1238
|
let 비밀 = [];
|
|
1179
|
-
|
|
1239
|
+
// 바깥으로 나가면 파일에서 읽어 온 글까지 가린다 (safety/secrets.js 의 가릴까).
|
|
1240
|
+
const 이번엔가릴까 = 가릴까(call.name, { 바깥: 바깥으로나감 });
|
|
1241
|
+
if (이번엔가릴까) {
|
|
1180
1242
|
const 가린 = 가리기(실을것, { 열쇠들: 아는열쇠들 });
|
|
1181
1243
|
if (가린.가린것.length) {
|
|
1182
1244
|
비밀 = 가린.가린것;
|
|
@@ -1189,7 +1251,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1189
1251
|
if (비밀.length) {
|
|
1190
1252
|
ctx.audit?.write?.('secret', {
|
|
1191
1253
|
tool: call.name,
|
|
1192
|
-
가렸나:
|
|
1254
|
+
가렸나: 이번엔가릴까,
|
|
1255
|
+
바깥: 바깥으로나감,
|
|
1193
1256
|
무엇: 비밀.map((x) => `${x.종류}×${x.몇번}`).join(' '),
|
|
1194
1257
|
});
|
|
1195
1258
|
}
|
|
@@ -1207,7 +1270,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
|
|
|
1207
1270
|
result,
|
|
1208
1271
|
ms,
|
|
1209
1272
|
parallel: 함께,
|
|
1210
|
-
...(비밀.length ? { 비밀: { 가렸나:
|
|
1273
|
+
...(비밀.length ? { 비밀: { 가렸나: 이번엔가릴까, 말: 봤다는말(비밀) } } : {}),
|
|
1211
1274
|
};
|
|
1212
1275
|
}
|
|
1213
1276
|
}
|
package/src/backend/adapter.js
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
|
-
// 규격 차이(OpenAI 호환 / Ollama)를 여기 한 곳에서만 흡수한다.
|
|
1
|
+
// 규격 차이(OpenAI 호환 / Ollama / Anthropic)를 여기 한 곳에서만 흡수한다.
|
|
2
2
|
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
3
|
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
4
4
|
import { 할당량기억 } from './quota.js';
|
|
5
5
|
import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
|
|
6
6
|
|
|
7
|
+
/*
|
|
8
|
+
* Anthropic 규격의 판 이름.
|
|
9
|
+
*
|
|
10
|
+
* 이 머리 하나가 없으면 400 이다. 열쇠가 멀쩡해도 그렇다. 날짜처럼 생겼지만
|
|
11
|
+
* 「오늘 날짜」 가 아니라 **규격 판 이름**이라, 새 날짜를 넣는다고 새 기능이
|
|
12
|
+
* 켜지지 않는다. 회사가 새 판을 내놓기 전에는 이 값이 바뀔 일이 없다.
|
|
13
|
+
*/
|
|
14
|
+
export const ANTHROPIC_VERSION = '2023-06-01';
|
|
15
|
+
|
|
7
16
|
export function endpoint(shape) {
|
|
8
|
-
|
|
17
|
+
if (shape === 'ollama') return '/api/chat';
|
|
18
|
+
if (shape === 'anthropic') return '/messages';
|
|
19
|
+
return '/chat/completions';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 이 규격이 더 요구하는 머리. 없으면 빈 것. */
|
|
23
|
+
export function 더할머리(shape) {
|
|
24
|
+
return shape === 'anthropic' ? { 'anthropic-version': ANTHROPIC_VERSION } : {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 이 규격을 사람에게 뭐라고 부를까.
|
|
29
|
+
*
|
|
30
|
+
* 한 곳에 모아 둔다. 화면 여러 자리가 각자 `kind === 'ollama' ? … : 'OpenAI
|
|
31
|
+
* 호환'` 로 갈라 놓고 있었는데, 규격이 셋이 되는 순간 그 자리들이 전부
|
|
32
|
+
* Anthropic 연결을 **「OpenAI 호환」 이라고 잘못 적는다.** 붙는 데는 아무
|
|
33
|
+
* 지장이 없어서 아무도 안 고치고, 그 화면을 믿고 남에게 설명하게 된다.
|
|
34
|
+
*/
|
|
35
|
+
export function 규격이름(shape) {
|
|
36
|
+
if (shape === 'ollama') return 'Ollama 자체 규격';
|
|
37
|
+
if (shape === 'anthropic') return 'Anthropic 규격';
|
|
38
|
+
return 'OpenAI 호환';
|
|
9
39
|
}
|
|
10
40
|
|
|
11
41
|
/**
|
|
@@ -60,6 +90,9 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
60
90
|
if (think !== undefined) body.think = think;
|
|
61
91
|
return body;
|
|
62
92
|
}
|
|
93
|
+
if (shape === 'anthropic') return anthropic몸(
|
|
94
|
+
{ model, messages, tools, stream, json, think, maxTokens },
|
|
95
|
+
);
|
|
63
96
|
// 출력 상한을 **두 이름으로 같이** 보낸다.
|
|
64
97
|
//
|
|
65
98
|
// 옛 규격은 max_tokens 하나였다. 그런데 GPT-5 계열을 붙여 놓은 게이트웨이는
|
|
@@ -78,7 +111,90 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
78
111
|
return body;
|
|
79
112
|
}
|
|
80
113
|
|
|
114
|
+
/*
|
|
115
|
+
* ── Anthropic 규격은 어디가 다른가 ─────────────────────────────────────
|
|
116
|
+
*
|
|
117
|
+
* OpenAI 호환 서버는 문 이름(/chat/completions)만 같으면 대충 통했다. 이쪽은
|
|
118
|
+
* 통하지 않는 자리가 넷이다. 그래서 여기 한 곳에 몰아 둔다.
|
|
119
|
+
*
|
|
120
|
+
* 1. 시킴말(system)이 messages 안에 못 들어간다. 몸의 딴 칸이다.
|
|
121
|
+
* 2. 차례가 사람·모델로 **번갈아** 와야 한다. 도구를 한 턴에 둘 부르면
|
|
122
|
+
* 결과가 둘인데, 그대로 보내면 거절당한다.
|
|
123
|
+
* 3. 도구 모양이 { name, description, input_schema } 다.
|
|
124
|
+
* OpenAI 의 { type:'function', function:{...} } 가 아니다.
|
|
125
|
+
* 4. 답이 글 한 덩어리가 아니라 **블록 배열**이다.
|
|
126
|
+
*
|
|
127
|
+
* 안 보내는 것도 적어 둔다. 확인 못 한 것은 안 보낸다는 뜻이지, 없다는 뜻이
|
|
128
|
+
* 아니다 — 짐작으로 보낸 칸 하나가 400 을 만들면 열쇠가 틀린 줄 알게 된다.
|
|
129
|
+
*
|
|
130
|
+
* · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
|
|
131
|
+
* · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
|
|
132
|
+
*/
|
|
133
|
+
function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }) {
|
|
134
|
+
const 머리말 = [];
|
|
135
|
+
const 나머지 = [];
|
|
136
|
+
for (const m of messages ?? []) {
|
|
137
|
+
if (m?.role === 'system') 머리말.push(typeof m.content === 'string' ? m.content : String(m.content ?? ''));
|
|
138
|
+
else 나머지.push(m);
|
|
139
|
+
}
|
|
140
|
+
const body = { model, messages: 차례합치기(나머지), stream: !!stream, max_tokens: maxTokens };
|
|
141
|
+
if (머리말.length) body.system = 머리말.join('\n\n');
|
|
142
|
+
if (tools?.length) {
|
|
143
|
+
body.tools = tools.map((t) => {
|
|
144
|
+
const f = t.function ?? t;
|
|
145
|
+
return {
|
|
146
|
+
name: f.name,
|
|
147
|
+
description: f.description ?? '',
|
|
148
|
+
input_schema: f.parameters ?? f.input_schema ?? { type: 'object', properties: {} },
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
body.tool_choice = { type: 'auto' };
|
|
152
|
+
}
|
|
153
|
+
return body;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 같은 차례가 연달아 오면 하나로 합친다.
|
|
158
|
+
*
|
|
159
|
+
* 도구를 한 턴에 둘 부르면 결과 메시지가 둘이고, 이 규격에서 그 둘은 다
|
|
160
|
+
* 사람 차례다. 번갈아 오지 않으면 서버가 통째로 거절한다 — 그러면 도구를
|
|
161
|
+
* 하나만 부를 때는 되고 둘 부를 때만 안 되는, 제일 알아내기 어려운 고장이 된다.
|
|
162
|
+
*/
|
|
163
|
+
export function 차례합치기(messages) {
|
|
164
|
+
const 덩이 = (c) => (Array.isArray(c) ? c : [{ type: 'text', text: String(c ?? '') }]);
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const m of messages ?? []) {
|
|
167
|
+
const 앞 = out[out.length - 1];
|
|
168
|
+
if (앞 && 앞.role === m?.role) {
|
|
169
|
+
앞.content = [...덩이(앞.content), ...덩이(m.content)];
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
out.push({ ...m });
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
81
177
|
export function extractMessage(shape, json) {
|
|
178
|
+
if (shape === 'anthropic') {
|
|
179
|
+
// 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
|
|
180
|
+
let content = '';
|
|
181
|
+
let thinking = '';
|
|
182
|
+
const 부름들 = [];
|
|
183
|
+
for (const b of Array.isArray(json?.content) ? json.content : []) {
|
|
184
|
+
if (b?.type === 'text') content += b.text ?? '';
|
|
185
|
+
else if (b?.type === 'thinking') thinking += b.thinking ?? '';
|
|
186
|
+
else if (b?.type === 'tool_use') 부름들.push({ id: b.id, name: b.name, args: b.input ?? {} });
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
content,
|
|
190
|
+
thinking,
|
|
191
|
+
toolCalls: normalizeCalls(부름들),
|
|
192
|
+
// 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
|
|
193
|
+
// 「토큰을 하나도 안 썼다」 로 뜬다.
|
|
194
|
+
usage: { in: json?.usage?.input_tokens ?? 0, out: json?.usage?.output_tokens ?? 0 },
|
|
195
|
+
stopped: json?.stop_reason ?? null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
82
198
|
if (shape === 'ollama') {
|
|
83
199
|
const m = json?.message ?? {};
|
|
84
200
|
return {
|
|
@@ -141,6 +257,23 @@ export function normalizeCalls(list) {
|
|
|
141
257
|
|
|
142
258
|
// 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
|
|
143
259
|
export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
|
|
260
|
+
if (shape === 'anthropic') {
|
|
261
|
+
const 블록 = [];
|
|
262
|
+
if (content) 블록.push({ type: 'text', text: content });
|
|
263
|
+
for (const t of toolCalls) 블록.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args ?? {} });
|
|
264
|
+
/*
|
|
265
|
+
* 생각은 **안 돌려보낸다.**
|
|
266
|
+
*
|
|
267
|
+
* 이 규격의 생각 블록에는 서명(signature)이 딸려 있고, 서명 없이 돌려보내면
|
|
268
|
+
* 서버가 거절한다. 흘려받기에서 서명 조각을 제대로 모으는 것까지 확인하지
|
|
269
|
+
* 못했으므로 여기서는 뺀다. 화면에는 그대로 흘러가고, 대화 이력에만 안 남는다.
|
|
270
|
+
*
|
|
271
|
+
* 블록이 하나도 없으면 이 규격은 거절한다. 빈 답이 오는 일은 드물지만
|
|
272
|
+
* 그때 대화 전체가 죽으면 안 되니 자리표시를 하나 넣는다.
|
|
273
|
+
*/
|
|
274
|
+
if (!블록.length) 블록.push({ type: 'text', text: '(빈 답)' });
|
|
275
|
+
return { role: 'assistant', content: 블록 };
|
|
276
|
+
}
|
|
144
277
|
if (shape === 'ollama') {
|
|
145
278
|
const m = { role: 'assistant', content };
|
|
146
279
|
if (thinking) m.thinking = thinking;
|
|
@@ -158,6 +291,11 @@ export function assistantMessage(shape, { content = '', thinking = '', toolCalls
|
|
|
158
291
|
}
|
|
159
292
|
|
|
160
293
|
export function toolMessage(shape, { callId, name, content }) {
|
|
294
|
+
// 이 규격에는 도구 차례가 없다. 도구 결과도 **사람 차례**로 돌려준다.
|
|
295
|
+
// role:'tool' 로 보내면 「모르는 역할」 이라고 거절당한다.
|
|
296
|
+
if (shape === 'anthropic') {
|
|
297
|
+
return { role: 'user', content: [{ type: 'tool_result', tool_use_id: callId, content: String(content) }] };
|
|
298
|
+
}
|
|
161
299
|
return shape === 'ollama'
|
|
162
300
|
? { role: 'tool', tool_name: name, content: String(content) }
|
|
163
301
|
: { role: 'tool', tool_call_id: callId, content: String(content) };
|
|
@@ -170,7 +308,7 @@ export async function chat(conn, opts) {
|
|
|
170
308
|
for (let 시도 = 1; ; 시도++) {
|
|
171
309
|
const r = await req(요청주소(conn), {
|
|
172
310
|
method: 'POST',
|
|
173
|
-
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
311
|
+
headers: headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind)),
|
|
174
312
|
body,
|
|
175
313
|
timeout: opts.timeout ?? 300000,
|
|
176
314
|
signal: opts.signal ?? null,
|
|
@@ -244,7 +382,7 @@ export async function* chatStream(conn, opts) {
|
|
|
244
382
|
for (let 시도 = 1; ; 시도++) {
|
|
245
383
|
r = await req(요청주소(conn), {
|
|
246
384
|
method: 'POST',
|
|
247
|
-
headers: headersFor(conn.auth, conn.key ?? ''),
|
|
385
|
+
headers: headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind)),
|
|
248
386
|
body,
|
|
249
387
|
timeout: opts.timeout ?? 300000,
|
|
250
388
|
stream: true,
|
|
@@ -299,6 +437,7 @@ export async function* chatStream(conn, opts) {
|
|
|
299
437
|
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
300
438
|
function absorb(shape, obj, acc) {
|
|
301
439
|
const out = [];
|
|
440
|
+
if (shape === 'anthropic') return anthropic흡수(obj, acc, out);
|
|
302
441
|
if (shape === 'ollama') {
|
|
303
442
|
const m = obj.message ?? {};
|
|
304
443
|
if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
|
|
@@ -320,6 +459,68 @@ function absorb(shape, obj, acc) {
|
|
|
320
459
|
return out;
|
|
321
460
|
}
|
|
322
461
|
|
|
462
|
+
/*
|
|
463
|
+
* Anthropic 흘려받기.
|
|
464
|
+
*
|
|
465
|
+
* OpenAI 는 조각마다 같은 모양(choices[0].delta)이 오는데, 이쪽은 **사건 이름**
|
|
466
|
+
* 으로 나뉜다. 하나씩 다르게 읽어야 한다.
|
|
467
|
+
*
|
|
468
|
+
* message_start — 시작. 여기 입력 토큰 수가 들어 있다.
|
|
469
|
+
* content_block_start — 블록 하나 시작. 도구 부름이면 이름과 번호가 여기 있다.
|
|
470
|
+
* content_block_delta — 알맹이 조각. 글·생각·도구 인자가 각각 딴 이름으로 온다.
|
|
471
|
+
* message_delta — 끝난 까닭과 출력 토큰 수.
|
|
472
|
+
* message_stop — 끝.
|
|
473
|
+
*
|
|
474
|
+
* 도구 인자는 글자 단위로 쪼개져 오므로 번호별로 이어 붙인다. 이 자리는
|
|
475
|
+
* OpenAI 쪽과 같아서 마무리(도구마무리)를 같이 쓴다.
|
|
476
|
+
*/
|
|
477
|
+
function anthropic흡수(obj, acc, out) {
|
|
478
|
+
const 종류 = obj?.type;
|
|
479
|
+
const 번호 = obj?.index ?? 0;
|
|
480
|
+
if (종류 === 'message_start') {
|
|
481
|
+
const u = obj.message?.usage;
|
|
482
|
+
if (u) acc.usage = { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0 };
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
if (종류 === 'content_block_start') {
|
|
486
|
+
const b = obj.content_block ?? {};
|
|
487
|
+
if (b.type === 'tool_use') {
|
|
488
|
+
acc._raw ??= [];
|
|
489
|
+
acc._raw[번호] = { id: b.id, name: b.name ?? '', args: '' };
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
if (종류 === 'content_block_delta') {
|
|
494
|
+
const d = obj.delta ?? {};
|
|
495
|
+
if (d.type === 'text_delta' && d.text) {
|
|
496
|
+
acc.content += d.text;
|
|
497
|
+
out.push({ type: 'content', text: d.text });
|
|
498
|
+
} else if (d.type === 'thinking_delta' && d.thinking) {
|
|
499
|
+
acc.thinking += d.thinking;
|
|
500
|
+
out.push({ type: 'thinking', text: d.thinking });
|
|
501
|
+
} else if (d.type === 'input_json_delta' && d.partial_json != null) {
|
|
502
|
+
acc._raw ??= [];
|
|
503
|
+
acc._raw[번호] ??= { id: null, name: '', args: '' };
|
|
504
|
+
acc._raw[번호].args += d.partial_json;
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
if (종류 === 'message_delta') {
|
|
509
|
+
/*
|
|
510
|
+
* 출력 토큰은 **덮어쓴다.** 조각마다의 양이 아니라 여태 누적 총계라서,
|
|
511
|
+
* 더하면 조각 수만큼 부풀어 오른다. 입력 쪽은 해당 없으면 아예 안 오므로
|
|
512
|
+
* 왔을 때만 덮는다 — 없는 것을 0 으로 덮으면 message_start 에서 받아 둔
|
|
513
|
+
* 진짜 값이 지워진다.
|
|
514
|
+
*/
|
|
515
|
+
if (obj.usage?.output_tokens != null) acc.usage.out = obj.usage.output_tokens;
|
|
516
|
+
if (obj.usage?.input_tokens != null) acc.usage.in = obj.usage.input_tokens;
|
|
517
|
+
if (obj.delta?.stop_reason) acc.stopped = obj.delta.stop_reason;
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
if (종류 === 'content_block_stop' || 종류 === 'message_stop') 도구마무리(acc);
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
323
524
|
// OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
|
|
324
525
|
function mergeDeltaCalls(acc, deltas) {
|
|
325
526
|
acc._raw ??= [];
|
|
@@ -330,6 +531,12 @@ function mergeDeltaCalls(acc, deltas) {
|
|
|
330
531
|
if (d.function?.name) acc._raw[i].name += d.function.name;
|
|
331
532
|
if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
|
|
332
533
|
}
|
|
534
|
+
도구마무리(acc);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 글자로 쪼개져 온 도구 인자를 하나로 읽는다. 두 규격이 같이 쓴다.
|
|
538
|
+
function 도구마무리(acc) {
|
|
539
|
+
if (!acc._raw) return;
|
|
333
540
|
// 인자가 안 읽히면 읽혔다고 치지 않는다 — normalizeCalls 머리말 참고.
|
|
334
541
|
// 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
|
|
335
542
|
acc.toolCalls = acc._raw.filter(Boolean).map((c, i) => {
|
package/src/backend/ctxsize.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// 따로 알려 준다 — 사용자가 서버에서 더 올린 다음 /ctx 로 맞출 수 있게.
|
|
29
29
|
import { req, headersFor } from './http.js';
|
|
30
30
|
import { 애저인가 } from './azure.js';
|
|
31
|
+
import { 더할머리 } from './adapter.js';
|
|
31
32
|
|
|
32
33
|
// 이 이름들 중 하나면 '모델이 낼 수 있는 최대 컨텍스트' 다. 순서가 곧 우선순위다.
|
|
33
34
|
const 최대이름 = [
|
|
@@ -156,7 +157,7 @@ function 목록에서찾기(json, model) {
|
|
|
156
157
|
* @returns {Promise<{value, max, loaded, out, source, outSource, tried, why}>}
|
|
157
158
|
*/
|
|
158
159
|
export async function probeCtx(conn, { timeout = 6000 } = {}) {
|
|
159
|
-
const H = () => headersFor(conn.auth, conn.key);
|
|
160
|
+
const H = () => headersFor(conn.auth, conn.key, 더할머리(conn.kind));
|
|
160
161
|
const base = String(conn.base ?? '').replace(/\/+$/, '');
|
|
161
162
|
const origin = base.replace(/\/v\d+$/, '').replace(/\/api$/, '');
|
|
162
163
|
const model = conn.model;
|
|
@@ -196,7 +197,20 @@ export async function probeCtx(conn, { timeout = 6000 } = {}) {
|
|
|
196
197
|
? [
|
|
197
198
|
['Ollama 모델 정보', `${origin}/api/show`, { method: 'POST', body: { model } }, {}],
|
|
198
199
|
]
|
|
199
|
-
:
|
|
200
|
+
: conn.kind === 'anthropic'
|
|
201
|
+
/*
|
|
202
|
+
* Anthropic 은 모델 목록에 **창 크기가 없다.** 이름과 나온 날짜뿐이다.
|
|
203
|
+
*
|
|
204
|
+
* 그래도 두 자리는 두드린다. 값이 없어도 「모델이 있다」 는 것은 확인되고,
|
|
205
|
+
* 못 찾았다는 사실이 tried 에 남아 화면이 짐작을 사실처럼 말하지 않게 된다.
|
|
206
|
+
* llama.cpp 의 /props 나 TGI 의 /info 는 여기 있을 리가 없으므로 안 두드린다 —
|
|
207
|
+
* 없는 문을 두드리면 켤 때마다 그만큼 기다린다.
|
|
208
|
+
*/
|
|
209
|
+
? [
|
|
210
|
+
['모델 상세', `${base}/models/${encodeURIComponent(model)}`, {}, {}],
|
|
211
|
+
['모델 목록', `${base}/models`, {}, { 목록: true }],
|
|
212
|
+
]
|
|
213
|
+
: [
|
|
200
214
|
// 이 모델만 콕 집어 묻는다. 있으면 가장 정확하다.
|
|
201
215
|
['모델 상세', `${base}/models/${encodeURIComponent(model)}`, {}, {}],
|
|
202
216
|
// LM Studio 는 자기 규격에만 '올린 길이' 를 준다. 이게 실제로 쓸 값이다.
|
package/src/backend/detect.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 주소만 받아서 "이 서버가 무슨 규격이고 인증을 어떻게 받는지" 알아낸다.
|
|
2
2
|
import { req, headersFor, AUTH_STYLES, serverMessage } from './http.js';
|
|
3
3
|
import { 애저인가, 애저풀기, 애저base, 배포목록 } from './azure.js';
|
|
4
|
+
import { ANTHROPIC_VERSION } from './adapter.js';
|
|
4
5
|
|
|
5
6
|
// 사람이 대충 적은 주소를 시도해볼 후보들로 넓힌다.
|
|
6
7
|
export function candidates(input) {
|
|
@@ -105,12 +106,73 @@ async function tryAzure(input, key) {
|
|
|
105
106
|
return null;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
/*
|
|
110
|
+
* 못 붙었을 때 **무엇이 막았는지**를 적어 둔다.
|
|
111
|
+
*
|
|
112
|
+
* 여태는 실패한 응답을 그냥 버렸다. 그래서 401(닿았는데 열쇠) · 404(주소가
|
|
113
|
+
* 아님) · Bedrock 의 AccessDenied(모델 신청을 안 함)가 화면에서 전부
|
|
114
|
+
* 「연결 실패」 한 마디가 됐다. 셋은 고칠 자리가 완전히 다른데도.
|
|
115
|
+
*
|
|
116
|
+
* 값을 채워 주는 자리를 인자로 받는 것은, 두드리는 함수들이 돌려주는 모양을
|
|
117
|
+
* 안 바꾸려는 것이다 — 그 반환값(붙었나 아닌가)은 부르는 데가 여럿이다.
|
|
118
|
+
*
|
|
119
|
+
* **진짜 상태코드를 먼저 적는다.** 후보를 여럿 두드리다 보면 하나는 401 이고
|
|
120
|
+
* 하나는 0(닿지도 못함)일 수 있는데, 401 쪽이 훨씬 쓸모 있는 단서다 — 적어도
|
|
121
|
+
* 서버가 거기 있다는 뜻이니까.
|
|
122
|
+
*/
|
|
123
|
+
function 막은것적기(막힌것, r) {
|
|
124
|
+
if (!막힌것) return;
|
|
125
|
+
const 말 = (r.status ? serverMessage(r) : r.error) ?? '';
|
|
126
|
+
if (!막힌것.status && r.status) { 막힌것.status = r.status; 막힌것.why = 말; }
|
|
127
|
+
else if (!막힌것.status && !막힌것.why) 막힌것.why = 말;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/*
|
|
131
|
+
* Anthropic 규격인지 확인 (GET {base}/models · 판 머리를 얹어서).
|
|
132
|
+
*
|
|
133
|
+
* ── 왜 OpenAI 쪽과 안 헷갈리나 ─────────────────────────────────────────
|
|
134
|
+
*
|
|
135
|
+
* 문 이름이 `/models` 로 같다. 그런데 이쪽 서버는 `anthropic-version` 머리가
|
|
136
|
+
* 없으면 400 을 준다. tryOpenAI 는 그 머리를 안 보내므로 여기 붙을 수가 없다.
|
|
137
|
+
*
|
|
138
|
+
* 반대는 성립하지 않는다 — OpenAI 호환 서버는 모르는 머리를 그냥 무시하므로
|
|
139
|
+
* 이 함수로도 붙어 버린다. 그래서 **뒤에 두드린다.** 다만 열쇠가 sk-ant- 로
|
|
140
|
+
* 시작하거나 주소가 그 회사면 먼저 본다. 그때는 헷갈릴 일이 없고, 앞에서
|
|
141
|
+
* 네 가지 인증 방식으로 헛되이 두드리는 시간도 없앤다.
|
|
142
|
+
*
|
|
143
|
+
* 인증 방식을 한 가지만 본다. 이 규격은 x-api-key 하나뿐이라, 나머지로
|
|
144
|
+
* 두드려 봐야 401 만 늘고 사람이 기다리는 시간만 길어진다.
|
|
145
|
+
*/
|
|
146
|
+
async function tryAnthropic(base, key, 막힌것 = null) {
|
|
147
|
+
if (!key) return null;
|
|
148
|
+
const r = await req(`${base}/models`, {
|
|
149
|
+
headers: headersFor('x-api-key', key, { 'anthropic-version': ANTHROPIC_VERSION }),
|
|
150
|
+
timeout: 12000,
|
|
151
|
+
});
|
|
152
|
+
막은것적기(막힌것, r);
|
|
153
|
+
if (r.ok && Array.isArray(r.json?.data)) {
|
|
154
|
+
return { kind: 'anthropic', base, auth: 'x-api-key', models: normalizeModels(r.json.data), ms: r.ms };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 이 규격을 먼저 볼 만한 단서가 있나. 열쇠 앞머리나 주소로만 본다. */
|
|
160
|
+
export function 앤트로픽같나(input, key) {
|
|
161
|
+
if (/^sk-ant-/.test(String(key ?? '').trim())) return true;
|
|
162
|
+
try {
|
|
163
|
+
const u = new URL(/^https?:\/\//i.test(input) ? String(input) : 'https://' + String(input));
|
|
164
|
+
return /(^|\.)anthropic\.com$/i.test(u.hostname);
|
|
165
|
+
} catch { return false; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// OpenAI 호환인지 확인 (GET {base}/models). 막힌것은 막은것적기 머리말 참고.
|
|
169
|
+
async function tryOpenAI(base, key, 막힌것 = null) {
|
|
170
|
+
const 적기 = (r) => 막은것적기(막힌것, r);
|
|
110
171
|
for (const style of AUTH_STYLES) {
|
|
111
172
|
if (style.id !== 'none' && !key) continue;
|
|
112
173
|
if (style.id === 'none' && key) { /* 키를 줬어도 인증 없는 서버일 수 있으니 마지막에 본다 */ }
|
|
113
174
|
const r = await req(`${base}/models`, { headers: headersFor(style.id, key), timeout: 12000 });
|
|
175
|
+
적기(r);
|
|
114
176
|
if (r.ok && r.json) {
|
|
115
177
|
const list = r.json.data ?? r.json.models ?? [];
|
|
116
178
|
if (Array.isArray(list)) {
|
|
@@ -126,6 +188,16 @@ async function tryOpenAI(base, key) {
|
|
|
126
188
|
// 로그인 페이지를 200 으로 내주는 일이 흔한데, 예전에는 그걸 "OpenAI 호환
|
|
127
189
|
// 서버 · 모델 0개" 로 잡았다. 그러면 사람은 모델이 안 올라온 줄 알고
|
|
128
190
|
// 엉뚱한 데를 파게 된다 — 실제로는 인증 페이지에 막힌 것이다.
|
|
191
|
+
/*
|
|
192
|
+
* 400 인데 「anthropic-version 이 필요하다」 고 하면, 그건 서버가 화가 난
|
|
193
|
+
* 것이 아니라 **규격이 다르다**는 말이다. 서버가 제 규격을 말해 줬다.
|
|
194
|
+
*
|
|
195
|
+
* 여기서 openai 로 잡아 버리면 설치는 초록색으로 끝나고 첫 한마디에서
|
|
196
|
+
* 400 이 난다 — 그 화면으로는 원인을 알 길이 없다. 열쇠 앞머리가 sk-ant-
|
|
197
|
+
* 가 아닌 열쇠(사내에서 다시 발급한 것 등)로 그 규격 서버에 붙을 때
|
|
198
|
+
* 실제로 이 길로 온다.
|
|
199
|
+
*/
|
|
200
|
+
if (r.status === 400 && /anthropic-version/i.test(serverMessage(r) ?? '')) return null;
|
|
129
201
|
const 성공인데JSON아님 = r.status >= 200 && r.status < 300 && !r.json;
|
|
130
202
|
if (r.status && !성공인데JSON아님 && ![401, 403, 0, 404].includes(r.status)) {
|
|
131
203
|
return { kind: 'openai', base, auth: style.id, models: [], ms: r.ms, warn: serverMessage(r) };
|
|
@@ -154,7 +226,7 @@ function fmtSize(bytes) {
|
|
|
154
226
|
|
|
155
227
|
function normalizeModels(list) {
|
|
156
228
|
return list
|
|
157
|
-
.map((m) => (typeof m === 'string' ? { id: m } : { id: m.id ?? m.name ?? m.model, note: m.owned_by ?? '' }))
|
|
229
|
+
.map((m) => (typeof m === 'string' ? { id: m } : { id: m.id ?? m.name ?? m.model, note: m.owned_by ?? m.display_name ?? '' }))
|
|
158
230
|
.filter((m) => m.id);
|
|
159
231
|
}
|
|
160
232
|
|
|
@@ -177,14 +249,40 @@ export async function detect(input, key) {
|
|
|
177
249
|
};
|
|
178
250
|
}
|
|
179
251
|
|
|
252
|
+
// 못 붙으면 무엇이 막았는지를 같이 돌려준다 — 부르는 쪽이 사람 말로 옮긴다
|
|
253
|
+
// (providers/index.js 의 막힌까닭). tryOpenAI 머리말 참고.
|
|
254
|
+
const 막힌것 = { status: 0, why: '' };
|
|
255
|
+
|
|
256
|
+
// 단서가 있으면 이 규격을 먼저 본다 (tryAnthropic 머리말).
|
|
257
|
+
if (앤트로픽같나(input, key)) {
|
|
258
|
+
for (const base of candidates(input)) {
|
|
259
|
+
tried.push(base);
|
|
260
|
+
const hit = await tryAnthropic(base, key, 막힌것);
|
|
261
|
+
if (hit) return { ...hit, tried };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
180
265
|
// Ollama 를 먼저 본다 — 로컬이면 대개 이쪽이고 확인이 빠르다.
|
|
181
266
|
const oll = await tryOllama(/^https?:\/\//i.test(origin) ? origin : 'http://' + origin);
|
|
182
267
|
if (oll) return { ...oll, tried };
|
|
183
268
|
|
|
184
269
|
for (const base of candidates(input)) {
|
|
185
|
-
tried.push(base);
|
|
186
|
-
const hit = await tryOpenAI(base, key);
|
|
270
|
+
if (!tried.includes(base)) tried.push(base);
|
|
271
|
+
const hit = await tryOpenAI(base, key, 막힌것);
|
|
187
272
|
if (hit) return { ...hit, tried };
|
|
188
273
|
}
|
|
189
|
-
|
|
274
|
+
|
|
275
|
+
/*
|
|
276
|
+
* 마지막으로 이 규격도 본다.
|
|
277
|
+
*
|
|
278
|
+
* 사내 게이트웨이가 이 규격만 열어 둔 경우가 있다. 여기까지 왔다는 것은
|
|
279
|
+
* OpenAI 쪽으로는 안 붙었다는 뜻이라, 이제 헷갈릴 일이 없다.
|
|
280
|
+
*/
|
|
281
|
+
if (key && !앤트로픽같나(input, key)) {
|
|
282
|
+
for (const base of candidates(input)) {
|
|
283
|
+
const hit = await tryAnthropic(base, key, 막힌것);
|
|
284
|
+
if (hit) return { ...hit, tried };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { kind: null, tried, status: 막힌것.status, why: 막힌것.why };
|
|
190
288
|
}
|