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.
@@ -1,473 +1,487 @@
1
- // 게이트웨이/로컬서버가 "에이전트를 돌릴 수 있는지"를 실제 요청으로 확인한다.
2
- // 각 검사는 { id, label, status, detail, ms } 를 돌려준다.
3
- // ok 되는 것을 확인함
4
- // no 안 됨 (기능에 직접 영향)
5
- // warn 되긴 하는데 조건이 붙음
6
- // skip 앞 검사가 실패해 확인 불가
7
- import { req, headersFor, serverMessage } from './http.js';
8
- import { probeCtx } from './ctxsize.js';
9
- import { 눈검사메시지 } from './vision.js';
10
- import {
11
- 주소붙이기, endpoint, 더할머리, buildBody, extractMessage,
12
- assistantMessage, toolMessage,
13
- } from './adapter.js';
14
- import { 벤더 } from './toolfit.js';
15
- import { 말 } from '../i18n/index.js';
16
-
17
- const READ_TOOL = {
18
- type: 'function',
19
- function: {
20
- name: 'read_file',
21
- description: '파일 하나의 내용을 읽는다',
22
- parameters: {
23
- type: 'object',
24
- properties: { path: { type: 'string', description: '읽을 파일 경로' } },
25
- required: ['path'],
26
- },
27
- },
28
- };
29
-
30
- /*
31
- * ── 규격 차이는 여기서 흡수하지 않는다 ──────────────────────────────────
32
- *
33
- * 여기에 build() 와 extract() 가 따로 있었다. 그 둘이 아는 규격은 `ollama` 와
34
- * 「나머지 = OpenAI」 **둘뿐**이었고, `anthropic` 은 「나머지」 로 떨어졌다.
35
- * 그래서 Claude 를 직접 붙이면 진단이 이렇게 갔다 —
36
- *
37
- * · 판 머리(anthropic-version)를 안 얹는다 → 그 하나로 400
38
- * · 문 이름이 /chat/completions (있어야 할 것은 /messages)
39
- * · 시킴말이 messages 안으로 들어간다 → 「모르는 역할」
40
- * · 도구가 {type:'function', function:{…}} 모양으로 간다
41
- * · 답을 choices[0].message 에서 찾는다 (실제로는 content 블록 배열)
42
- *
43
- * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 일곱 칸은 전부 「확인 불가」 로
44
- * 건너뛴다. 그 결과 `deel setup` 이 저장하는 프로필에 streaming·tools·json·
45
- * vision 이 **다 false** 로 적힌다 — Claude 를 붙였는데 도구를 아예 안 쓰는
46
- * 연결이 만들어진다. 붙기는 붙으니 아무도 고장이라고 생각하지 않는다.
47
- *
48
- * adapter.js 첫 줄은 처음부터 "진단(probe)과 에이전트 루프가 같은 함수를
49
- * 쓴다" 고 적어 두었다. 실제로는 안 썼다. 이제 쓴다 — 규격이 넷째가 되어도
50
- * 이 파일은 안 고쳐도 된다.
51
- */
52
-
53
- /** 이 회사가 무엇을 받는지까지 봐야 몸통이 맞다 (toolfit.js 의 벤더). */
54
- function 몸통(conn, opts) {
55
- return buildBody(conn.kind, { model: conn.model, 회사: 벤더(conn), maxTokens: 128, ...opts });
56
- }
57
-
58
- // 추론 모델일 때 기본 대화 칸에 덧붙일 설명.
59
- function c_note(retried) {
60
- return retried ? ' (추론 모델 — 사고를 끄고 다시 물어 확인)' : ' (추론 모델)';
61
- }
62
-
63
- /*
64
- * ── 이 문으로는 안 나오는 모델 (Codex 계열) ─────────────────────────────
65
- *
66
- * `gpt-5-codex` 계열은 OpenAI 가 **Responses API(/responses)로만** 내준다.
67
- * `/chat/completions` 로 물으면 404 나 400 이 오는데, 그 화면에는 상태 코드
68
- * 한 줄만 남는다. 그러면 사람은 열쇠를 의심하거나 모델 이름을 잘못 적은 줄
69
- * 알고, 맞는 이름을 몇 번씩 다시 넣어 본다 — 이름은 처음부터 맞았다.
70
- *
71
- * ── 왜 이름을 보나. 이 파일에서만 본다 ─────────────────────────────────
72
- *
73
- * 어디로 보낼지는 **주소로만** 정한다(1.9). 그 규칙은 여기서도 안 깬다 —
74
- * 여기서 이름을 보는 것은 보낼 곳을 고르려는 것이 아니라, **이미 실패한
75
- * 요청에 까닭을 붙이려는** 것뿐이다. 그래서 미리 막지 않는다. 요청은 그대로
76
- * 나가고, 실패했을 때만 한 줄이 붙는다. 언젠가 OpenAI 가 이 문으로도 내주면
77
- * 요청이 그냥 성공하고 이 줄은 저절로 안 나온다.
78
- *
79
- * 게이트웨이는 해당 없다. 그 뒤에 무엇이 걸려 있는지 우리는 모르고, 사내
80
- * 게이트웨이가 제 나름대로 이 계열을 chat 창구로 내주는 일도 있다.
81
- */
82
- const CODEX계열 = /^(?:gpt-5-codex|codex-)/i;
83
-
84
- export function 코덱스인가(conn) {
85
- return 벤더(conn) === 'openai' && CODEX계열.test(String(conn?.model ?? ''));
86
- }
87
-
88
- const SKIPPED = [
89
- ['system', '시스템 메시지'],
90
- ['stream', '스트리밍'],
91
- ['tools', '도구 호출'],
92
- ['toolresult', '도구 결과 되돌리기'],
93
- ['json', '구조적 출력'],
94
- ['vision', '그림 보기'],
95
- ['think', '추론 강도 조절'],
96
- ['ctx', '컨텍스트 길이'],
97
- ];
98
-
99
- export async function probe(conn, onStep = () => {}) {
100
- const { kind: shape, base, auth, model } = conn;
101
- const key = conn.key ?? '';
102
- /*
103
- * 판 머리를 여기서도 얹는다 (adapter.js 의 더할머리).
104
- *
105
- * Anthropic 규격은 `anthropic-version` 하나가 없으면 400 이다. 열쇠가
106
- * 멀쩡해도 그렇다. 진단 화면은 그 400 을 「연결 실패」 로 적고, 사람은
107
- * 열쇠를 다시 받으러 간다 — 여덟 칸이 전부 그 한 줄 때문에 빨개진다.
108
- */
109
- const H = () => headersFor(auth, key, 더할머리(shape));
110
- /*
111
- * 물음표 뒤를 끝에 남겨야 한다 (adapter.js 의 주소붙이기).
112
- *
113
- * 이 한 줄이 예전에 `${base}${p}` 였다. Azure base 에는 `?api-version=` 이
114
- * 붙어 있어서, 그대로 이으면
115
- * `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 된다.
116
- * 그러면 **설치 화면이 제 검사에 통째로 실패한다** — 기본 대화가 안 되니
117
- * 나머지 일곱 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
118
- * 도구 호출도 안 된다고 적힌다. 붙기는 붙는데 반쪽짜리로 붙는다.
119
- */
120
- const url = (p) => 주소붙이기(base, p);
121
- const results = [];
122
- const facts = { shape, base, auth, model };
123
-
124
- const add = (r) => { results.push(r); onStep(r); return r; };
125
- const call = (opts) => req(url(endpoint(shape)), {
126
- method: 'POST',
127
- headers: H(),
128
- body: 몸통(conn, opts),
129
- timeout: opts.timeout ?? 60000,
130
- stream: opts.stream,
131
- });
132
- // 답 읽기도 루프와 같은 함수를 쓴다. 도구 부름은 { id, name, args } 로
133
- // 고르게 나온다 — 규격마다 다른 자리를 여기서 또 헤아리지 않는다.
134
- const 읽기 = (r) => extractMessage(shape, r?.json);
135
-
136
- // 1. 기본 대화 — 이게 안 되면 나머지는 볼 필요가 없다.
137
- // 추론 모델은 본문이 전부 thinking 으로 가고 토큰 상한에 잘린다.
138
- // 그걸 "안됨"으로 볼 수 없으므로, 사고를 끄고 넉넉히 한 번 더 물어본다.
139
- const ASK = { messages: [{ role: 'user', content: '1+1은? 숫자만 답하세요.' }] };
140
- let basic = await call({ ...ASK, maxTokens: 256 });
141
- let got = basic.ok ? 읽기(basic) : { content: '', thinking: '' };
142
- let thinkingModel = false;
143
- let retried = false;
144
-
145
- if (basic.ok && !got.content && got.thinking) {
146
- thinkingModel = true;
147
- retried = true;
148
- const second = await call({ ...ASK, maxTokens: 1024, think: false, timeout: 90000 });
149
- if (second.ok) {
150
- const e2 = 읽기(second);
151
- if (e2.content) { basic = second; got = e2; }
152
- else {
153
- // 사고를 못 끄는 서버 — 상한만 크게 올려 한 번 더.
154
- const third = await call({ ...ASK, maxTokens: 2048, timeout: 120000 });
155
- if (third.ok && 읽기(third).content) { basic = third; got = 읽기(third); }
156
- }
157
- }
158
- }
159
-
160
- const basicText = got.content;
161
- const basicOk = basic.ok && !!basicText;
162
- facts.thinkingModel = thinkingModel;
163
- // 사고를 끌 수 있는 모델이면 이후 검사에서 꺼서 토큰과 시간을 아낀다.
164
- const quiet = shape === 'ollama' && thinkingModel ? { think: false, maxTokens: 512 } : {};
165
-
166
- add({
167
- id: 'chat',
168
- label: '기본 대화',
169
- status: basicOk ? 'ok' : 'no',
170
- detail: basicOk
171
- ? `응답 "${basicText.trim().slice(0, 24)}"` + (thinkingModel ? c_note(retried) : '')
172
- : basic.ok
173
- ? got.thinking
174
- ? '사고만 나오고 본문이 안 나옵니다 — 토큰 상한을 크게 올려야 합니다'
175
- : '응답이 비어 있습니다 — 모델 이름을 확인하세요'
176
- // 못 붙은 까닭을 아는 자리가 하나 있다. 상태 코드만 남기면 사람은
177
- // 열쇠를 의심하는데, 열쇠도 이름도 처음부터 맞았다 (코덱스인가 머리말).
178
- : 코덱스인가(conn)
179
- ? `${serverMessage(basic)}\n${말('probe.codexOnly', { 모델: model })}`
180
- : serverMessage(basic),
181
- ms: basic.ms,
182
- });
183
- if (!basicOk) {
184
- for (const [id, label] of SKIPPED) {
185
- add({ id, label, status: 'skip', detail: '기본 대화가 안 되어 확인 불가', ms: 0 });
186
- }
187
- return { facts, results };
188
- }
189
-
190
- // 2. 시스템 메시지 — 규칙과 스킬이 먹느냐가 여기 달렸다.
191
- const sys = await call({
192
- ...quiet,
193
- messages: [
194
- { role: 'system', content: '너는 무슨 질문을 받든 정확히 DEEL 한 단어만 답한다.' },
195
- { role: 'user', content: '안녕하세요' },
196
- ],
197
- });
198
- const sysHit = sys.ok && /DEEL/i.test(읽기(sys).content);
199
- add({
200
- id: 'system',
201
- label: '시스템 메시지',
202
- status: sys.ok ? (sysHit ? 'ok' : 'warn') : 'no',
203
- detail: sys.ok
204
- ? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 적용됩니다'
205
- : serverMessage(sys),
206
- ms: sys.ms,
207
- });
208
-
209
- // 3. 스트리밍 — 화면이 한 글자씩 흐르느냐.
210
- const st = await call({
211
- ...quiet,
212
- messages: [{ role: 'user', content: '1부터 20까지 세어보세요.' }],
213
- stream: true,
214
- timeout: 45000,
215
- });
216
- let chunks = 0;
217
- let firstMs = 0;
218
- if (st.ok && st.res?.body) {
219
- const t0 = Date.now();
220
- try {
221
- const reader = st.res.body.getReader();
222
- const dec = new TextDecoder();
223
- while (chunks < 400) {
224
- const { done, value } = await reader.read();
225
- if (done) break;
226
- const text = dec.decode(value, { stream: true });
227
- const hits = (text.match(/(^|\n)data:|"done"\s*:/g) ?? []).length;
228
- if (hits && !firstMs) firstMs = Date.now() - t0;
229
- chunks += hits || (text.trim() ? 1 : 0);
230
- }
231
- reader.cancel().catch(() => {});
232
- } catch {}
233
- }
234
- add({
235
- id: 'stream',
236
- label: '스트리밍',
237
- status: chunks > 2 ? 'ok' : st.ok ? 'warn' : 'no',
238
- detail: chunks > 2
239
- ? `조각 ${chunks}개, 응답 ${firstMs}ms`
240
- : st.ok ? '한 번에 옵니다 화면은 스피너로 대체합니다' : serverMessage(st),
241
- ms: st.ms,
242
- });
243
- facts.streaming = chunks > 2;
244
-
245
- // 4. 도구 호출 — 에이전트의 생사가 걸린 검사.
246
- const tl = await call({
247
- ...quiet,
248
- messages: [{ role: 'user', content: 'config.json 파일을 읽어 주세요.' }],
249
- tools: [READ_TOOL],
250
- maxTokens: 512,
251
- timeout: 90000,
252
- });
253
- const tcalls = tl.ok ? 읽기(tl).toolCalls : [];
254
- const gotCall = tcalls.length > 0;
255
- // 부름은 이미 { id, name, args } 로 고르게 나온다 (extractMessage).
256
- // 예전에는 여기서 OpenAI 날모양(function.arguments)을 직접 팠다 —
257
- // 그러면 Anthropic 도구를 제대로 불러도 「안 불렀다」 로 읽힌다.
258
- const argOk = gotCall && JSON.stringify(tcalls[0]?.args ?? '').includes('config');
259
- add({
260
- id: 'tools',
261
- label: '도구 호출',
262
- status: gotCall ? (argOk ? 'ok' : 'warn') : 'no',
263
- detail: gotCall
264
- ? `${tcalls[0]?.name} 호출됨${argOk ? '' : ' — 인자가 부정확, 편집 신뢰성 작업이 더 필요합니다'}`
265
- : tl.ok ? '도구를 안 부르고 글로만 답합니다' : serverMessage(tl),
266
- ms: tl.ms,
267
- });
268
- facts.tools = gotCall;
269
-
270
- // 5. 도구 결과 되돌리기 여러 턴이 이어지느냐. 에이전트 루프의 전제다.
271
- if (gotCall) {
272
- const tc = tcalls[0];
273
- const callId = tc.id ?? 'call_1';
274
- /*
275
- * 되돌려 넣는 모양도 루프와 같은 함수로 짓는다 (adapter.js).
276
- *
277
- * 여기에 규격별 갈래를 손으로 적어 두면 규격이 하나 늘 때마다 이 자리가
278
- * 조용히 틀린다Anthropic 에서는 도구 결과가 사람 차례로 가야 하는데
279
- * `role:'tool'` 보내 「모르는 역할」 받고 있었다.
280
- */
281
- const assistantMsg = assistantMessage(shape, { content: '', toolCalls: [tc] });
282
- const toolMsg = toolMessage(shape, { callId, name: tc.name, content: '{"port": 7099}' });
283
- const rt = await call({
284
- ...quiet,
285
- timeout: 90000,
286
- messages: [
287
- { role: 'user', content: 'config.json 파일을 읽어 주세요.' },
288
- assistantMsg,
289
- toolMsg,
290
- { role: 'user', content: 'port 값이 몇인가요? 숫자만 답하세요.' },
291
- ],
292
- tools: [READ_TOOL],
293
- maxTokens: 256,
294
- });
295
- const said = rt.ok ? 읽기(rt).content : '';
296
- add({
297
- id: 'toolresult',
298
- label: '도구 결과 되돌리기',
299
- status: rt.ok ? (/7099/.test(said) ? 'ok' : 'warn') : 'no',
300
- detail: rt.ok
301
- ? /7099/.test(said) ? '결과를 읽고 이어서 답함' : `받긴 하나 활용이 약함 ("${said.trim().slice(0, 20)}")`
302
- : serverMessage(rt),
303
- ms: rt.ms,
304
- });
305
- } else {
306
- add({ id: 'toolresult', label: '도구 결과 되돌리기', status: 'skip', detail: '도구 호출이 안 되어 확인 불가', ms: 0 });
307
- }
308
-
309
- // 6. 구조적 출력 편집 형식을 강제할 수 있느냐.
310
- const schema = {
311
- type: 'object',
312
- properties: { answer: { type: 'number' } },
313
- required: ['answer'],
314
- additionalProperties: false,
315
- };
316
- // 한 번은 흔들릴 수 있으므로 실패하면 한 번만 더 본다.
317
- let js = null;
318
- let parsed = null;
319
- let raw = '';
320
- for (let attempt = 0; attempt < 2 && !parsed; attempt++) {
321
- js = await call({
322
- ...quiet,
323
- messages: [{ role: 'user', content: '3 곱하기 7은?' }],
324
- json: schema,
325
- maxTokens: 512,
326
- timeout: 90000,
327
- });
328
- if (!js.ok) break;
329
- raw = 읽기(js).content ?? '';
330
- /*
331
- * **객체일 때만** 읽었다고 한다.
332
- *
333
- * 모양을 강제하는 칸이 없는 규격(Anthropic)에서는 이 물음에 그냥
334
- * `21` 이라고 답한다. `JSON.parse('21')` 오류 없이 숫자 21 준다.
335
- * 그런데 그다음 줄이 `'answer' in parsed` 라, 숫자에 `in` 을 써서
336
- * **TypeError 로 진단 전체가 통째로 죽었다.** 스키마를 안 지킨 것은
337
- * 「구조적 출력 됨」 이라고 적을 일이지 프로그램이 끝날 일이 아니다.
338
- */
339
- try {
340
- const 읽힌것 = JSON.parse(raw);
341
- if (읽힌것 && typeof 읽힌것 === 'object' && !Array.isArray(읽힌것)) parsed = 읽힌것;
342
- } catch { /* 글로만 답하는 서버 — 아래에서 '스키마를 안 지킴' 으로 적는다 */ }
343
- }
344
- const jsonOk = !!(parsed && 'answer' in parsed);
345
- add({
346
- id: 'json',
347
- label: '구조적 출력',
348
- status: jsonOk ? 'ok' : js?.ok ? 'warn' : 'no',
349
- detail: jsonOk
350
- ? `스키마대로 반환 (answer=${parsed.answer})`
351
- : js?.ok
352
- ? `스키마를 안 지킴 — 받은 값 ${JSON.stringify(raw.slice(0, 60))} · 편집 형식을 프롬프트로 강제합니다`
353
- : serverMessage(js ?? {}),
354
- ms: js?.ms ?? 0,
355
- });
356
- facts.json = jsonOk;
357
-
358
- /*
359
- * 6.5 그림 — 이 모델이 그림을 볼 수 있느냐.
360
- *
361
- * 이름으로 짐작하지 않는다. 사내 게이트웨이는 `gpt-4o` 라는 이름 뒤에
362
- * 무엇이든 걸어 있고, 로컬에 받아 llava 계열은 이름이 제각각이다.
363
- * 한 번 물어보면 확실해지는 것을 짐작할 이유가 없다.
364
- *
365
- * 흰 점 하나짜리 1×1 PNG 로 묻는다 (vision.js). 무엇이 찍혀 있을지 모르는
366
- * 진짜 화면을 확인하자고 바깥으로 내보낼 수는 없다.
367
- *
368
- * 답의 내용은 안 본다. 흰 점 하나를 보고 무슨 말을 하든 상관없고, 우리가
369
- * 알고 싶은 것은 **서버가 그림이 든 메시지를 받아 주느냐** 하나다.
370
- * 받는 서버는 400 이나 415 로 거절한다.
371
- */
372
- const 눈 = await call({
373
- ...quiet,
374
- messages: [눈검사메시지(shape)],
375
- maxTokens: 32,
376
- timeout: 60000,
377
- });
378
- const 눈있음 = !!(눈.ok && 읽기(눈).content);
379
- add({
380
- id: 'vision',
381
- label: '그림 보기',
382
- status: 눈있음 ? 'ok' : 눈.ok ? 'warn' : 'no',
383
- detail: 눈있음
384
- ? `1×1 PNG 받아서 답함 Read·@ 화면 사진을 보여 줄 수 있습니다`
385
- : 눈.ok
386
- ? '그림을 받긴 했는데 답이 비었습니다 — 안 보이는 것으로 칩니다'
387
- : `${serverMessage(눈)} — 그림은 안 보냅니다`,
388
- ms: 눈.ms ?? 0,
389
- });
390
- facts.vision = 눈있음;
391
-
392
- /*
393
- * 7. 추론 강도 조절 — 낮음/높음이 실제로 다른 결과를 내느냐.
394
- *
395
- * 상한에 걸리면 둘 다 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
396
- *
397
- * Anthropic 규격은 여기서 상한을 더 크게 잡아야 한다. 그쪽은 강도를 말이
398
- * 아니라 **출력 상한 안에서 나가는 예산**으로 주는데(adapter.js
399
- * 생각예산), 1,500 으로는 답에 남길 자리를 빼고 나면 최소 예산 1,024 를
400
- * 만든다. 그러면 생각이 아예켜진 채로 두 번을 부르고, 이 칸은
401
- * 「차이 없음」 이라고 적는다 있는 모델에 대고 못 켠다고 적는 셈이다.
402
- */
403
- const THINK_CAP = shape === 'anthropic' ? 8000 : 1500;
404
- const seen = [];
405
- for (const lv of ['low', 'high']) {
406
- const r = await call({
407
- messages: [{ role: 'user', content: '17 곱하기 23은? 계산 과정을 보이세요.' }],
408
- think: lv,
409
- maxTokens: THINK_CAP,
410
- timeout: 120000,
411
- });
412
- if (r.ok) {
413
- const e = 읽기(r);
414
- seen.push({
415
- lv,
416
- ms: r.ms,
417
- thought: (e.thinking ?? '').length,
418
- // 토큰 수와 끝난 까닭도 규격마다 이름이 다르다. 읽는 자리를 하나로 모은다 —
419
- // 여기서 OpenAI 이름만 보면 Anthropic 은 늘 0 이고 늘 ' 잘림' 이 된다.
420
- out: e.usage?.out ?? 0,
421
- capped: e.stopped === 'length' || e.stopped === 'max_tokens',
422
- });
423
- } else {
424
- seen.push({ lv, ms: r.ms, err: serverMessage(r) });
425
- }
426
- }
427
- const bothOk = seen.every((s) => !s.err);
428
- const capped = bothOk && seen.every((s) => s.capped);
429
- // 사고 길이가 눈에 띄게 다르거나, 출력량이 20% 넘게 차이나야 "먹는다"고 본다.
430
- const thoughtGap = bothOk ? Math.abs(seen[0].thought - seen[1].thought) : 0;
431
- const outGap = bothOk ? Math.abs(seen[0].out - seen[1].out) : 0;
432
- const differs = bothOk && !capped &&
433
- (thoughtGap > Math.max(80, seen[0].thought * 0.2) || outGap > Math.max(20, seen[0].out * 0.2));
434
- const fmt = (s) => `${s.lv === 'low' ? '낮음' : '높음'} 사고 ${s.thought}자/출력 ${s.out}토큰/${s.ms}ms`;
435
- add({
436
- id: 'think',
437
- label: '추론 강도 조절',
438
- status: !bothOk ? 'no' : capped ? 'warn' : differs ? 'ok' : 'warn',
439
- detail: !bothOk
440
- ? `파라미터 거부됨 — ${seen.find((s) => s.err)?.err}`
441
- : capped
442
- ? `둘 토큰 상한(${THINK_CAP}) 걸려 비교 불가 — 루프 층에서 조절합니다`
443
- : differs
444
- ? `${fmt(seen[0])} · ${fmt(seen[1])}`
445
- : `차이 없음 (${fmt(seen[0])} · ${fmt(seen[1])}) 루프 층에서 조절합니다`,
446
- ms: seen.reduce((a, s) => a + (s.ms ?? 0), 0),
447
- });
448
- facts.think = differs;
449
-
450
- // 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
451
- //
452
- // 자리만 보지 않는다. 서버마다 이름도 자리도 다르다 (ctxsize.js 참고).
453
- // 여기서 작게 잡으면 프로그램 전체가 작아진다 — 답 길이 상한까지 이 값에서 나온다.
454
- const 길이 = await probeCtx({ kind: shape, base, auth, key, model });
455
- const ctx = 길이.value;
456
- add({
457
- id: 'ctx',
458
- label: '컨텍스트 길이',
459
- status: ctx ? 'ok' : 'warn',
460
- detail: ctx
461
- ? `${ctx.toLocaleString()} 토큰 (${길이.source ?? '모델 정보'}에서 읽음)`
462
- + (길이.max && 길이.loaded && 길이.max > 길이.loaded
463
- ? ` · 이 모델은 ${길이.max.toLocaleString()} 까지 되는데 지금 ${길이.loaded.toLocaleString()} 로 올려 두셨습니다`
464
- : '')
465
- : '서버가 알려주지 않음 — /ctx 로 직접 지정하세요',
466
- ms: 0,
467
- });
468
- facts.ctx = ctx;
469
- facts.ctxMax = 길이.max ?? null;
470
- facts.ctxLoaded = 길이.loaded ?? null;
471
-
472
- return { facts, results };
473
- }
1
+ // 게이트웨이/로컬서버가 "에이전트를 돌릴 수 있는지"를 실제 요청으로 확인한다.
2
+ // 각 검사는 { id, label, status, detail, ms } 를 돌려준다.
3
+ // ok 되는 것을 확인함
4
+ // no 안 됨 (기능에 직접 영향)
5
+ // warn 되긴 하는데 조건이 붙음
6
+ // skip 앞 검사가 실패해 확인 불가
7
+ import { req, headersFor, serverMessage } from './http.js';
8
+ import { probeCtx } from './ctxsize.js';
9
+ import { 눈검사메시지 } from './vision.js';
10
+ import {
11
+ 주소붙이기, endpoint, 더할머리, buildBody, extractMessage,
12
+ assistantMessage, toolMessage,
13
+ } from './adapter.js';
14
+ import { 벤더 } from './toolfit.js';
15
+ import { 말 } from '../i18n/index.js';
16
+
17
+ const READ_TOOL = {
18
+ type: 'function',
19
+ function: {
20
+ name: 'read_file',
21
+ description: '파일 하나의 내용을 읽는다',
22
+ parameters: {
23
+ type: 'object',
24
+ properties: { path: { type: 'string', description: '읽을 파일 경로' } },
25
+ required: ['path'],
26
+ },
27
+ },
28
+ };
29
+
30
+ /*
31
+ * ── 규격 차이는 여기서 흡수하지 않는다 ──────────────────────────────────
32
+ *
33
+ * 여기에 build() 와 extract() 가 따로 있었다. 그 둘이 아는 규격은 `ollama` 와
34
+ * 「나머지 = OpenAI」 **둘뿐**이었고, `anthropic` 은 「나머지」 로 떨어졌다.
35
+ * 그래서 Claude 를 직접 붙이면 진단이 이렇게 갔다 —
36
+ *
37
+ * · 판 머리(anthropic-version)를 안 얹는다 → 그 하나로 400
38
+ * · 문 이름이 /chat/completions (있어야 할 것은 /messages)
39
+ * · 시킴말이 messages 안으로 들어간다 → 「모르는 역할」
40
+ * · 도구가 {type:'function', function:{…}} 모양으로 간다
41
+ * · 답을 choices[0].message 에서 찾는다 (실제로는 content 블록 배열)
42
+ *
43
+ * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 일곱 칸은 전부 「확인 불가」 로
44
+ * 건너뛴다. 그 결과 `deel setup` 이 저장하는 프로필에 streaming·tools·json·
45
+ * vision 이 **다 false** 로 적힌다 — Claude 를 붙였는데 도구를 아예 안 쓰는
46
+ * 연결이 만들어진다. 붙기는 붙으니 아무도 고장이라고 생각하지 않는다.
47
+ *
48
+ * adapter.js 첫 줄은 처음부터 "진단(probe)과 에이전트 루프가 같은 함수를
49
+ * 쓴다" 고 적어 두었다. 실제로는 안 썼다. 이제 쓴다 — 규격이 넷째가 되어도
50
+ * 이 파일은 안 고쳐도 된다.
51
+ */
52
+
53
+ /** 이 회사가 무엇을 받는지까지 봐야 몸통이 맞다 (toolfit.js 의 벤더). */
54
+ function 몸통(conn, opts) {
55
+ return buildBody(conn.kind, { model: conn.model, 회사: 벤더(conn), maxTokens: 128, ...opts });
56
+ }
57
+
58
+ // 추론 모델일 때 기본 대화 칸에 덧붙일 설명.
59
+ function c_note(retried) {
60
+ return retried ? ' (추론 모델 — 사고를 끄고 다시 물어 확인)' : ' (추론 모델)';
61
+ }
62
+
63
+ /*
64
+ * ── 이 문으로는 안 나오는 모델 (Codex 계열) ─────────────────────────────
65
+ *
66
+ * `gpt-5-codex` 계열은 OpenAI 가 **Responses API(/responses)로만** 내준다.
67
+ * `/chat/completions` 로 물으면 404 나 400 이 오는데, 그 화면에는 상태 코드
68
+ * 한 줄만 남는다. 그러면 사람은 열쇠를 의심하거나 모델 이름을 잘못 적은 줄
69
+ * 알고, 맞는 이름을 몇 번씩 다시 넣어 본다 — 이름은 처음부터 맞았다.
70
+ *
71
+ * ── 왜 이름을 보나. 이 파일에서만 본다 ─────────────────────────────────
72
+ *
73
+ * 어디로 보낼지는 **주소로만** 정한다(1.9). 그 규칙은 여기서도 안 깬다 —
74
+ * 여기서 이름을 보는 것은 보낼 곳을 고르려는 것이 아니라, **이미 실패한
75
+ * 요청에 까닭을 붙이려는** 것뿐이다. 그래서 미리 막지 않는다. 요청은 그대로
76
+ * 나가고, 실패했을 때만 한 줄이 붙는다. 언젠가 OpenAI 가 이 문으로도 내주면
77
+ * 요청이 그냥 성공하고 이 줄은 저절로 안 나온다.
78
+ *
79
+ * 게이트웨이는 해당 없다. 그 뒤에 무엇이 걸려 있는지 우리는 모르고, 사내
80
+ * 게이트웨이가 제 나름대로 이 계열을 chat 창구로 내주는 일도 있다.
81
+ */
82
+ const CODEX계열 = /^(?:gpt-5-codex|codex-)/i;
83
+
84
+ export function 코덱스인가(conn) {
85
+ return 벤더(conn) === 'openai' && CODEX계열.test(String(conn?.model ?? ''));
86
+ }
87
+
88
+ const SKIPPED = [
89
+ ['system', '시스템 메시지'],
90
+ ['stream', '스트리밍'],
91
+ ['tools', '도구 호출'],
92
+ ['toolresult', '도구 결과 되돌리기'],
93
+ ['json', '구조적 출력'],
94
+ ['vision', '그림 보기'],
95
+ ['think', '추론 강도 조절'],
96
+ ['ctx', '컨텍스트 길이'],
97
+ ];
98
+
99
+ export async function probe(conn, onStep = () => {}) {
100
+ const { kind: shape, base, auth, model } = conn;
101
+ const key = conn.key ?? '';
102
+ /*
103
+ * 판 머리를 여기서도 얹는다 (adapter.js 의 더할머리).
104
+ *
105
+ * Anthropic 규격은 `anthropic-version` 하나가 없으면 400 이다. 열쇠가
106
+ * 멀쩡해도 그렇다. 진단 화면은 그 400 을 「연결 실패」 로 적고, 사람은
107
+ * 열쇠를 다시 받으러 간다 — 여덟 칸이 전부 그 한 줄 때문에 빨개진다.
108
+ */
109
+ const H = () => headersFor(auth, key, 더할머리(shape));
110
+ /*
111
+ * 물음표 뒤를 끝에 남겨야 한다 (adapter.js 의 주소붙이기).
112
+ *
113
+ * 이 한 줄이 예전에 `${base}${p}` 였다. Azure base 에는 `?api-version=` 이
114
+ * 붙어 있어서, 그대로 이으면
115
+ * `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 된다.
116
+ * 그러면 **설치 화면이 제 검사에 통째로 실패한다** — 기본 대화가 안 되니
117
+ * 나머지 일곱 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
118
+ * 도구 호출도 안 된다고 적힌다. 붙기는 붙는데 반쪽짜리로 붙는다.
119
+ */
120
+ const url = (p) => 주소붙이기(base, p);
121
+ const results = [];
122
+ const facts = { shape, base, auth, model };
123
+
124
+ const add = (r) => { results.push(r); onStep(r); return r; };
125
+ const call = (opts) => req(url(endpoint(shape)), {
126
+ method: 'POST',
127
+ headers: H(),
128
+ body: 몸통(conn, opts),
129
+ timeout: opts.timeout ?? 60000,
130
+ stream: opts.stream,
131
+ });
132
+ // 답 읽기도 루프와 같은 함수를 쓴다. 도구 부름은 { id, name, args } 로
133
+ // 고르게 나온다 — 규격마다 다른 자리를 여기서 또 헤아리지 않는다.
134
+ const 읽기 = (r) => extractMessage(shape, r?.json);
135
+
136
+ // 1. 기본 대화 — 이게 안 되면 나머지는 볼 필요가 없다.
137
+ // 추론 모델은 본문이 전부 thinking 으로 가고 토큰 상한에 잘린다.
138
+ // 그걸 "안됨"으로 볼 수 없으므로, 사고를 끄고 넉넉히 한 번 더 물어본다.
139
+ const ASK = { messages: [{ role: 'user', content: '1+1은? 숫자만 답하세요.' }] };
140
+ let basic = await call({ ...ASK, maxTokens: 256 });
141
+ let got = basic.ok ? 읽기(basic) : { content: '', thinking: '' };
142
+ let thinkingModel = false;
143
+ let retried = false;
144
+
145
+ if (basic.ok && !got.content && got.thinking) {
146
+ thinkingModel = true;
147
+ retried = true;
148
+ const second = await call({ ...ASK, maxTokens: 1024, think: false, timeout: 90000 });
149
+ if (second.ok) {
150
+ const e2 = 읽기(second);
151
+ if (e2.content) { basic = second; got = e2; }
152
+ else {
153
+ // 사고를 못 끄는 서버 — 상한만 크게 올려 한 번 더.
154
+ const third = await call({ ...ASK, maxTokens: 2048, timeout: 120000 });
155
+ if (third.ok && 읽기(third).content) { basic = third; got = 읽기(third); }
156
+ }
157
+ }
158
+ }
159
+
160
+ const basicText = got.content;
161
+ const basicOk = basic.ok && !!basicText;
162
+ facts.thinkingModel = thinkingModel;
163
+ // 사고를 끌 수 있는 모델이면 이후 검사에서 꺼서 토큰과 시간을 아낀다.
164
+ const quiet = shape === 'ollama' && thinkingModel ? { think: false, maxTokens: 512 } : {};
165
+
166
+ add({
167
+ id: 'chat',
168
+ label: '기본 대화',
169
+ status: basicOk ? 'ok' : 'no',
170
+ detail: basicOk
171
+ ? `응답 "${basicText.trim().slice(0, 24)}"` + (thinkingModel ? c_note(retried) : '')
172
+ : basic.ok
173
+ ? got.thinking
174
+ ? '사고만 나오고 본문이 안 나옵니다 — 토큰 상한을 크게 올려야 합니다'
175
+ : '응답이 비어 있습니다 — 모델 이름을 확인하세요'
176
+ // 못 붙은 까닭을 아는 자리가 하나 있다. 상태 코드만 남기면 사람은
177
+ // 열쇠를 의심하는데, 열쇠도 이름도 처음부터 맞았다 (코덱스인가 머리말).
178
+ : 코덱스인가(conn)
179
+ ? `${serverMessage(basic)}\n${말('probe.codexOnly', { 모델: model })}`
180
+ : serverMessage(basic),
181
+ ms: basic.ms,
182
+ });
183
+ if (!basicOk) {
184
+ for (const [id, label] of SKIPPED) {
185
+ add({ id, label, status: 'skip', detail: '기본 대화가 안 되어 확인 불가', ms: 0 });
186
+ }
187
+ return { facts, results };
188
+ }
189
+
190
+ // 2. 시스템 메시지 — 규칙과 스킬이 먹느냐가 여기 달렸다.
191
+ const sys = await call({
192
+ ...quiet,
193
+ messages: [
194
+ { role: 'system', content: '너는 무슨 질문을 받든 정확히 DEEL 한 단어만 답한다.' },
195
+ { role: 'user', content: '안녕하세요' },
196
+ ],
197
+ });
198
+ const sysHit = sys.ok && /DEEL/i.test(읽기(sys).content);
199
+ add({
200
+ id: 'system',
201
+ label: '시스템 메시지',
202
+ status: sys.ok ? (sysHit ? 'ok' : 'warn') : 'no',
203
+ detail: sys.ok
204
+ ? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 적용됩니다'
205
+ : serverMessage(sys),
206
+ ms: sys.ms,
207
+ });
208
+
209
+ // 3. 스트리밍 — 화면이 한 글자씩 흐르느냐.
210
+ const st = await call({
211
+ ...quiet,
212
+ messages: [{ role: 'user', content: '1부터 20까지 세어보세요.' }],
213
+ stream: true,
214
+ timeout: 45000,
215
+ });
216
+ let chunks = 0;
217
+ let firstMs = 0;
218
+ let 읽다실패 = null;
219
+ if (st.ok && st.res?.body) {
220
+ const t0 = Date.now();
221
+ try {
222
+ const reader = st.res.body.getReader();
223
+ const dec = new TextDecoder();
224
+ while (chunks < 400) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+ const text = dec.decode(value, { stream: true });
228
+ const hits = (text.match(/(^|\n)data:|"done"\s*:/g) ?? []).length;
229
+ if (hits && !firstMs) firstMs = Date.now() - t0;
230
+ chunks += hits || (text.trim() ? 1 : 0);
231
+ }
232
+ reader.cancel().catch(() => {});
233
+ } catch (e) {
234
+ /*
235
+ * 여기가 빈 catch 였다.
236
+ *
237
+ * 그러면 `getReader()` 던진 TypeError(본문이 우리가 기대한 꼴이
238
+ * 아니다)도 삼켜지고, 조각이 0개인 채로 아래에서 **「한 번에 옵니다」**
239
+ * 라고 적힌다. 우리 잘못이 서버 진단으로 둔갑하는 것이다 — 사람은
240
+ * 멀쩡한 창구를 스트리밍 되는 창구로 알고 쓴다.
241
+ *
242
+ * 끊긴 것(Abort)은 진짜로 예상한 일이라 그대로 넘긴다. 그 밖은 적는다.
243
+ */
244
+ if (e?.name !== 'AbortError') 읽다실패 = String(e?.message ?? e).slice(0, 120);
245
+ }
246
+ }
247
+ add({
248
+ id: 'stream',
249
+ label: '스트리밍',
250
+ status: chunks > 2 ? 'ok' : (st.ok && !읽다실패) ? 'warn' : 'no',
251
+ detail: chunks > 2
252
+ ? `조각 ${chunks}개, 첫 응답 ${firstMs}ms`
253
+ : 읽다실패 ? `흘러오는 것을 읽지 못했습니다 — ${읽다실패}`
254
+ : st.ok ? '한 번에 옵니다 — 화면은 스피너로 대체합니다' : serverMessage(st),
255
+ ms: st.ms,
256
+ });
257
+ facts.streaming = chunks > 2;
258
+
259
+ // 4. 도구 호출 — 에이전트의 생사가 걸린 검사.
260
+ const tl = await call({
261
+ ...quiet,
262
+ messages: [{ role: 'user', content: 'config.json 파일을 읽어 주세요.' }],
263
+ tools: [READ_TOOL],
264
+ maxTokens: 512,
265
+ timeout: 90000,
266
+ });
267
+ const tcalls = tl.ok ? 읽기(tl).toolCalls : [];
268
+ const gotCall = tcalls.length > 0;
269
+ // 부름은 이미 { id, name, args } 로 고르게 나온다 (extractMessage).
270
+ // 예전에는 여기서 OpenAI 날모양(function.arguments)을 직접 팠다
271
+ // 그러면 Anthropic 은 도구를 제대로 불러도 「안 불렀다」 로 읽힌다.
272
+ const argOk = gotCall && JSON.stringify(tcalls[0]?.args ?? '').includes('config');
273
+ add({
274
+ id: 'tools',
275
+ label: '도구 호출',
276
+ status: gotCall ? (argOk ? 'ok' : 'warn') : 'no',
277
+ detail: gotCall
278
+ ? `${tcalls[0]?.name} 호출됨${argOk ? '' : ' 인자가 부정확, 편집 신뢰성 작업이 필요합니다'}`
279
+ : tl.ok ? '도구를 부르고 글로만 답합니다' : serverMessage(tl),
280
+ ms: tl.ms,
281
+ });
282
+ facts.tools = gotCall;
283
+
284
+ // 5. 도구 결과 되돌리기 — 여러 턴이 이어지느냐. 에이전트 루프의 전제다.
285
+ if (gotCall) {
286
+ const tc = tcalls[0];
287
+ const callId = tc.id ?? 'call_1';
288
+ /*
289
+ * 되돌려 넣는 모양도 루프와 같은 함수로 짓는다 (adapter.js).
290
+ *
291
+ * 여기에 규격별 갈래를 손으로 적어 두면 규격이 하나 늘 때마다 이 자리가
292
+ * 조용히 틀린다 — Anthropic 에서는 도구 결과가 사람 차례로 가야 하는데
293
+ * `role:'tool'` 로 보내 「모르는 역할」 을 받고 있었다.
294
+ */
295
+ const assistantMsg = assistantMessage(shape, { content: '', toolCalls: [tc] });
296
+ const toolMsg = toolMessage(shape, { callId, name: tc.name, content: '{"port": 7099}' });
297
+ const rt = await call({
298
+ ...quiet,
299
+ timeout: 90000,
300
+ messages: [
301
+ { role: 'user', content: 'config.json 파일을 읽어 주세요.' },
302
+ assistantMsg,
303
+ toolMsg,
304
+ { role: 'user', content: 'port 값이 몇인가요? 숫자만 답하세요.' },
305
+ ],
306
+ tools: [READ_TOOL],
307
+ maxTokens: 256,
308
+ });
309
+ const said = rt.ok ? 읽기(rt).content : '';
310
+ add({
311
+ id: 'toolresult',
312
+ label: '도구 결과 되돌리기',
313
+ status: rt.ok ? (/7099/.test(said) ? 'ok' : 'warn') : 'no',
314
+ detail: rt.ok
315
+ ? /7099/.test(said) ? '결과를 읽고 이어서 답함' : `받긴 하나 활용이 약함 ("${said.trim().slice(0, 20)}")`
316
+ : serverMessage(rt),
317
+ ms: rt.ms,
318
+ });
319
+ } else {
320
+ add({ id: 'toolresult', label: '도구 결과 되돌리기', status: 'skip', detail: '도구 호출이 안 되어 확인 불가', ms: 0 });
321
+ }
322
+
323
+ // 6. 구조적 출력 편집 형식을 강제할 수 있느냐.
324
+ const schema = {
325
+ type: 'object',
326
+ properties: { answer: { type: 'number' } },
327
+ required: ['answer'],
328
+ additionalProperties: false,
329
+ };
330
+ // 한 번은 흔들릴 수 있으므로 실패하면 한 번만 더 본다.
331
+ let js = null;
332
+ let parsed = null;
333
+ let raw = '';
334
+ for (let attempt = 0; attempt < 2 && !parsed; attempt++) {
335
+ js = await call({
336
+ ...quiet,
337
+ messages: [{ role: 'user', content: '3 곱하기 7은?' }],
338
+ json: schema,
339
+ maxTokens: 512,
340
+ timeout: 90000,
341
+ });
342
+ if (!js.ok) break;
343
+ raw = 읽기(js).content ?? '';
344
+ /*
345
+ * **객체일 때만** 읽었다고 한다.
346
+ *
347
+ * 모양을 강제하는 칸이 없는 규격(Anthropic)에서는 이 물음에 그냥
348
+ * `21` 이라고 답한다. `JSON.parse('21')` 오류 없이 숫자 21 을 준다.
349
+ * 그런데 그다음 줄이 `'answer' in parsed` 라, 숫자에 `in` 을 써서
350
+ * **TypeError 진단 전체가 통째로 죽었다.** 스키마를 안 지킨 것은
351
+ * 「구조적 출력 안 됨」 이라고 적을 일이지 프로그램이 끝날 일이 아니다.
352
+ */
353
+ try {
354
+ const 읽힌것 = JSON.parse(raw);
355
+ if (읽힌것 && typeof 읽힌것 === 'object' && !Array.isArray(읽힌것)) parsed = 읽힌것;
356
+ } catch { /* 글로만 답하는 서버 — 아래에서 '스키마를 안 지킴' 으로 적는다 */ }
357
+ }
358
+ const jsonOk = !!(parsed && 'answer' in parsed);
359
+ add({
360
+ id: 'json',
361
+ label: '구조적 출력',
362
+ status: jsonOk ? 'ok' : js?.ok ? 'warn' : 'no',
363
+ detail: jsonOk
364
+ ? `스키마대로 반환 (answer=${parsed.answer})`
365
+ : js?.ok
366
+ ? `스키마를 지킴 받은 ${JSON.stringify(raw.slice(0, 60))} · 편집 형식을 프롬프트로 강제합니다`
367
+ : serverMessage(js ?? {}),
368
+ ms: js?.ms ?? 0,
369
+ });
370
+ facts.json = jsonOk;
371
+
372
+ /*
373
+ * 6.5 그림 — 이 모델이 그림을 볼 수 있느냐.
374
+ *
375
+ * 이름으로 짐작하지 않는다. 사내 게이트웨이는 `gpt-4o` 라는 이름 뒤에
376
+ * 무엇이든 걸어 둘 수 있고, 로컬에 받아 둔 llava 계열은 이름이 제각각이다.
377
+ * 한 번 물어보면 확실해지는 것을 짐작할 이유가 없다.
378
+ *
379
+ * 흰 점 하나짜리 1×1 PNG 로 묻는다 (vision.js). 무엇이 찍혀 있을지 모르는
380
+ * 진짜 화면을 확인하자고 바깥으로 내보낼 수는 없다.
381
+ *
382
+ * 답의 내용은 본다. 하나를 보고 무슨 말을 하든 상관없고, 우리가
383
+ * 알고 싶은 것은 **서버가 그림이 든 메시지를 받아 주느냐** 하나다.
384
+ * 받는 서버는 400 이나 415거절한다.
385
+ */
386
+ const = await call({
387
+ ...quiet,
388
+ messages: [눈검사메시지(shape)],
389
+ maxTokens: 32,
390
+ timeout: 60000,
391
+ });
392
+ const 눈있음 = !!(눈.ok && 읽기(눈).content);
393
+ add({
394
+ id: 'vision',
395
+ label: '그림 보기',
396
+ status: 눈있음 ? 'ok' : 눈.ok ? 'warn' : 'no',
397
+ detail: 눈있음
398
+ ? `1×1 PNG 받아서 답함 Read·@ 로 화면 사진을 보여 줄 수 있습니다`
399
+ : 눈.ok
400
+ ? '그림을 받긴 했는데 답이 비었습니다 보이는 것으로 칩니다'
401
+ : `${serverMessage(눈)}그림은 보냅니다`,
402
+ ms: 눈.ms ?? 0,
403
+ });
404
+ facts.vision = 눈있음;
405
+
406
+ /*
407
+ * 7. 추론 강도 조절 낮음/높음이 실제로 다른 결과를 내느냐.
408
+ *
409
+ * 상한에 걸리면 둘 다 같은 숫자가 나와 비교가 무의미해진다. 넉넉히 준다.
410
+ *
411
+ * Anthropic 규격은 여기서 상한을 더 크게 잡아야 한다. 그쪽은 강도를 말이
412
+ * 아니라 **출력 상한 안에서 나가는 예산**으로 주는데(adapter.js
413
+ * 생각예산), 1,500 으로는 답에 남길 자리를 빼고 나면 최소 예산 1,024 를
414
+ * 못 만든다. 그러면 생각이 아예 안 켜진 채로 두 번을 부르고, 이 칸은
415
+ * 「차이 없음」 이라고 적는다 — 켤 수 있는 모델에 대고 못 켠다고 적는 셈이다.
416
+ */
417
+ const THINK_CAP = shape === 'anthropic' ? 8000 : 1500;
418
+ const seen = [];
419
+ for (const lv of ['low', 'high']) {
420
+ const r = await call({
421
+ messages: [{ role: 'user', content: '17 곱하기 23은? 계산 과정을 보이세요.' }],
422
+ think: lv,
423
+ maxTokens: THINK_CAP,
424
+ timeout: 120000,
425
+ });
426
+ if (r.ok) {
427
+ const e = 읽기(r);
428
+ seen.push({
429
+ lv,
430
+ ms: r.ms,
431
+ thought: (e.thinking ?? '').length,
432
+ // 토큰 수와 끝난 까닭도 규격마다 이름이 다르다. 읽는 자리를 하나로 모은다 —
433
+ // 여기서 OpenAI 이름만 보면 Anthropic 은 늘 0 이고 '안 잘림' 된다.
434
+ out: e.usage?.out ?? 0,
435
+ capped: e.stopped === 'length' || e.stopped === 'max_tokens',
436
+ });
437
+ } else {
438
+ seen.push({ lv, ms: r.ms, err: serverMessage(r) });
439
+ }
440
+ }
441
+ const bothOk = seen.every((s) => !s.err);
442
+ const capped = bothOk && seen.every((s) => s.capped);
443
+ // 사고 길이가 눈에 띄게 다르거나, 출력량이 20% 넘게 차이나야 "먹는다"고 본다.
444
+ const thoughtGap = bothOk ? Math.abs(seen[0].thought - seen[1].thought) : 0;
445
+ const outGap = bothOk ? Math.abs(seen[0].out - seen[1].out) : 0;
446
+ const differs = bothOk && !capped &&
447
+ (thoughtGap > Math.max(80, seen[0].thought * 0.2) || outGap > Math.max(20, seen[0].out * 0.2));
448
+ const fmt = (s) => `${s.lv === 'low' ? '낮음' : '높음'} 사고 ${s.thought}자/출력 ${s.out}토큰/${s.ms}ms`;
449
+ add({
450
+ id: 'think',
451
+ label: '추론 강도 조절',
452
+ status: !bothOk ? 'no' : capped ? 'warn' : differs ? 'ok' : 'warn',
453
+ detail: !bothOk
454
+ ? `파라미터 거부됨 ${seen.find((s) => s.err)?.err}`
455
+ : capped
456
+ ? `둘 다 토큰 상한(${THINK_CAP})에 걸려 비교 불가 — 루프 층에서 조절합니다`
457
+ : differs
458
+ ? `${fmt(seen[0])} · ${fmt(seen[1])}`
459
+ : `차이 없음 (${fmt(seen[0])} · ${fmt(seen[1])}) — 루프 층에서 조절합니다`,
460
+ ms: seen.reduce((a, s) => a + (s.ms ?? 0), 0),
461
+ });
462
+ facts.think = differs;
463
+
464
+ // 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
465
+ //
466
+ // 한 자리만 보지 않는다. 서버마다 이름도 자리도 다르다 (ctxsize.js 참고).
467
+ // 여기서 작게 잡으면 프로그램 전체가 작아진다 — 답 길이 상한까지 이 값에서 나온다.
468
+ const 길이 = await probeCtx({ kind: shape, base, auth, key, model });
469
+ const ctx = 길이.value;
470
+ add({
471
+ id: 'ctx',
472
+ label: '컨텍스트 길이',
473
+ status: ctx ? 'ok' : 'warn',
474
+ detail: ctx
475
+ ? `${ctx.toLocaleString()} 토큰 (${길이.source ?? '모델 정보'}에서 읽음)`
476
+ + (길이.max && 길이.loaded && 길이.max > 길이.loaded
477
+ ? ` · 이 모델은 ${길이.max.toLocaleString()} 까지 되는데 지금 ${길이.loaded.toLocaleString()} 로 올려 두셨습니다`
478
+ : '')
479
+ : '서버가 알려주지 않음 — /ctx 로 직접 지정하세요',
480
+ ms: 0,
481
+ });
482
+ facts.ctx = ctx;
483
+ facts.ctxMax = 길이.max ?? null;
484
+ facts.ctxLoaded = 길이.loaded ?? null;
485
+
486
+ return { facts, results };
487
+ }