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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * 요금 셈 — 쓴 토큰을 돈으로 옮긴다.
3
+ *
4
+ * ── 이 파일에는 값이 박힌 요금표가 없다 ────────────────────────────────
5
+ *
6
+ * 일부러 안 넣었다. 요금은 회사가 아무 때나 바꾸는 값이고, 소스에 박아 두면
7
+ * 고칠 사람이 없다. 그러면 이 도구는 **틀린 금액을 자신 있게 찍는 도구**가
8
+ * 된다. 토큰 수를 안 보여 주는 것보다 그쪽이 훨씬 나쁘다 — 사람은 화면에
9
+ * 뜬 숫자를 믿고 예산을 잡는다.
10
+ *
11
+ * 그래서 값의 출처는 둘뿐이다.
12
+ *
13
+ * 1. 사람이 설정에 적은 값 (.deel/config.json 의 `요금`)
14
+ * 2. 제공자 서술 파일이 들고 있는 값 (src/providers/*.js 의 `요금`)
15
+ *
16
+ * 2번은 지금 전부 비어 있다. 확인한 값만 넣기로 했고, 확인하지 못했다.
17
+ * 비어 있는 표는 「모른다」 고 말하지만, 지어낸 표는 거짓말을 한다.
18
+ *
19
+ * 모르면 돈을 안 찍는다. 토큰만 찍고 「요금 모름」 이라고 적는다.
20
+ *
21
+ * ── 단위 ───────────────────────────────────────────────────────────────
22
+ *
23
+ * 100만 토큰당 달러로 적는다. 회사들이 요금표를 그 단위로 내놓기 때문이다.
24
+ * 사람이 요금표에서 본 숫자를 그대로 옮겨 적을 수 있어야 한다. 여기서
25
+ * 1000토큰당으로 받으면 1000배 틀린 값이 조용히 들어온다.
26
+ */
27
+ import { 제공자고르기 } from '../providers/index.js';
28
+
29
+ // 100만 토큰당. 요금표에 적힌 숫자를 그대로 받기 위한 단위다.
30
+ export const 단위 = 1000000;
31
+
32
+ /*
33
+ * 기준 날짜가 이만큼 지나면 「오래됨」 이라고 덧붙인다.
34
+ *
35
+ * 값을 버리지는 않는다. 오래된 값도 자릿수는 맞으므로 안 보여 주는 것보다
36
+ * 낫다. 다만 그대로 믿게 두면 안 된다. 반년이면 회사가 요금을 한 번쯤
37
+ * 손대는 기간이라 이렇게 잡았다.
38
+ */
39
+ export const 낡는날 = 180;
40
+
41
+ /**
42
+ * 설정에 적힌 값이 쓸 수 있는 요금인가.
43
+ *
44
+ * 문자열로 적어도 받는다 — 사람이 JSON 에 "2.5" 라고 적는 일은 흔하고,
45
+ * 그걸 튕기면 조용히 「요금 모름」 이 되어 왜 안 나오는지 알 길이 없다.
46
+ * 대신 숫자가 아닌 것·음수는 받지 않고, 왜 못 받았는지 말로 남긴다.
47
+ */
48
+ function 값읽기(x) {
49
+ if (x == null || x === '') return null;
50
+ const v = typeof x === 'string' ? Number(x.trim()) : Number(x);
51
+ if (!Number.isFinite(v) || v < 0) return null;
52
+ return v;
53
+ }
54
+
55
+ /**
56
+ * 모델 이름이 이 열쇠에 걸리나.
57
+ *
58
+ * 딱 맞는 이름이 먼저다. `*` 를 쓴 열쇠(`gpt-4o*`)는 사람이 **일부러**
59
+ * 적었을 때만 걸린다 — 우리가 알아서 앞머리를 잘라 맞추면, 값이 다른
60
+ * 형제 모델(`-mini`)에 비싼 요금이 붙어도 아무도 모른다.
61
+ */
62
+ function 걸리나(열쇠, 모델) {
63
+ if (열쇠 === 모델) return { 맞나: true, 딱: true };
64
+ if (!열쇠.includes('*')) return { 맞나: false };
65
+ const 무늬 = new RegExp(`^${열쇠.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
66
+ return { 맞나: 무늬.test(모델), 딱: false };
67
+ }
68
+
69
+ function 한칸읽기(열쇠, 값, 어디서) {
70
+ const 입 = 값읽기(값?.입력 ?? 값?.in ?? 값?.input);
71
+ const 출 = 값읽기(값?.출력 ?? 값?.out ?? 값?.output);
72
+ if (입 == null || 출 == null) {
73
+ return { 탈: `${어디서} 의 '${열쇠}' 요금이 숫자가 아닙니다 (입력·출력 둘 다 필요합니다)` };
74
+ }
75
+ const 기준 = 값?.기준 ?? 값?.asOf ?? null;
76
+ return { 입력: 입, 출력: 출, 기준: 기준 ? String(기준) : null, 어디서, 열쇠 };
77
+ }
78
+
79
+ /**
80
+ * 이 모델의 요금을 찾는다.
81
+ *
82
+ * 사람이 적은 값이 먼저다. 딸려 온 표가 낡았을 때 사람이 고칠 방법이
83
+ * 그것뿐이라 그렇다. 못 찾으면 null 을 준다 — 0 이 아니다. 0 을 주면
84
+ * 「공짜」 와 「모름」 이 화면에서 같아진다.
85
+ *
86
+ * @param {string} 모델
87
+ * @param {{설정?: object|null, 제공자?: object|null, 이제?: number}} o
88
+ */
89
+ export function 요금찾기(모델, { 설정 = null, 제공자 = null, 이제 = Date.now() } = {}) {
90
+ const 이름 = String(모델 ?? '').trim();
91
+ if (!이름) return null;
92
+ const 탈들 = [];
93
+
94
+ for (const [표, 어디서] of [[설정, '설정'], [제공자?.요금, '딸려 온 표']]) {
95
+ if (!표 || typeof 표 !== 'object') continue;
96
+ // 딱 맞는 이름 먼저, 그다음에 사람이 `*` 로 적어 둔 것.
97
+ for (const 딱먼저 of [true, false]) {
98
+ for (const [열쇠, 값] of Object.entries(표)) {
99
+ const g = 걸리나(열쇠, 이름);
100
+ if (!g.맞나 || !!g.딱 !== 딱먼저) continue;
101
+ const 읽은 = 한칸읽기(열쇠, 값, 어디서);
102
+ if (읽은.탈) { 탈들.push(읽은.탈); continue; }
103
+ const 기준 = 읽은.기준 ?? (어디서 === '딸려 온 표' ? 제공자?.요금기준 ?? null : null);
104
+ return { ...읽은, 기준, 낡았나: 낡았나(기준, 이제), 탈들 };
105
+ }
106
+ }
107
+ }
108
+ return 탈들.length ? { 없음: true, 탈들 } : null;
109
+ }
110
+
111
+ /**
112
+ * 이 대화에 걸린 요금값.
113
+ *
114
+ * 상태줄은 글자를 칠 때마다 다시 그려진다. 그때마다 표를 훑으면 아까우니
115
+ * 모델 이름이 그대로인 동안은 찾은 것을 들고 있는다. 모델을 갈아타면
116
+ * (/model) 이름이 달라지므로 저절로 다시 찾는다 — 갈아탈 때 이걸 지워
117
+ * 달라고 부탁하는 방식은 언젠가 한 군데를 빠뜨린다.
118
+ */
119
+ export function 세션요금(session, { 이제 = Date.now() } = {}) {
120
+ const 모델 = String(session?.conn?.model ?? '');
121
+ if (session?.요금캐시 && session.요금캐시.모델 === 모델) return session.요금캐시.값;
122
+ const 값 = 요금찾기(모델, {
123
+ 설정: session?.요금표 ?? null,
124
+ 제공자: session?.제공자 ? 제공자고르기(session.제공자) : null,
125
+ 이제,
126
+ });
127
+ if (session) session.요금캐시 = { 모델, 값 };
128
+ return 값;
129
+ }
130
+
131
+ /** 기준 날짜가 반년보다 오래됐나. 날짜가 없으면 「모른다」 — 오래됐다고 치지 않는다. */
132
+ export function 낡았나(기준, 이제 = Date.now()) {
133
+ if (!기준) return false;
134
+ const t = Date.parse(기준);
135
+ if (!Number.isFinite(t)) return false;
136
+ return (이제 - t) / 86400000 > 낡는날;
137
+ }
138
+
139
+ /**
140
+ * 토큰을 달러로.
141
+ *
142
+ * 요금을 모르면 null 이다. 여기서 0 을 돌려주면 부르는 쪽이 「$0.00」 을
143
+ * 찍게 되고, 그건 공짜라는 뜻이 되어 버린다.
144
+ */
145
+ export function 돈셈({ in: 입 = 0, out: 출 = 0 } = {}, 값) {
146
+ if (!값 || 값.없음 || 값.입력 == null || 값.출력 == null) return null;
147
+ const 입돈 = (Number(입) || 0) / 단위 * 값.입력;
148
+ const 출돈 = (Number(출) || 0) / 단위 * 값.출력;
149
+ return { 달러: 입돈 + 출돈, 입력: 입돈, 출력: 출돈 };
150
+ }
151
+
152
+ /**
153
+ * 돈을 사람이 읽는 글로.
154
+ *
155
+ * 아주 작은 값을 `$0.00` 으로 반올림하면 안 된다. 로컬에서 한두 턴 돌린
156
+ * 뒤 화면에 `$0.00` 이 서 있으면 「공짜네」 로 읽히고, 그 상태로 백 턴을
157
+ * 돌린다. 자릿수가 안 되면 반올림하지 말고 미만이라고 적는다.
158
+ */
159
+ export function 돈말(달러) {
160
+ const v = Number(달러);
161
+ if (!Number.isFinite(v) || v < 0) return null;
162
+ if (v === 0) return '$0';
163
+ if (v < 0.0001) return '<$0.0001';
164
+ if (v < 1) return `$${v.toFixed(4)}`;
165
+ if (v < 1000) return `$${v.toFixed(2)}`;
166
+ return `$${Math.round(v).toLocaleString()}`;
167
+ }
168
+
169
+ /**
170
+ * 이 금액이 어디서 온 값인지 한 줄로.
171
+ *
172
+ * 금액만 찍으면 사람은 그게 오늘 요금표에서 온 값이라고 믿는다. 어디서
173
+ * 왔는지·언제 기준인지를 같이 적어야 의심할 거리가 생긴다.
174
+ */
175
+ export function 어디서온값(값) {
176
+ if (!값 || 값.없음) return null;
177
+ const 조각 = [값.어디서];
178
+ if (값.열쇠 && 값.열쇠.includes('*')) 조각.push(`'${값.열쇠}' 로 맞춤`);
179
+ if (값.기준) 조각.push(`${값.기준} 기준${값.낡았나 ? ' · 오래됨' : ''}`);
180
+ else 조각.push('기준 날짜 없음');
181
+ return 조각.join(' · ');
182
+ }
183
+
184
+ /**
185
+ * 요금을 모를 때 사람에게 할 말.
186
+ *
187
+ * 「모릅니다」 로 끝내면 사람이 할 수 있는 일이 없다. 어디에 무엇을 적으면
188
+ * 되는지까지 적는다. 요금표 주소를 아는 제공자면 그것도 같이 준다.
189
+ */
190
+ export function 요금적는법(모델, { 제공자 = null, 설정파일 = '.deel/config.json' } = {}) {
191
+ const 줄 = [
192
+ `요금을 모릅니다. ${설정파일} 에 100만 토큰당 달러로 적으면 셈합니다.`,
193
+ ` "요금": { "${모델 || '모델이름'}": { "입력": 0, "출력": 0, "기준": "${new Date().toISOString().slice(0, 10)}" } }`,
194
+ ];
195
+ if (제공자?.요금표주소) 줄.push(` 요금표: ${제공자.요금표주소}`);
196
+ return 줄;
197
+ }
@@ -3,6 +3,7 @@ import { c, say, rule, pad, mark, clip, width } from '../ui/ansi.js';
3
3
  import { pick } from '../ui/prompt.js';
4
4
  import { spin } from '../ui/spinner.js';
5
5
  import { scanLocal, toProfiles, KNOWN } from './scan.js';
6
+ import { 규격이름 } from './adapter.js';
6
7
  import { load, save, upsert } from '../config.js';
7
8
 
8
9
  // 코딩 에이전트로 쓰려면 도구 호출이 되어야 한다. 진단 전이라 확실히는 모르니,
@@ -66,7 +67,7 @@ export async function runScan(flags = {}) {
66
67
  const wRun = Math.max(10, ...found.map((f) => width(f.runtime)));
67
68
  for (const f of found) {
68
69
  const 자리 = `${f.host}:${f.port}`;
69
- const 규격 = f.kind === 'ollama' ? 'Ollama 규격' : 'OpenAI 호환';
70
+ const 규격 = f.kind === 'ollama' ? 'Ollama 규격' : 규격이름(f.kind);
70
71
  const 표시 = f.guessed ? c.gray(' (추정)') : '';
71
72
  say(` ${c.hcyan('◆')} ${c.bold(pad(f.runtime, wRun))}${표시} ${c.gray(pad(자리, 22))}${c.gray(pad(규격, 14))}${c.gray(f.ms + 'ms')}`);
72
73
  if (f.locked) {
@@ -13,10 +13,11 @@
13
13
  //
14
14
  // ── 무엇을 하나 ────────────────────────────────────────────────────────
15
15
  //
16
- // 그림이면 그림으로 싣는다. 규격이 둘이라 모양도 둘이다.
16
+ // 그림이면 그림으로 싣는다. 규격이 셋이라 모양도 셋이다.
17
17
  //
18
18
  // OpenAI 호환 content 배열에 { type:'image_url', image_url:{ url:'data:…' } }
19
19
  // Ollama 메시지에 images: ['<base64>'] (data: 머리말 없이)
20
+ // Anthropic content 배열에 { type:'image', source:{ type:'base64', media_type, data } }
20
21
  //
21
22
  // 못 보는 모델에게는 **바이트를 아예 안 보낸다.** 보내 봐야 400 이 오거나,
22
23
  // 더 나쁘게는 서버가 조용히 무시하고 답을 지어낸다. 대신 한 줄로 말한다.
@@ -127,11 +128,15 @@ export function 그림읽기(abs, { 한도 = 기본한도 } = {}) {
127
128
  */
128
129
  export const 그림한장토큰 = 1000;
129
130
 
130
- /** 메시지 하나에 그림이 몇 장 실려 있나. 규격 가지를 다 본다. */
131
+ /** 메시지 하나에 그림이 몇 장 실려 있나. 규격 가지를 다 본다. */
131
132
  export function 그림장수(m) {
132
133
  if (!m) return 0;
133
134
  if (Array.isArray(m.images)) return m.images.length; // Ollama
134
- if (Array.isArray(m.content)) return m.content.filter((p) => p?.type === 'image_url').length;
135
+ // image_url OpenAI 호환, image 는 Anthropic. 한쪽만 세면 그림을 실어
136
+ // 놓고도 안 실은 줄 알고 창 크기를 잘못 잡는다.
137
+ if (Array.isArray(m.content)) {
138
+ return m.content.filter((p) => p?.type === 'image_url' || p?.type === 'image').length;
139
+ }
135
140
  return 0;
136
141
  }
137
142
 
@@ -155,6 +160,23 @@ export function 글만(m) {
155
160
  */
156
161
  export function 그림메시지(shape, { 글 = '', 그림들 = [] } = {}) {
157
162
  const 것들 = 그림들.filter((g) => g?.b64);
163
+ /*
164
+ * Anthropic 은 그림을 data: 주소로 안 받는다. 종류와 알맹이를 따로 준다.
165
+ * image_url 로 보내면 「모르는 블록」 이라고 통째로 거절당한다 — 글까지
166
+ * 같이 안 간다는 뜻이라, 눈이 없는 것과 달리 대화가 아예 안 이어진다.
167
+ */
168
+ if (shape === 'anthropic') {
169
+ return {
170
+ role: 'user',
171
+ content: [
172
+ { type: 'text', text: 글 },
173
+ ...것들.map((g) => ({
174
+ type: 'image',
175
+ source: { type: 'base64', media_type: g.mime, data: g.b64 },
176
+ })),
177
+ ],
178
+ };
179
+ }
158
180
  if (shape === 'ollama') {
159
181
  // Ollama 는 data: 머리말을 안 받는다. base64 알맹이만 준다.
160
182
  return { role: 'user', content: 글, images: 것들.map((g) => g.b64) };
@@ -176,7 +198,7 @@ export function 그림메시지(shape, { 글 = '', 그림들 = [] } = {}) {
176
198
  */
177
199
  export const 한점PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
178
200
 
179
- /** 눈 검사에 쓸 메시지. 규격 가지 다. */
201
+ /** 눈 검사에 쓸 메시지. 규격 가지 다. */
180
202
  export function 눈검사메시지(shape) {
181
203
  return 그림메시지(shape, {
182
204
  글: '이 그림에 무엇이 있습니까? 한 단어로 답하세요.',
package/src/commands.js CHANGED
@@ -2,19 +2,23 @@
2
2
  import { writeFileSync, existsSync, statSync } from 'node:fs';
3
3
  import { 볼것, 리뷰받기 } from './agent/review.js';
4
4
  import { 마지막할당량, 할당량말, 아슬아슬한가 } from './backend/quota.js';
5
+ import { 세션요금, 돈셈, 돈말, 어디서온값, 요금적는법 } from './backend/price.js';
5
6
  import { join } from 'node:path';
6
7
  import { c, say, rule, pad, bar, mark, width, clip } from './ui/ansi.js';
7
8
  import { compact } from './agent/compact.js';
8
9
  import { 증거모으기, 증거적기 } from './agent/evidence.js';
9
10
  import { 커밋준비, 커밋실행 } from './agent/commit.js';
10
11
  import { allowEndpoint } from './safety/network.js';
12
+ import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
13
+ import { 주소가리기 } from './safety/secrets.js';
11
14
  import { pick, confirm } from './ui/prompt.js';
12
- import { load, save, resolveKey, upsert, 열쇠보관 } from './config.js';
15
+ import { load, save, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
16
+ import { 제공자고르기 } from './providers/index.js';
13
17
  import { 종, 알릴만한초 } from './ui/notify.js';
14
18
  import { 말, 언어, 언어들, 언어정하기, 언어고르기, 옮긴만큼, 지시말, 지시말정하기, 지시말따로정했나 } from './i18n/index.js';
15
19
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
16
20
  import { allowTemporarily } from './safety/network.js';
17
- import { chat } from './backend/adapter.js';
21
+ import { chat, 규격이름 } from './backend/adapter.js';
18
22
  import { 알림채움 } from './backend/retry.js';
19
23
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
20
24
  import { 정한셸 } from './tools/shell.js';
@@ -877,6 +881,33 @@ export async function handle(line, session, ctx) {
877
881
  // 서버가 잠깐 막아 다시 부른 횟수. 0 이면 안 적는다 — 없는 일을 줄로 남기면 표만 길어진다.
878
882
  if (session.usage.retries) say(` ${c.gray(pad(말('cost.retries'), 14))} ${session.usage.retries}회`);
879
883
  say(` ${c.gray(pad('경과', 14))} ${mins}분`);
884
+ /*
885
+ * 돈.
886
+ *
887
+ * 이 도구에는 값이 박힌 요금표가 없다(backend/price.js). 지어낸 요금을
888
+ * 자신 있게 찍는 것보다 모른다고 말하는 편이 낫다고 봤다.
889
+ *
890
+ * 그래서 두 갈래다 — 아는 값이 있으면 금액과 **어디서 온 값인지**를
891
+ * 같이 적고, 모르면 어디에 무엇을 적으면 되는지를 적는다. 「모릅니다」
892
+ * 한 줄로 끝내면 사람이 할 수 있는 일이 없다.
893
+ *
894
+ * 로컬로만 쓰는 사람에게는 아예 안 띄운다. 로컬은 공짜라 그 자리가
895
+ * 영영 안 채워지고, 그러면 매번 쓸모없는 안내만 보게 된다.
896
+ */
897
+ const 요금값 = 세션요금(session);
898
+ const 쓴돈 = 돈셈(session.usage, 요금값);
899
+ if (쓴돈) {
900
+ say(` ${c.gray(pad('요금', 14))} ${c.bold(돈말(쓴돈.달러))}`
901
+ + ` ${c.gray(`(${어디서온값(요금값)})`)}`);
902
+ } else if (바깥인가(session.conn.base) && (session.usage.in || session.usage.out)) {
903
+ const 붙은곳 = session.제공자 ? 제공자고르기(session.제공자) : null;
904
+ for (const 줄 of 요금적는법(session.conn.model, { 제공자: 붙은곳, 설정파일: configPath() })) {
905
+ say(` ${c.gray(줄)}`);
906
+ }
907
+ }
908
+ // 설정에 적어는 뒀는데 숫자가 아니면 조용히 넘어가면 안 된다. 사람은
909
+ // 적었다고 믿고 있으므로, 안 나오는 까닭을 화면에서 알 수 있어야 한다.
910
+ for (const 탈 of 요금값?.탈들 ?? []) say(` ${c.yellow(mark.no)} ${c.gray(탈)}`);
880
911
  /*
881
912
  * 서버가 남았다고 말해 준 할당량 (backend/quota.js).
882
913
  *
@@ -896,7 +927,7 @@ export async function handle(line, session, ctx) {
896
927
  case 'status': {
897
928
  const k = session.conn;
898
929
  rule('연결', 70);
899
- say(` ${c.gray(pad('규격', 10))} ${k.kind === 'ollama' ? 'Ollama' : 'OpenAI 호환'}`);
930
+ say(` ${c.gray(pad('규격', 10))} ${규격이름(k.kind)}`);
900
931
  say(` ${c.gray(pad('주소', 10))} ${k.base}`);
901
932
  // 프록시를 거치면 어느 것을, 어디서 읽었는지(env · config)까지. 안 거치면 줄 자체가 없다.
902
933
  // 적어 놨는데 못 쓰는 것(socks5 등)이면 그 까닭을 — 조용히 직접 가면 사람은 프록시를 탄 줄 안다.
@@ -2555,6 +2586,9 @@ function 연결적용(session, p) {
2555
2586
  // 자물쇠도 같이 옮긴다. 이걸 빼먹으면 옛 주소가 열린 채로 남고 새 주소는 막혀
2556
2587
  // 다음 한마디에서 바로 "허용되지 않은 주소" 가 난다.
2557
2588
  allowEndpoint(p.baseUrl);
2589
+ // 어디 것인지도 같이 옮긴다. 요금표 주소 같은 안내가 옛 회사 것으로 남으면
2590
+ // 사람이 엉뚱한 요금표를 보러 간다.
2591
+ session.제공자 = p.제공자 ?? null;
2558
2592
  }
2559
2593
 
2560
2594
  /**
@@ -2616,7 +2650,7 @@ async function switchModel(session, ctx, arg = '') {
2616
2650
  // 이름으로 바로 바꾸기 — 메뉴를 안 거친다.
2617
2651
  if (말) {
2618
2652
  const 찾은 = 이름으로찾기(cfg.profiles, 말);
2619
- if (찾은.length === 1) return 골라적용(session, cfg, 찾은[0]);
2653
+ if (찾은.length === 1) return await 골라적용(session, cfg, 찾은[0]);
2620
2654
  if (찾은.length > 1) {
2621
2655
  say(` ${mark.warn} ${c.white(말)} ${c.gray('에 맞는 것이 여럿입니다.')}`);
2622
2656
  for (const p of 찾은.slice(0, 12)) say(` ${c.cyan(p.name)} ${c.gray(p.model)}`);
@@ -2653,7 +2687,7 @@ async function switchModel(session, ctx, arg = '') {
2653
2687
  ask: ctx?.ask,
2654
2688
  });
2655
2689
  if (i === cfg.profiles.length) return await 서버모델고르기(session, ctx, cfg);
2656
- return 골라적용(session, cfg, cfg.profiles[i]);
2690
+ return await 골라적용(session, cfg, cfg.profiles[i]);
2657
2691
  }
2658
2692
 
2659
2693
  function 이름으로찾기(profiles, 말) {
@@ -2663,7 +2697,44 @@ function 이름으로찾기(profiles, 말) {
2663
2697
  return profiles.filter((p) => `${p.name} ${p.model} ${p.id}`.toLowerCase().includes(q));
2664
2698
  }
2665
2699
 
2666
- function 골라적용(session, cfg, p) {
2700
+ /*
2701
+ * 이 프로필로 갈아타면 바깥으로 나가는가. 나가면 한 번 묻는다.
2702
+ *
2703
+ * 켤 때 지나는 문(repl.js)과 **같은 문**이다. 여기를 안 지키면 자물쇠가
2704
+ * 반쪽이 된다 — 로컬로 켜서 물음을 지나친 다음, /model 로 바깥 프로필에
2705
+ * 갈아타는 순간 아무것도 안 묻고 나간다. 실제로 그렇게 쓴다: 한 시간쯤
2706
+ * 로컬로 하다가 「이건 큰 모델이 낫겠다」 하고 옮긴다.
2707
+ *
2708
+ * 허락은 프로필에 적힌다. 갈아탈 때마다 묻지 않는다.
2709
+ */
2710
+ async function 나가도되나묻기(session, p) {
2711
+ const 모드 = session.실행모드 ?? 지금모드({});
2712
+ const 나감 = 나갈수있나(모드, { 바깥: 바깥인가(p.baseUrl), 허가: p.online === true });
2713
+ if (나감.되나) return true;
2714
+
2715
+ const 어디 = 주소가리기((() => { try { return new URL(p.baseUrl).host; } catch { return String(p.baseUrl); } })());
2716
+ if (!나감.물어볼까) {
2717
+ // 봉인이다. 여기서 바꿔 주면 다음 한마디에서 막히는데, 그 화면만 보고는
2718
+ // 왜 막혔는지 알 수 없다 — 바꾸기 전에 말하는 편이 언제나 낫다.
2719
+ say(` ${mark.warn} ${c.gray(`${어디} 는 이 컴퓨터 밖입니다. 지금은`)} ${c.white('봉인(--offline)')} ${c.gray('이라 안 바꿉니다.')}`);
2720
+ say('');
2721
+ return false;
2722
+ }
2723
+ say('');
2724
+ say(` ${c.yellow('↗')} ${c.bold(어디)} ${c.gray('는 이 컴퓨터 밖입니다.')}`);
2725
+ say(` ${c.gray('바꾸면 시킨 말과, 모델이 읽은 파일의 내용이 그리로 갑니다.')}`);
2726
+ const 예 = await confirm('나가도 될까요? (한 번 허락하면 이 연결은 다음부터 안 묻습니다)', true);
2727
+ if (!예) {
2728
+ say(` ${c.gray('안 바꿨습니다. 아무것도 안 보냈습니다.')}`);
2729
+ say('');
2730
+ return false;
2731
+ }
2732
+ p.online = true;
2733
+ return true;
2734
+ }
2735
+
2736
+ async function 골라적용(session, cfg, p) {
2737
+ if (!await 나가도되나묻기(session, p)) return;
2667
2738
  cfg.active = p.id;
2668
2739
  try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
2669
2740
  연결적용(session, p);
@@ -2691,6 +2762,9 @@ async function 모델만바꾸기(session, cfg, 모델) {
2691
2762
  // 반대로 큰 데서 작은 데로 옮기면 긴 대화에서 서버가 거절한다. 아래에서 다시 잰다.
2692
2763
  ctx: null,
2693
2764
  };
2765
+ // 서버는 그대로지만 문은 같이 지난다. 등록 안 된 서버로 옮겨 가는 길도
2766
+ // 여기라서, 여기를 열어 두면 자물쇠에 구멍이 하나 남는다.
2767
+ if (!await 나가도되나묻기(session, p)) return;
2694
2768
  if (!이미) { try { save(upsert(cfg, p)); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ } }
2695
2769
  else { cfg.active = p.id; try { save(cfg); } catch {} }
2696
2770
  연결적용(session, p);
package/src/completion.js CHANGED
@@ -62,6 +62,7 @@ export const 깃발들 = [
62
62
  { 이름: '--max-tokens', 뜻: '한 번에 뱉을 최대 토큰', en: 'max tokens per reply', 값: null },
63
63
  { 이름: '--think', 뜻: '생각의 양', en: 'how much thinking', 값: LEVELS },
64
64
  { 이름: '--effort', 뜻: '생각을 어디에 몰아줄까', en: 'where to spend the thinking budget', 값: Object.keys(PROFILES) },
65
+ { 이름: '--online', 뜻: '묻지 않고 바깥으로 나간다', en: 'go outside without asking', 값: false },
65
66
  { 이름: '--offline', 뜻: '바깥으로 아예 안 나간다', en: 'never reach the network', 값: false },
66
67
  { 이름: '--yes', 뜻: '물어보지 않고 진행', en: 'do not ask, just proceed', 값: false },
67
68
  { 이름: '--json', 뜻: '결과를 JSON 으로', en: 'output JSON', 값: false },
package/src/i18n/en.js CHANGED
@@ -114,6 +114,10 @@ export const en = {
114
114
  // ── Startup header ────────────────────────────────────────────────────
115
115
  'head.spec.ollama': 'Ollama API',
116
116
  'head.spec.openai': 'OpenAI-compatible API',
117
+ 'head.spec.anthropic': 'Anthropic API',
118
+ 'head.mode.default': 'inside (default)',
119
+ 'head.mode.online': 'outside (online)',
120
+ 'head.mode.offline': 'sealed (offline)',
117
121
  'head.model': 'model',
118
122
  'head.tokens': 'tokens',
119
123
  'head.grade': 'class',
@@ -152,6 +156,7 @@ export const en = {
152
156
 
153
157
  // ── Status bar segments ───────────────────────────────────────────────
154
158
  'seg.dir': 'working folder (the ⌂/↗ in front says where your source goes)',
159
+ 'seg.ver': 'version',
155
160
  'seg.thread': 'thread',
156
161
  'seg.model': 'model name',
157
162
  'seg.grade': 'model class (how much it does on its own)',
@@ -163,6 +168,7 @@ export const en = {
163
168
  'seg.verify': 'what was actually verified',
164
169
  'seg.undoable': 'turns you can still undo',
165
170
  'seg.tok': 'tokens in/out',
171
+ 'seg.cost': 'money spent (only when the price is known)',
166
172
  'seg.tools': 'tool calls',
167
173
  'seg.skills': 'skills found on this machine',
168
174
  'seg.time': 'time since start',
package/src/i18n/ja.js CHANGED
@@ -96,6 +96,10 @@ export const ja = {
96
96
  'consult.added': 'その答えを、誰が言ったか付けて会話に足しました。',
97
97
  'head.spec.ollama': 'Ollama API',
98
98
  'head.spec.openai': 'OpenAI 互換 API',
99
+ 'head.spec.anthropic': 'Anthropic API',
100
+ 'head.mode.default': 'この中(既定)',
101
+ 'head.mode.online': '外(online)',
102
+ 'head.mode.offline': '封印(offline)',
99
103
  'head.model': 'モデル',
100
104
  'head.tokens': 'トークン',
101
105
  'head.grade': '階級',
@@ -131,6 +135,7 @@ export const ja = {
131
135
  'grade.large': '大',
132
136
  'head.gradeSuffix': ' · {급} {급이름}{짐작}',
133
137
  'seg.dir': '作業フォルダ (前の ⌂/↗ が、書いたものがどこへ行くかを表します)',
138
+ 'seg.ver': 'バージョン',
134
139
  'seg.thread': 'スレッド',
135
140
  'seg.model': 'モデル名',
136
141
  'seg.grade': 'モデルの階級 (どこまで自力でやるか)',
@@ -142,6 +147,7 @@ export const ja = {
142
147
  'seg.verify': '実際に確かめたもの',
143
148
  'seg.undoable': 'まだ戻せるターン',
144
149
  'seg.tok': '入出力トークン',
150
+ 'seg.cost': '使った金額(料金が分かるときだけ)',
145
151
  'seg.tools': '道具の呼び出し',
146
152
  'seg.skills': 'このPCで見つかったスキル',
147
153
  'seg.time': '始めてからの時間',
package/src/i18n/ko.js CHANGED
@@ -101,6 +101,10 @@ export const ko = {
101
101
  // ── 켤 때 머리말 ──────────────────────────────────────────────────────
102
102
  'head.spec.ollama': 'Ollama 규격',
103
103
  'head.spec.openai': 'OpenAI 호환 규격',
104
+ 'head.spec.anthropic': 'Anthropic 규격',
105
+ 'head.mode.default': '이 안 (기본)',
106
+ 'head.mode.online': '바깥 (online)',
107
+ 'head.mode.offline': '봉인 (offline)',
104
108
  'head.model': '모델',
105
109
  'head.tokens': '토큰',
106
110
  'head.grade': '급',
@@ -139,6 +143,7 @@ export const ko = {
139
143
 
140
144
  // ── 상태줄 조각 이름 (/status 와 설정이 본다) ─────────────────────────
141
145
  'seg.dir': '작업 폴더 (앞의 ⌂/↗ 는 소스가 어디로 나가나)',
146
+ 'seg.ver': '판 번호',
142
147
  'seg.thread': '대화 갈래',
143
148
  'seg.model': '모델 이름',
144
149
  'seg.grade': '모델 급 (얼마나 알아서 하나)',
@@ -150,6 +155,7 @@ export const ko = {
150
155
  'seg.verify': '확인한 것 (Verify)',
151
156
  'seg.undoable': '되돌릴 수 있는 턴',
152
157
  'seg.tok': '주고받은 토큰',
158
+ 'seg.cost': '여태 쓴 돈 (요금을 알 때만)',
153
159
  'seg.tools': '도구 호출 수',
154
160
  'seg.skills': '이 PC 에서 찾은 스킬',
155
161
  'seg.time': '켠 지 얼마나',
package/src/i18n/zh.js CHANGED
@@ -96,6 +96,10 @@ export const zh = {
96
96
  'consult.added': '已把那个回答加进对话,并标明是谁说的。',
97
97
  'head.spec.ollama': 'Ollama API',
98
98
  'head.spec.openai': 'OpenAI 兼容 API',
99
+ 'head.spec.anthropic': 'Anthropic API',
100
+ 'head.mode.default': '本机内(默认)',
101
+ 'head.mode.online': '外部(online)',
102
+ 'head.mode.offline': '封锁(offline)',
99
103
  'head.model': '模型',
100
104
  'head.tokens': '词元',
101
105
  'head.grade': '等级',
@@ -131,6 +135,7 @@ export const zh = {
131
135
  'grade.large': '大',
132
136
  'head.gradeSuffix': ' · {급} {급이름}{짐작}',
133
137
  'seg.dir': '工作文件夹 (前面的 ⌂/↗ 表示你写的东西会去哪里)',
138
+ 'seg.ver': '版本号',
134
139
  'seg.thread': '分支',
135
140
  'seg.model': '模型名',
136
141
  'seg.grade': '模型等级 (能独立做到哪一步)',
@@ -142,6 +147,7 @@ export const zh = {
142
147
  'seg.verify': '真正验证过的东西',
143
148
  'seg.undoable': '还能撤销的轮数',
144
149
  'seg.tok': '输入/输出词元',
150
+ 'seg.cost': '已花费金额(仅在已知价格时)',
145
151
  'seg.tools': '工具调用',
146
152
  'seg.skills': '本机上找到的技能',
147
153
  'seg.time': '开始以来的时间',
package/src/oneshot.js CHANGED
@@ -23,6 +23,8 @@ import { 말 as 옮긴말 } from './i18n/index.js';
23
23
  import { 알림채움 } from './backend/retry.js';
24
24
  import { discover } from './skills/discover.js';
25
25
  import { allowEndpoint, setOffline } from './safety/network.js';
26
+ import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
27
+ import { 주소가리기 } from './safety/secrets.js';
26
28
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
27
29
  import { route } from './agent/route.js';
28
30
  import { get as getWork } from './agent/modes.js';
@@ -160,11 +162,30 @@ export async function runOnce(opts = {}) {
160
162
  vision: prof.vision ?? false,
161
163
  };
162
164
 
163
- // 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
164
- // 오히려 배치는 아무도 보는 자리라 단단해야 한다.
165
+ /*
166
+ * 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
167
+ * 오히려 배치는 아무도 안 보는 자리라 더 단단해야 한다.
168
+ *
169
+ * 다른 것은 딱 하나, **물을 수가 없다**는 것이다. 파이프 뒤에는 답할 사람이
170
+ * 없다. 그래서 여기서는 묻는 대신 멈추고, 무엇을 붙이면 되는지를 말한다.
171
+ * 조용히 나가 버리면 스크립트가 바깥으로 나가고 있는 줄 아무도 모른다.
172
+ */
173
+ const 바깥연결 = 바깥인가(conn.base);
174
+ const 실행모드 = 지금모드({
175
+ online: opts.online === true,
176
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
177
+ offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
178
+ });
179
+ const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
180
+ if (나감.물어볼까) {
181
+ const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
182
+ return 못함('needs-online',
183
+ `이 연결은 이 컴퓨터 밖으로 나갑니다 (${주소가리기(어디)}). 여기서는 물어볼 수 없습니다.\n`
184
+ + ' 나가도 된다면 --online 을 붙이거나, 대화 화면(deel)에서 한 번 허락해 두세요.\n'
185
+ + ' 이 컴퓨터 안의 모델을 쓰려면 deel scan --save 로 붙이세요.');
186
+ }
165
187
  allowEndpoint(conn.base);
166
- // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
167
- if (opts.offline ?? prof.offline ?? cfg?.offline) setOffline(true);
188
+ if (실행모드.허가무시) setOffline(true);
168
189
 
169
190
  const session = new Session(conn, {
170
191
  root,
@@ -174,6 +195,9 @@ export async function runOnce(opts = {}) {
174
195
  effort: opts.effort ?? 'save',
175
196
  maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
176
197
  });
198
+ // 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
199
+ // session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
200
+ session.실행모드 = 실행모드;
177
201
 
178
202
  const found = discover(root);
179
203
  session.skills = found.skills;
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Anthropic 직접 (Claude).
3
+ *
4
+ * ── 여기만 규격이 하나 더 필요했다 ─────────────────────────────────────
5
+ *
6
+ * 다섯 중에 유일하게 **몸통 모양이 다르다.** OpenAI 호환 창구가 없어서,
7
+ * backend/adapter.js 에 `anthropic` 갈래를 하나 더해야 말이 통한다. 그
8
+ * 갈래를 붙였다 — 문 이름 · 몸통 · 답 읽기 · 이력 되돌리기 · 도구 결과 ·
9
+ * 흘려받기 여섯 자리, 그리고 그림과 창 크기.
10
+ *
11
+ * 무엇이 다른지는 backend/adapter.js 의 anthropic몸() 머리말에 있다.
12
+ */
13
+ export const 제공자 = {
14
+ id: 'anthropic',
15
+ 이름: 'Anthropic (Claude)',
16
+ 한줄: '벤더에서 바로. 열쇠 하나만 있으면 됩니다.',
17
+
18
+ 규격: 'anthropic',
19
+ // 인증 방식이 다르다. Bearer 가 아니라 x-api-key 다 — 축이 갈라져 있다는
20
+ // 것이 여기서 그대로 보인다 (계획표 05절).
21
+ 인증: 'x-api-key',
22
+ 주소들: () => ['https://api.anthropic.com'],
23
+ 리전들: null,
24
+
25
+ 규격됐나: true,
26
+
27
+ 빈칸: ['열쇠'],
28
+ 열쇠받는곳: 'https://console.anthropic.com/settings/keys',
29
+ 키앞머리: [/^sk-ant-/],
30
+
31
+ // 왜 비워 두는지는 providers/openai.js 의 같은 자리에 적어 두었다.
32
+ 요금: {},
33
+ 요금기준: null,
34
+ 요금표주소: 'https://www.anthropic.com/pricing',
35
+
36
+ 오류읽기({ status, 서버말 }) {
37
+ if (status === 400 && /anthropic-version/i.test(서버말)) {
38
+ return 'anthropic-version 헤더가 없거나 틀렸습니다 — deel 의 규격 붙임이 아직 안 끝난 자리입니다.';
39
+ }
40
+ if (status === 404 && /model/i.test(서버말)) {
41
+ return '그 모델 이름을 이 열쇠로는 못 씁니다 — 이름이 틀렸거나, 아직 안 열린 모델입니다.';
42
+ }
43
+ if (status === 400 && /credit|balance/i.test(서버말)) {
44
+ return '잔액이 모자랍니다 — 콘솔에서 결제·크레딧을 확인하세요.';
45
+ }
46
+ return null;
47
+ },
48
+ };