deel-local-cli 1.6.3 → 1.8.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 +1182 -0
- package/README.md +850 -636
- package/bin/deel.js +44 -3
- package/package.json +3 -3
- package/src/acp/map.js +1 -1
- package/src/acp/serve.js +25 -2
- package/src/agent/loop.js +41 -9
- package/src/agent/session.js +15 -9
- package/src/backend/adapter.js +277 -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/retry.js +3 -0
- package/src/backend/scanui.js +2 -1
- package/src/backend/vision.js +26 -4
- package/src/commands.js +141 -38
- package/src/completion.js +21 -2
- package/src/i18n/en.js +160 -1
- package/src/i18n/index.js +22 -0
- package/src/i18n/ja.js +152 -1
- package/src/i18n/ko.js +173 -1
- package/src/i18n/zh.js +152 -1
- package/src/oneshot.js +39 -6
- package/src/pack/selfpack.js +1 -1
- 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 +198 -33
- package/src/report.js +3 -2
- package/src/reset.js +397 -0
- package/src/safety/audit.js +5 -2
- package/src/safety/authcmd.js +305 -0
- package/src/safety/keystore.js +53 -0
- package/src/safety/runmode.js +123 -0
- package/src/safety/secrets.js +61 -0
- package/src/safety/undo.js +3 -2
- package/src/setup.js +215 -12
- package/src/tools/desc.en.js +19 -14
- package/src/tools/index.js +141 -43
- package/src/tools/jobs.js +17 -13
- package/src/tools/lsp.js +5 -4
- package/src/tools/outline.js +7 -3
- package/src/tools/task.js +10 -6
- package/src/tools/todo.js +9 -2
- package/src/tools/verify.js +9 -3
- package/src/tools/webfetch.js +2 -1
- package/src/ui/export.js +1 -1
- package/src/ui/histline.js +67 -0
- package/src/ui/status.js +88 -3
- package/README.en.md +0 -1025
package/src/backend/adapter.js
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
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
|
+
import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
|
|
6
|
+
import { 말 } from '../i18n/index.js';
|
|
5
7
|
import { 다시부를지, 기다리기, 정책고르기 } from './retry.js';
|
|
6
8
|
|
|
9
|
+
/*
|
|
10
|
+
* Anthropic 규격의 판 이름.
|
|
11
|
+
*
|
|
12
|
+
* 이 머리 하나가 없으면 400 이다. 열쇠가 멀쩡해도 그렇다. 날짜처럼 생겼지만
|
|
13
|
+
* 「오늘 날짜」 가 아니라 **규격 판 이름**이라, 새 날짜를 넣는다고 새 기능이
|
|
14
|
+
* 켜지지 않는다. 회사가 새 판을 내놓기 전에는 이 값이 바뀔 일이 없다.
|
|
15
|
+
*/
|
|
16
|
+
export const ANTHROPIC_VERSION = '2023-06-01';
|
|
17
|
+
|
|
7
18
|
export function endpoint(shape) {
|
|
8
|
-
|
|
19
|
+
if (shape === 'ollama') return '/api/chat';
|
|
20
|
+
if (shape === 'anthropic') return '/messages';
|
|
21
|
+
return '/chat/completions';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 이 규격이 더 요구하는 머리. 없으면 빈 것. */
|
|
25
|
+
export function 더할머리(shape) {
|
|
26
|
+
return shape === 'anthropic' ? { 'anthropic-version': ANTHROPIC_VERSION } : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 이 규격을 사람에게 뭐라고 부를까.
|
|
31
|
+
*
|
|
32
|
+
* 한 곳에 모아 둔다. 화면 여러 자리가 각자 `kind === 'ollama' ? … : 'OpenAI
|
|
33
|
+
* 호환'` 로 갈라 놓고 있었는데, 규격이 셋이 되는 순간 그 자리들이 전부
|
|
34
|
+
* Anthropic 연결을 **「OpenAI 호환」 이라고 잘못 적는다.** 붙는 데는 아무
|
|
35
|
+
* 지장이 없어서 아무도 안 고치고, 그 화면을 믿고 남에게 설명하게 된다.
|
|
36
|
+
*/
|
|
37
|
+
export function 규격이름(shape) {
|
|
38
|
+
if (shape === 'ollama') return 말('head.spec.ollama');
|
|
39
|
+
if (shape === 'anthropic') return 말('head.spec.anthropic');
|
|
40
|
+
return 말('head.spec.openai');
|
|
9
41
|
}
|
|
10
42
|
|
|
11
43
|
/**
|
|
@@ -60,6 +92,9 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
60
92
|
if (think !== undefined) body.think = think;
|
|
61
93
|
return body;
|
|
62
94
|
}
|
|
95
|
+
if (shape === 'anthropic') return anthropic몸(
|
|
96
|
+
{ model, messages, tools, stream, json, think, maxTokens },
|
|
97
|
+
);
|
|
63
98
|
// 출력 상한을 **두 이름으로 같이** 보낸다.
|
|
64
99
|
//
|
|
65
100
|
// 옛 규격은 max_tokens 하나였다. 그런데 GPT-5 계열을 붙여 놓은 게이트웨이는
|
|
@@ -78,7 +113,90 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
|
|
|
78
113
|
return body;
|
|
79
114
|
}
|
|
80
115
|
|
|
116
|
+
/*
|
|
117
|
+
* ── Anthropic 규격은 어디가 다른가 ─────────────────────────────────────
|
|
118
|
+
*
|
|
119
|
+
* OpenAI 호환 서버는 문 이름(/chat/completions)만 같으면 대충 통했다. 이쪽은
|
|
120
|
+
* 통하지 않는 자리가 넷이다. 그래서 여기 한 곳에 몰아 둔다.
|
|
121
|
+
*
|
|
122
|
+
* 1. 시킴말(system)이 messages 안에 못 들어간다. 몸의 딴 칸이다.
|
|
123
|
+
* 2. 차례가 사람·모델로 **번갈아** 와야 한다. 도구를 한 턴에 둘 부르면
|
|
124
|
+
* 결과가 둘인데, 그대로 보내면 거절당한다.
|
|
125
|
+
* 3. 도구 모양이 { name, description, input_schema } 다.
|
|
126
|
+
* OpenAI 의 { type:'function', function:{...} } 가 아니다.
|
|
127
|
+
* 4. 답이 글 한 덩어리가 아니라 **블록 배열**이다.
|
|
128
|
+
*
|
|
129
|
+
* 안 보내는 것도 적어 둔다. 확인 못 한 것은 안 보낸다는 뜻이지, 없다는 뜻이
|
|
130
|
+
* 아니다 — 짐작으로 보낸 칸 하나가 400 을 만들면 열쇠가 틀린 줄 알게 된다.
|
|
131
|
+
*
|
|
132
|
+
* · think — 생각 칸의 값 모양을 문서에서 확인하지 못했다.
|
|
133
|
+
* · json — 이 규격에는 답 모양을 강제하는 칸이 없다. 도구로 하는 방법뿐이다.
|
|
134
|
+
*/
|
|
135
|
+
function anthropic몸({ model, messages, tools, stream, json, think, maxTokens }) {
|
|
136
|
+
const 머리말 = [];
|
|
137
|
+
const 나머지 = [];
|
|
138
|
+
for (const m of messages ?? []) {
|
|
139
|
+
if (m?.role === 'system') 머리말.push(typeof m.content === 'string' ? m.content : String(m.content ?? ''));
|
|
140
|
+
else 나머지.push(m);
|
|
141
|
+
}
|
|
142
|
+
const body = { model, messages: 차례합치기(나머지), stream: !!stream, max_tokens: maxTokens };
|
|
143
|
+
if (머리말.length) body.system = 머리말.join('\n\n');
|
|
144
|
+
if (tools?.length) {
|
|
145
|
+
body.tools = tools.map((t) => {
|
|
146
|
+
const f = t.function ?? t;
|
|
147
|
+
return {
|
|
148
|
+
name: f.name,
|
|
149
|
+
description: f.description ?? '',
|
|
150
|
+
input_schema: f.parameters ?? f.input_schema ?? { type: 'object', properties: {} },
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
body.tool_choice = { type: 'auto' };
|
|
154
|
+
}
|
|
155
|
+
return body;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 같은 차례가 연달아 오면 하나로 합친다.
|
|
160
|
+
*
|
|
161
|
+
* 도구를 한 턴에 둘 부르면 결과 메시지가 둘이고, 이 규격에서 그 둘은 다
|
|
162
|
+
* 사람 차례다. 번갈아 오지 않으면 서버가 통째로 거절한다 — 그러면 도구를
|
|
163
|
+
* 하나만 부를 때는 되고 둘 부를 때만 안 되는, 제일 알아내기 어려운 고장이 된다.
|
|
164
|
+
*/
|
|
165
|
+
export function 차례합치기(messages) {
|
|
166
|
+
const 덩이 = (c) => (Array.isArray(c) ? c : [{ type: 'text', text: String(c ?? '') }]);
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const m of messages ?? []) {
|
|
169
|
+
const 앞 = out[out.length - 1];
|
|
170
|
+
if (앞 && 앞.role === m?.role) {
|
|
171
|
+
앞.content = [...덩이(앞.content), ...덩이(m.content)];
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
out.push({ ...m });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
81
179
|
export function extractMessage(shape, json) {
|
|
180
|
+
if (shape === 'anthropic') {
|
|
181
|
+
// 답이 블록 배열이다. 글·생각·도구 부름이 한 배열에 섞여 온다.
|
|
182
|
+
let content = '';
|
|
183
|
+
let thinking = '';
|
|
184
|
+
const 부름들 = [];
|
|
185
|
+
for (const b of Array.isArray(json?.content) ? json.content : []) {
|
|
186
|
+
if (b?.type === 'text') content += b.text ?? '';
|
|
187
|
+
else if (b?.type === 'thinking') thinking += b.thinking ?? '';
|
|
188
|
+
else if (b?.type === 'tool_use') 부름들.push({ id: b.id, name: b.name, args: b.input ?? {} });
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
content,
|
|
192
|
+
thinking,
|
|
193
|
+
toolCalls: normalizeCalls(부름들),
|
|
194
|
+
// 이름이 다르다. prompt_tokens 를 찾으면 늘 0 이 나오고, 화면에는
|
|
195
|
+
// 「토큰을 하나도 안 썼다」 로 뜬다.
|
|
196
|
+
usage: { in: json?.usage?.input_tokens ?? 0, out: json?.usage?.output_tokens ?? 0 },
|
|
197
|
+
stopped: json?.stop_reason ?? null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
82
200
|
if (shape === 'ollama') {
|
|
83
201
|
const m = json?.message ?? {};
|
|
84
202
|
return {
|
|
@@ -141,6 +259,23 @@ export function normalizeCalls(list) {
|
|
|
141
259
|
|
|
142
260
|
// 대화 이력에 되돌려 넣을 메시지 만들기 — 규격마다 모양이 다르다.
|
|
143
261
|
export function assistantMessage(shape, { content = '', thinking = '', toolCalls = [] }) {
|
|
262
|
+
if (shape === 'anthropic') {
|
|
263
|
+
const 블록 = [];
|
|
264
|
+
if (content) 블록.push({ type: 'text', text: content });
|
|
265
|
+
for (const t of toolCalls) 블록.push({ type: 'tool_use', id: t.id, name: t.name, input: t.args ?? {} });
|
|
266
|
+
/*
|
|
267
|
+
* 생각은 **안 돌려보낸다.**
|
|
268
|
+
*
|
|
269
|
+
* 이 규격의 생각 블록에는 서명(signature)이 딸려 있고, 서명 없이 돌려보내면
|
|
270
|
+
* 서버가 거절한다. 흘려받기에서 서명 조각을 제대로 모으는 것까지 확인하지
|
|
271
|
+
* 못했으므로 여기서는 뺀다. 화면에는 그대로 흘러가고, 대화 이력에만 안 남는다.
|
|
272
|
+
*
|
|
273
|
+
* 블록이 하나도 없으면 이 규격은 거절한다. 빈 답이 오는 일은 드물지만
|
|
274
|
+
* 그때 대화 전체가 죽으면 안 되니 자리표시를 하나 넣는다.
|
|
275
|
+
*/
|
|
276
|
+
if (!블록.length) 블록.push({ type: 'text', text: '(빈 답)' });
|
|
277
|
+
return { role: 'assistant', content: 블록 };
|
|
278
|
+
}
|
|
144
279
|
if (shape === 'ollama') {
|
|
145
280
|
const m = { role: 'assistant', content };
|
|
146
281
|
if (thinking) m.thinking = thinking;
|
|
@@ -158,19 +293,71 @@ export function assistantMessage(shape, { content = '', thinking = '', toolCalls
|
|
|
158
293
|
}
|
|
159
294
|
|
|
160
295
|
export function toolMessage(shape, { callId, name, content }) {
|
|
296
|
+
// 이 규격에는 도구 차례가 없다. 도구 결과도 **사람 차례**로 돌려준다.
|
|
297
|
+
// role:'tool' 로 보내면 「모르는 역할」 이라고 거절당한다.
|
|
298
|
+
if (shape === 'anthropic') {
|
|
299
|
+
return { role: 'user', content: [{ type: 'tool_result', tool_use_id: callId, content: String(content) }] };
|
|
300
|
+
}
|
|
161
301
|
return shape === 'ollama'
|
|
162
302
|
? { role: 'tool', tool_name: name, content: String(content) }
|
|
163
303
|
: { role: 'tool', tool_call_id: callId, content: String(content) };
|
|
164
304
|
}
|
|
165
305
|
|
|
306
|
+
/*
|
|
307
|
+
* 이번 요청에 실을 머리말.
|
|
308
|
+
*
|
|
309
|
+
* 열쇠받기가 걸려 있으면 여기서 받아 온다. 요청 **직전**에 받는 것이
|
|
310
|
+
* 중요하다 — 판을 켤 때 한 번 받아 두면 세 시간짜리 대화의 두 시간째에
|
|
311
|
+
* 죽어 있고, 그 401 은 「열쇠가 틀렸다」 와 화면에서 구별이 안 된다.
|
|
312
|
+
*
|
|
313
|
+
* 못 받으면 **던지지 않는다.** 원래 열쇠(있으면)로 그냥 간다. 여기서
|
|
314
|
+
* 막아 버리면 열쇠받기 설정 한 줄이 잘못된 것으로 멀쩡히 붙던 연결까지
|
|
315
|
+
* 안 붙는다. 못 받았다는 것은 부르는 쪽이 onAuth 로 듣고 화면에 적는다.
|
|
316
|
+
*/
|
|
317
|
+
async function 머리말짓기(conn, opts, { 다시 = false } = {}) {
|
|
318
|
+
const 설정 = conn.열쇠받기 ?? null;
|
|
319
|
+
const 판단 = 쓸수있나(설정, { auth: conn.auth });
|
|
320
|
+
if (!설정 || !판단.된다) {
|
|
321
|
+
if (설정 && 판단.왜) opts.onAuth?.({ ok: false, 왜: 판단.왜, 안부름: true });
|
|
322
|
+
return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
|
|
323
|
+
}
|
|
324
|
+
const r = await 열쇠받아오기(설정, {
|
|
325
|
+
다시, signal: opts.signal ?? null,
|
|
326
|
+
물어보기: opts.열쇠물어보기 ?? null,
|
|
327
|
+
알림: opts.onAuth ? (것) => opts.onAuth(것) : null,
|
|
328
|
+
});
|
|
329
|
+
if (!r.ok) {
|
|
330
|
+
opts.onAuth?.({ ...r, ok: false });
|
|
331
|
+
return headersFor(conn.auth, conn.key ?? '', 더할머리(conn.kind));
|
|
332
|
+
}
|
|
333
|
+
if (!r.그대로) opts.onAuth?.({ ok: true, 만료: r.만료, ms: r.ms });
|
|
334
|
+
return headersFor(conn.auth, r.token, { ...더할머리(conn.kind), ...r.headers });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/*
|
|
338
|
+
* 401 을 맞았을 때 열쇠를 새로 받고 한 번만 다시 부를까.
|
|
339
|
+
*
|
|
340
|
+
* retry.js 는 401 을 안 다시 부른다 — 열쇠가 틀린 것은 백 번 불러도
|
|
341
|
+
* 같기 때문이다. 그 말은 지금도 맞다. 다른 것은 **열쇠를 바꿀 수 있을
|
|
342
|
+
* 때**뿐이다. 그때는 같은 열쇠로 다시 부르는 것이 아니라 새 열쇠로
|
|
343
|
+
* 부르는 것이라, 「불러 봐야 같다」 에 해당하지 않는다.
|
|
344
|
+
*
|
|
345
|
+
* 한 번만이다. 두 번째 401 은 진짜로 권한이 없는 것이고, 그때 더 부르면
|
|
346
|
+
* 로그인 명령만 되풀이해서 띄우게 된다.
|
|
347
|
+
*/
|
|
348
|
+
function 열쇠다시받을까(conn, status, 이미) {
|
|
349
|
+
return !이미 && Number(status) === 401 && !!conn.열쇠받기;
|
|
350
|
+
}
|
|
351
|
+
|
|
166
352
|
// 한 번에 받기.
|
|
167
353
|
export async function chat(conn, opts) {
|
|
168
354
|
const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts });
|
|
169
355
|
const 정책 = 정책고르기(conn, opts);
|
|
356
|
+
let 열쇠다시받음 = false;
|
|
170
357
|
for (let 시도 = 1; ; 시도++) {
|
|
171
358
|
const r = await req(요청주소(conn), {
|
|
172
359
|
method: 'POST',
|
|
173
|
-
headers:
|
|
360
|
+
headers: await 머리말짓기(conn, opts),
|
|
174
361
|
body,
|
|
175
362
|
timeout: opts.timeout ?? 300000,
|
|
176
363
|
signal: opts.signal ?? null,
|
|
@@ -179,6 +366,14 @@ export async function chat(conn, opts) {
|
|
|
179
366
|
// 429 를 맞고 나서야 아는 것과, 맞기 전에 아는 것은 사람이 할 일이 다르다.
|
|
180
367
|
할당량기억(r.headers);
|
|
181
368
|
if (r.ok) return extractMessage(conn.kind, r.json);
|
|
369
|
+
// 열쇠가 늙어서 막힌 것이면 새로 받고 한 번만 다시. 시도 수는 안 올린다 —
|
|
370
|
+
// 서버가 막은 것이 아니라 우리 열쇠가 낡았던 것이라 물러설 까닭이 없다.
|
|
371
|
+
if (열쇠다시받을까(conn, r.status, 열쇠다시받음)) {
|
|
372
|
+
열쇠다시받음 = true;
|
|
373
|
+
await 머리말짓기(conn, opts, { 다시: true });
|
|
374
|
+
시도 -= 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
182
377
|
// 잠깐 막힌 것이면 기다렸다 다시 부른다 (backend/retry.js 머리말).
|
|
183
378
|
// 한 번에 받는 길은 제너레이터가 아니라 화면에 말을 못 걸어서, 부르는 쪽이
|
|
184
379
|
// 준 onBackoff 로 알린다. 안 줬으면 조용히 기다린다.
|
|
@@ -241,10 +436,11 @@ export async function* chatStream(conn, opts) {
|
|
|
241
436
|
const body = buildBody(conn.kind, { model: conn.model, ctx: conn.ctx ?? null, ...opts, stream: true });
|
|
242
437
|
const 정책 = 정책고르기(conn, opts);
|
|
243
438
|
let r;
|
|
439
|
+
let 열쇠다시받음 = false;
|
|
244
440
|
for (let 시도 = 1; ; 시도++) {
|
|
245
441
|
r = await req(요청주소(conn), {
|
|
246
442
|
method: 'POST',
|
|
247
|
-
headers:
|
|
443
|
+
headers: await 머리말짓기(conn, opts),
|
|
248
444
|
body,
|
|
249
445
|
timeout: opts.timeout ?? 300000,
|
|
250
446
|
stream: true,
|
|
@@ -253,6 +449,14 @@ export async function* chatStream(conn, opts) {
|
|
|
253
449
|
할당량기억(r.headers ?? r.res?.headers);
|
|
254
450
|
if (r.ok && r.res?.body) break;
|
|
255
451
|
const 거절 = await 거절읽기(r);
|
|
452
|
+
// 위 chat() 과 같은 규칙. 몸을 먼저 읽고(거절읽기) 나서 다시 부른다 —
|
|
453
|
+
// 안 읽은 몸을 두고 다음 요청을 보내면 연결이 남는다.
|
|
454
|
+
if (열쇠다시받을까(conn, 거절.status, 열쇠다시받음)) {
|
|
455
|
+
열쇠다시받음 = true;
|
|
456
|
+
await 머리말짓기(conn, opts, { 다시: true });
|
|
457
|
+
시도 -= 1;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
256
460
|
// 잠깐 막힌 것이면 알리고, 기다렸다, 다시 부른다. 머리말도 못 받은 자리라
|
|
257
461
|
// 화면에 흘러간 글이 없다 — 그래서 여기서만 다시 부르고, 아래 읽기 도중에
|
|
258
462
|
// 끊긴 것은 다시 안 부른다 (backend/retry.js 머리말).
|
|
@@ -299,6 +503,7 @@ export async function* chatStream(conn, opts) {
|
|
|
299
503
|
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
300
504
|
function absorb(shape, obj, acc) {
|
|
301
505
|
const out = [];
|
|
506
|
+
if (shape === 'anthropic') return anthropic흡수(obj, acc, out);
|
|
302
507
|
if (shape === 'ollama') {
|
|
303
508
|
const m = obj.message ?? {};
|
|
304
509
|
if (m.thinking) { acc.thinking += m.thinking; out.push({ type: 'thinking', text: m.thinking }); }
|
|
@@ -320,6 +525,68 @@ function absorb(shape, obj, acc) {
|
|
|
320
525
|
return out;
|
|
321
526
|
}
|
|
322
527
|
|
|
528
|
+
/*
|
|
529
|
+
* Anthropic 흘려받기.
|
|
530
|
+
*
|
|
531
|
+
* OpenAI 는 조각마다 같은 모양(choices[0].delta)이 오는데, 이쪽은 **사건 이름**
|
|
532
|
+
* 으로 나뉜다. 하나씩 다르게 읽어야 한다.
|
|
533
|
+
*
|
|
534
|
+
* message_start — 시작. 여기 입력 토큰 수가 들어 있다.
|
|
535
|
+
* content_block_start — 블록 하나 시작. 도구 부름이면 이름과 번호가 여기 있다.
|
|
536
|
+
* content_block_delta — 알맹이 조각. 글·생각·도구 인자가 각각 딴 이름으로 온다.
|
|
537
|
+
* message_delta — 끝난 까닭과 출력 토큰 수.
|
|
538
|
+
* message_stop — 끝.
|
|
539
|
+
*
|
|
540
|
+
* 도구 인자는 글자 단위로 쪼개져 오므로 번호별로 이어 붙인다. 이 자리는
|
|
541
|
+
* OpenAI 쪽과 같아서 마무리(도구마무리)를 같이 쓴다.
|
|
542
|
+
*/
|
|
543
|
+
function anthropic흡수(obj, acc, out) {
|
|
544
|
+
const 종류 = obj?.type;
|
|
545
|
+
const 번호 = obj?.index ?? 0;
|
|
546
|
+
if (종류 === 'message_start') {
|
|
547
|
+
const u = obj.message?.usage;
|
|
548
|
+
if (u) acc.usage = { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0 };
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
if (종류 === 'content_block_start') {
|
|
552
|
+
const b = obj.content_block ?? {};
|
|
553
|
+
if (b.type === 'tool_use') {
|
|
554
|
+
acc._raw ??= [];
|
|
555
|
+
acc._raw[번호] = { id: b.id, name: b.name ?? '', args: '' };
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
if (종류 === 'content_block_delta') {
|
|
560
|
+
const d = obj.delta ?? {};
|
|
561
|
+
if (d.type === 'text_delta' && d.text) {
|
|
562
|
+
acc.content += d.text;
|
|
563
|
+
out.push({ type: 'content', text: d.text });
|
|
564
|
+
} else if (d.type === 'thinking_delta' && d.thinking) {
|
|
565
|
+
acc.thinking += d.thinking;
|
|
566
|
+
out.push({ type: 'thinking', text: d.thinking });
|
|
567
|
+
} else if (d.type === 'input_json_delta' && d.partial_json != null) {
|
|
568
|
+
acc._raw ??= [];
|
|
569
|
+
acc._raw[번호] ??= { id: null, name: '', args: '' };
|
|
570
|
+
acc._raw[번호].args += d.partial_json;
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
if (종류 === 'message_delta') {
|
|
575
|
+
/*
|
|
576
|
+
* 출력 토큰은 **덮어쓴다.** 조각마다의 양이 아니라 여태 누적 총계라서,
|
|
577
|
+
* 더하면 조각 수만큼 부풀어 오른다. 입력 쪽은 해당 없으면 아예 안 오므로
|
|
578
|
+
* 왔을 때만 덮는다 — 없는 것을 0 으로 덮으면 message_start 에서 받아 둔
|
|
579
|
+
* 진짜 값이 지워진다.
|
|
580
|
+
*/
|
|
581
|
+
if (obj.usage?.output_tokens != null) acc.usage.out = obj.usage.output_tokens;
|
|
582
|
+
if (obj.usage?.input_tokens != null) acc.usage.in = obj.usage.input_tokens;
|
|
583
|
+
if (obj.delta?.stop_reason) acc.stopped = obj.delta.stop_reason;
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
if (종류 === 'content_block_stop' || 종류 === 'message_stop') 도구마무리(acc);
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
|
|
323
590
|
// OpenAI 스트리밍은 도구 호출 인자를 글자 단위로 쪼개 보낸다. 인덱스별로 이어 붙인다.
|
|
324
591
|
function mergeDeltaCalls(acc, deltas) {
|
|
325
592
|
acc._raw ??= [];
|
|
@@ -330,6 +597,12 @@ function mergeDeltaCalls(acc, deltas) {
|
|
|
330
597
|
if (d.function?.name) acc._raw[i].name += d.function.name;
|
|
331
598
|
if (d.function?.arguments) acc._raw[i].args += d.function.arguments;
|
|
332
599
|
}
|
|
600
|
+
도구마무리(acc);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 글자로 쪼개져 온 도구 인자를 하나로 읽는다. 두 규격이 같이 쓴다.
|
|
604
|
+
function 도구마무리(acc) {
|
|
605
|
+
if (!acc._raw) return;
|
|
333
606
|
// 인자가 안 읽히면 읽혔다고 치지 않는다 — normalizeCalls 머리말 참고.
|
|
334
607
|
// 스트리밍은 마지막 조각이 안 오면 여기서 늘 깨진 채로 끝난다.
|
|
335
608
|
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
|
}
|