deel-local-cli 1.6.3 → 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,102 @@
1
+ /*
2
+ * AWS Bedrock.
3
+ *
4
+ * ── SigV4 서명이 필요 없다 ─────────────────────────────────────────────
5
+ *
6
+ * 이게 이 파일에서 제일 중요한 사실이고, 계획을 세울 때 내가 틀렸던 자리다.
7
+ * AWS 를 붙인다고 하면 보통 SigV4 서명(해시 체인 · 날짜 키 유도)을 떠올리고,
8
+ * 그건 의존성 0개로는 꽤 큰 일이다. 그런데 문서를 보면 —
9
+ * (docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions-mantle.md)
10
+ *
11
+ * curl -X POST "https://bedrock-runtime.us-east-1.amazonaws.com/v1/chat/completions" \
12
+ * -H "Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK"
13
+ *
14
+ * **Bedrock API 키를 그냥 Bearer 로 받는다.** 그리고 OpenAI Chat Completions
15
+ * 창구로 부를 때는 인증이 API 키 하나로 제한된다고 문서가 못 박아 두었다.
16
+ * 그러면 Bedrock 은 새 규격이 아니라 **이미 있는 축들의 새 조합**일 뿐이다 —
17
+ * 규격 openai · 인증 bearer · 주소가 리전을 탄다.
18
+ *
19
+ * ── 주소가 셋이다 ──────────────────────────────────────────────────────
20
+ *
21
+ * bedrock-runtime.{리전}.amazonaws.com/v1 API 키를 Bearer 로 (문서)
22
+ * bedrock-runtime.{리전}.amazonaws.com/openai/v1 Bedrock 위의 OpenAI 모델 (문서)
23
+ * bedrock-mantle.{리전}.api.aws/v1 mantle 창구 (문서)
24
+ *
25
+ * 셋 다 후보로 주고 **어느 것이 되는지는 물어본다**(backend/detect.js).
26
+ * 표를 믿고 하나만 쓰면 그 표가 낡은 날 사람은 「연결 실패」 만 본다.
27
+ *
28
+ * ── 리전 목록은 짧게, 진실은 엔드포인트에 묻는다 ───────────────────────
29
+ *
30
+ * 리전마다 열려 있는 모델이 다르다. 「서울에 최신 Claude 가 있다」 를 표에
31
+ * 박으면 그 표가 낡는다. 리전을 고르면 그 자리에서 실제로 물어보고, 없으면
32
+ * 없다고 말한다. 그래서 목록에는 「직접 입력」 이 늘 있다 — 목록은 빈칸을
33
+ * 줄여 주는 것이지 울타리가 아니다.
34
+ */
35
+ export const 리전들 = [
36
+ { id: 'us-east-1', 어디: '버지니아', 왜: '새 모델이 제일 먼저 열립니다' },
37
+ { id: 'us-west-2', 어디: '오리건', 왜: '두 번째로 넓습니다' },
38
+ { id: 'ap-northeast-2', 어디: '서울', 왜: '자료를 국내에 남깁니다 — 사내 도입엔 이게 관건입니다' },
39
+ { id: 'ap-northeast-1', 어디: '도쿄', 왜: '서울에 없을 때 제일 가까운 대안입니다' },
40
+ { id: 'eu-central-1', 어디: '프랑크푸르트', 왜: 'EU' },
41
+ ];
42
+
43
+ export const 제공자 = {
44
+ id: 'bedrock',
45
+ 이름: 'AWS Bedrock',
46
+ 한줄: '리전과 열쇠 둘만 있으면 됩니다. AWS 서명(SigV4)은 필요 없습니다.',
47
+
48
+ 규격: 'openai',
49
+ 인증: 'bearer',
50
+ 기본리전: 'us-east-1',
51
+ 리전들,
52
+
53
+ 주소들: ({ 리전 } = {}) => {
54
+ const r = String(리전 ?? 'us-east-1').trim();
55
+ if (!/^[a-z]{2}-[a-z]+-\d$/.test(r)) return [];
56
+ return [
57
+ `https://bedrock-runtime.${r}.amazonaws.com/v1`,
58
+ `https://bedrock-runtime.${r}.amazonaws.com/openai/v1`,
59
+ `https://bedrock-mantle.${r}.api.aws/v1`,
60
+ ];
61
+ },
62
+
63
+ 규격됐나: true,
64
+ 빈칸: ['리전', '열쇠'],
65
+ 열쇠받는곳: 'https://console.aws.amazon.com/bedrock/home#/api-keys',
66
+
67
+ /*
68
+ * 앞머리를 안 둔다.
69
+ *
70
+ * Bedrock API 키에는 알아볼 만한 고정 앞머리가 없다. 「모르면 뿌리지 말고
71
+ * 물어본다」 가 이 도구의 규칙이라(providers/index.js), 짐작이 안 서면
72
+ * 짐작하지 않는다. 빈 목록이 곧 그 뜻이다.
73
+ */
74
+ 키앞머리: [],
75
+
76
+ // 왜 비워 두는지는 providers/openai.js 의 같은 자리에 적어 두었다.
77
+ 요금: {},
78
+ 요금기준: null,
79
+ 요금표주소: 'https://aws.amazon.com/bedrock/pricing/',
80
+
81
+ 오류읽기({ status, 서버말 }) {
82
+ /*
83
+ * Bedrock 에서 제일 흔한 첫 실패는 **모델 접근 신청을 안 한 것**이다.
84
+ * 열쇠도 맞고 주소도 맞는데 그 리전의 그 모델을 아직 안 열어 둔 상태다.
85
+ * 이걸 「연결 실패」로 뭉개면 사람은 주소와 열쇠를 몇 번이고 다시 본다 —
86
+ * 정작 해야 할 일은 콘솔에서 신청 버튼 한 번 누르는 것인데.
87
+ */
88
+ if (/AccessDenied|not authorized|access to the model/i.test(서버말)) {
89
+ return '이 리전에서 그 모델에 접근 권한이 없습니다 — Bedrock 콘솔의 모델 접근(Model access)에서 신청하세요.';
90
+ }
91
+ if (/ValidationException/i.test(서버말) && /model/i.test(서버말)) {
92
+ return '그 모델 이름을 이 리전에서 못 씁니다 — 리전을 바꾸거나 이름을 다시 보세요.';
93
+ }
94
+ if (status === 403 && /security token|ExpiredToken/i.test(서버말)) {
95
+ return '열쇠가 만료됐습니다 — Bedrock 콘솔에서 새로 발급받으세요 (단기 키는 만료됩니다).';
96
+ }
97
+ if (status === 404 && /^$|Not Found/i.test(서버말)) {
98
+ return '그 리전에 이 창구가 없습니다 — 리전 이름을 다시 보세요.';
99
+ }
100
+ return null;
101
+ },
102
+ };
@@ -0,0 +1,65 @@
1
+ /*
2
+ * 주소를 직접 넣기 — 사내 게이트웨이 · 이름 없는 창구 · 아직 안 적은 벤더.
3
+ *
4
+ * ── 이게 목록의 주인공이다 ─────────────────────────────────────────────
5
+ *
6
+ * 앞의 넷은 「못 외우는 주소를 대신 적어 둔 메모」 다. 이 다섯째가 진짜로
7
+ * 하는 일이고, 화면에서도 위쪽에 둔다.
8
+ *
9
+ * 회사마다 규격도 도메인도 다르다. 명단에 없는 회사가 안 되는 것처럼 보이면
10
+ * 그 명단은 도움이 아니라 울타리다. 그래서 목록의 어느 자리에서든 「주소를
11
+ * 직접」 으로 빠져나갈 수 있어야 한다.
12
+ *
13
+ * ── 빈칸이 하나도 안 채워져 있다 ───────────────────────────────────────
14
+ *
15
+ * 규격도 인증도 안 적는다. **스캐너가 알아낸다**(backend/detect.js) —
16
+ * 후보 주소를 넓혀 보고, 인증 방식을 다 해 보고, 첫 401 에서 안 멈추고,
17
+ * 401/403(규격은 맞고 인증이 틀림)과 404(규격이 아님)를 구분하고, 200 인데
18
+ * JSON 이 아니면(사내 프록시의 로그인 페이지) 성공으로 안 친다.
19
+ *
20
+ * 그 힘이 이미 있는데 여기에 규격을 적어 두면, 적어 둔 것이 틀린 날 스캐너가
21
+ * 알아낼 수 있는 것까지 못 붙게 만든다.
22
+ *
23
+ * ── 사내는 빈칸이 아예 0개가 될 수 있다 ────────────────────────────────
24
+ *
25
+ * 관리자가 policy.json (%ProgramData%\deel · /etc/deel)으로 baseUrl 을 못박아
26
+ * 두면 직원은 열쇠만 넣는다. 정책은 설정을 이기지만 넓히지는 못한다 — 금지를
27
+ * 더할 수는 있어도 사용자가 걸어 둔 금지를 풀지는 못한다(safety/policy.js).
28
+ */
29
+ export const 제공자 = {
30
+ id: 'custom',
31
+ 이름: '주소를 직접 넣기',
32
+ 한줄: '사내 게이트웨이·목록에 없는 곳. 규격과 인증은 알아서 알아냅니다.',
33
+
34
+ // 셋 다 null 이다. 「모른다」 를 기본값으로 덮어쓰지 않는다.
35
+ 규격: null,
36
+ 인증: null,
37
+ 주소들: () => [],
38
+ 리전들: null,
39
+
40
+ 규격됐나: true,
41
+ 빈칸: ['주소', '열쇠(있으면)'],
42
+ 열쇠받는곳: null,
43
+ 키앞머리: [],
44
+
45
+ 요금: {},
46
+ 요금기준: null,
47
+ 요금표주소: null,
48
+
49
+ 오류읽기({ status, 서버말 }) {
50
+ /*
51
+ * 사내에서 제일 흔한 두 가지. 둘 다 「연결 실패」로 보이지만 고칠 자리가
52
+ * 전혀 다르다 — 하나는 프록시 설정, 하나는 인증서다.
53
+ */
54
+ if (/ENOTFOUND|EAI_AGAIN/i.test(서버말)) {
55
+ return '주소를 못 찾았습니다 — 사내망이라면 VPN 이 올라와 있는지, 프록시가 필요한지 보세요 (HTTPS_PROXY).';
56
+ }
57
+ if (/self.signed|unable to verify|CERT_|certificate/i.test(서버말)) {
58
+ return '사내 인증서를 못 믿습니다 — NODE_EXTRA_CA_CERTS 에 회사 인증서 파일을 걸어 주세요.';
59
+ }
60
+ if (status === 200 && /<html|<!DOCTYPE/i.test(서버말)) {
61
+ return '서버가 JSON 대신 웹 페이지를 줬습니다 — 앞단 프록시의 로그인 화면일 가능성이 큽니다.';
62
+ }
63
+ return null;
64
+ },
65
+ };
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Google Gemini.
3
+ *
4
+ * ── 규격을 안 늘려도 된다 ──────────────────────────────────────────────
5
+ *
6
+ * 구글이 OpenAI 호환 창구를 낸다. 문서로 확인한 것 (ai.google.dev/gemini-api/docs/openai):
7
+ *
8
+ * base_url https://generativelanguage.googleapis.com/v1beta/openai/
9
+ * 목록 GET {base}/models Authorization: Bearer <열쇠>
10
+ * 대화 POST {base}/chat/completions · tools · tool_choice 그대로
11
+ *
12
+ * 그래서 Gemini 는 **주소 한 줄**이면 끝난다. deel 의 기본 규격이 이미 이
13
+ * 모양이라 adapter 를 안 건드린다.
14
+ *
15
+ * ── 다만 도구 스키마에 걸릴 수 있다 ────────────────────────────────────
16
+ *
17
+ * 구글 문서가 「함수 호출은 OpenAPI 스키마의 일부만 지원하고, 크거나 깊이
18
+ * 중첩된 스키마는 거절될 수 있다」 고 적어 두었다. deel 의 Write(files[]) ·
19
+ * Edit(edits[]) · Move(moves[]) 는 객체 배열이라 여기 걸릴 수 있다.
20
+ *
21
+ * 추측하지 않는다 — `deel diagnose` 가 그 자리에서 진짜로 찔러 본다. 걸리면
22
+ * 그때 배열 도구를 한 개씩 쓰도록 낮춘다. 미리 낮춰 두면 멀쩡한 자리에서까지
23
+ * 한 번에 한 파일씩 고치게 되고, 그건 몇 분을 그냥 버리는 일이다.
24
+ */
25
+ export const 제공자 = {
26
+ id: 'gemini',
27
+ 이름: 'Google Gemini',
28
+ 한줄: 'OpenAI 호환 창구가 있어 주소 한 줄이면 됩니다.',
29
+
30
+ 규격: 'openai',
31
+ 인증: 'bearer',
32
+ 주소들: () => ['https://generativelanguage.googleapis.com/v1beta/openai/'],
33
+ 리전들: null,
34
+
35
+ 규격됐나: true,
36
+ 빈칸: ['열쇠'],
37
+ 열쇠받는곳: 'https://aistudio.google.com/apikey',
38
+ 키앞머리: [/^AIza/],
39
+
40
+ // 왜 비워 두는지는 providers/openai.js 의 같은 자리에 적어 두었다.
41
+ 요금: {},
42
+ 요금기준: null,
43
+ 요금표주소: 'https://ai.google.dev/gemini-api/docs/pricing',
44
+
45
+ /*
46
+ * 도구 스키마가 걸릴 수 있는 곳이라고 표를 달아 둔다. 화면이 이걸 보고
47
+ * 「걸리면 deel diagnose 로 확인하세요」 한 줄을 붙인다.
48
+ */
49
+ 도구스키마조심: '함수 호출이 OpenAPI 스키마의 일부만 지원됩니다 — 깊이 중첩된 스키마는 거절될 수 있습니다.',
50
+
51
+ 오류읽기({ status, 서버말 }) {
52
+ if (status === 400 && /API key not valid/i.test(서버말)) {
53
+ return '열쇠를 못 알아봅니다 — AI Studio 에서 받은 열쇠(AIza…)가 맞는지 보세요.';
54
+ }
55
+ if (status === 400 && /schema|function|tool/i.test(서버말)) {
56
+ return '도구 스키마를 거절했습니다 — Gemini 는 스키마의 일부만 받습니다.'
57
+ + ' deel diagnose 로 어느 도구가 걸리는지 확인하세요.';
58
+ }
59
+ if (status === 403 && /SERVICE_DISABLED|PERMISSION_DENIED/i.test(서버말)) {
60
+ return '이 프로젝트에서 Generative Language API 가 꺼져 있습니다 — 콘솔에서 켜세요.';
61
+ }
62
+ return null;
63
+ },
64
+ };
@@ -0,0 +1,121 @@
1
+ /*
2
+ * 붙일 만한 곳들 — **명단이 아니라 주소 메모다.**
3
+ *
4
+ * ── 이게 「지원 목록」이 아닌 까닭 ───────────────────────────────────────
5
+ *
6
+ * 회사마다 규격도 도메인도 다르다. 명단을 코드에 박으면 그 명단이 곧 지원
7
+ * 목록이 되고, 없는 회사는 안 되는 것처럼 보인다. 그리고 반드시 낡는다.
8
+ *
9
+ * 이 도구는 처음부터 스캐너였다(backend/detect.js). 주소만 주면 규격도 인증도
10
+ * 알아낸다 — 후보를 넓혀 보고, 첫 401 에서 안 멈추고, 401/403 과 404 를
11
+ * 구분하고, 200 인데 JSON 이 아니면 성공으로 안 친다. 그 힘은 그대로 둔다.
12
+ *
13
+ * 그래서 여기 있는 것들의 역할은 딱 하나다 —
14
+ * **아무도 못 외우는 주소를 대신 적어 두는 것.**
15
+ *
16
+ * bedrock-runtime.ap-northeast-2.amazonaws.com/v1
17
+ * generativelanguage.googleapis.com/v1beta/openai/
18
+ *
19
+ * 이걸 외워서 치라고 하면 아무도 안 쓴다. 목록에 없는 곳은 「주소를 직접
20
+ * 넣기」로 그냥 된다 — 화면에서도 그 길을 늘 위에 둔다.
21
+ *
22
+ * ── 늘리는 법 ───────────────────────────────────────────────────────────
23
+ *
24
+ * 파일 하나 더하고 아래 `제공자들` 에 줄 하나 더한다. 코드 분기가 아니라
25
+ * 데이터라서, 남이 PR 하기도 쉽고 회사가 제 것을 보태기도 쉽다.
26
+ *
27
+ * ── 열쇠를 절대 뿌리지 않는다 ───────────────────────────────────────────
28
+ *
29
+ * 「키만 붙여 넣으면 어디 것인지 알아서 찾아 주기」 는 편하지만, 그걸 여러
30
+ * 벤더에 던져 보고 200 이 오는 곳을 찾는 식으로 만들면 안 된다. 그러면
31
+ * Anthropic 열쇠가 OpenAI 서버로, 다시 Google 서버로 전송된다. 401 이 오고
32
+ * 끝이지만 **열쇠는 이미 갔다.** 「내 것이 안 나간다」 가 정체성인 도구가
33
+ * 할 짓이 아니다.
34
+ *
35
+ * 그래서 앞머리로 짐작하고 **한 곳에만** 물어본다. 모르면 뿌리지 말고 사람에게
36
+ * 묻는다. 앞머리 표가 틀려도 손해가 없다 — 틀리면 401 이 오고 그때 물어보면
37
+ * 된다. 표는 순서를 정하는 힌트지 판단이 아니다.
38
+ */
39
+ import { 제공자 as openai } from './openai.js';
40
+ import { 제공자 as anthropic } from './anthropic.js';
41
+ import { 제공자 as gemini } from './gemini.js';
42
+ import { 제공자 as bedrock } from './bedrock.js';
43
+ import { 제공자 as 직접 } from './custom.js';
44
+
45
+ /** 화면에 보일 차례. 「직접 넣기」 는 언제나 목록에 있고, 화면에서는 위에 둔다. */
46
+ export const 제공자들 = [openai, anthropic, gemini, bedrock, 직접];
47
+
48
+ /** id 로 하나 집는다. 모르면 null — 지어내지 않는다. */
49
+ export function 제공자고르기(id) {
50
+ return 제공자들.find((p) => p.id === String(id ?? '')) ?? null;
51
+ }
52
+
53
+ /**
54
+ * 이 열쇠는 어디 것인가.
55
+ *
56
+ * @returns {{제공자, 왜: string} | null} 모르면 null. **그때는 아무 데도 안 묻는다.**
57
+ *
58
+ * 확신이 서는 앞머리 하나만 본다. 겹치는 앞머리가 있으면(예: `sk-ant-` 는
59
+ * `sk-` 로도 시작한다) **긴 것이 이긴다** — 짧은 쪽이 먼저 집으면 Anthropic
60
+ * 열쇠를 OpenAI 로 보내게 된다. 그게 여기서 제일 조심할 일이다.
61
+ */
62
+ export function 어디것일까(열쇠) {
63
+ const k = String(열쇠 ?? '').trim();
64
+ if (!k) return null;
65
+ let 제일긴것 = null;
66
+ for (const p of 제공자들) {
67
+ for (const 무늬 of p.키앞머리 ?? []) {
68
+ if (!무늬.test(k)) continue;
69
+ const 길이 = 무늬.source.length;
70
+ if (!제일긴것 || 길이 > 제일긴것.길이) 제일긴것 = { 제공자: p, 길이, 무늬 };
71
+ }
72
+ }
73
+ if (!제일긴것) return null;
74
+ return { 제공자: 제일긴것.제공자, 왜: `열쇠가 ${제일긴것.무늬.source.replace(/[\^\\]/g, '')} 로 시작합니다` };
75
+ }
76
+
77
+ /**
78
+ * 이 제공자에 붙을 주소 후보들. 앞엣것이 제일 그럴듯한 것이다.
79
+ *
80
+ * 목록을 주는 것이지 정하는 것이 아니다 — 진짜로 되는지는 detect 가 물어본다.
81
+ * 표에 적힌 것을 믿고 그냥 쓰면, 그 표가 낡은 날 사람은 「연결 실패」만 본다.
82
+ */
83
+ export function 주소후보(제공자, { 리전 = null } = {}) {
84
+ if (!제공자?.주소들) return [];
85
+ return 제공자.주소들({ 리전: 리전 ?? 제공자.기본리전 ?? null }).filter(Boolean);
86
+ }
87
+
88
+ /**
89
+ * 막힌 까닭을 사람 말로.
90
+ *
91
+ * 제공자마다 처음 막히는 자리가 다르다 — Bedrock 은 모델 접근 신청, 벤더
92
+ * 직접은 잔액, 사내는 프록시. 여기서 갈라 주지 않으면 전부 「연결 실패」 한
93
+ * 마디가 되고, 사람은 주소를 의심하며 엉뚱한 데를 판다.
94
+ *
95
+ * @returns {string | null} 아는 자리만 답한다. 모르면 null 이고, 그때는
96
+ * **서버가 한 말을 그대로** 보여 주는 편이 낫다.
97
+ */
98
+ export function 막힌까닭(제공자, { status, 서버말 = '' } = {}) {
99
+ const 공통 = 공통까닭(status, String(서버말 ?? ''));
100
+ const 제것 = 제공자?.오류읽기?.({ status, 서버말: String(서버말 ?? '') }) ?? null;
101
+ return 제것 ?? 공통;
102
+ }
103
+
104
+ /*
105
+ * 어디서나 같은 뜻인 것들. 제공자 파일에 네 번 적지 않는다.
106
+ *
107
+ * 401 을 「연결 실패」로 뭉개던 것이 이번 판에서 고치는 자리다. 401 은 닿은
108
+ * 것이다 — 주소는 맞고 열쇠가 틀렸거나 만료된 것이라, 주소를 의심하면 안 된다.
109
+ */
110
+ function 공통까닭(status, 서버말) {
111
+ if (status === 401) {
112
+ return /expired|만료/i.test(서버말)
113
+ ? '열쇠가 만료됐습니다 — 새로 발급받아 다시 넣으세요.'
114
+ : '열쇠를 받아 주지 않았습니다 — 주소는 닿았습니다. 열쇠가 틀렸거나 만료됐거나, 인증 방식이 다릅니다.';
115
+ }
116
+ if (status === 403) return '닿았지만 권한이 없습니다 — 열쇠는 맞는데 이 모델·이 자원을 쓸 권한이 없습니다.';
117
+ if (status === 404) return '그 주소에 이 규격이 없습니다 — 주소를 다시 보세요 (열쇠 문제가 아닙니다).';
118
+ if (status === 429) return '너무 자주 불렀거나 한도를 다 썼습니다 — 잠시 뒤에 다시 하세요.';
119
+ if (status === 402) return '잔액·한도가 모자랍니다 — 결제 상태를 확인하세요.';
120
+ return null;
121
+ }
@@ -0,0 +1,68 @@
1
+ /*
2
+ * OpenAI 직접 (GPT · Codex).
3
+ *
4
+ * 규격이 이미 있다 — deel 의 기본 규격이 OpenAI 호환이다. 그래서 여기서 할
5
+ * 일은 주소를 대신 채우고, 막힌 까닭을 알아듣는 것뿐이다.
6
+ *
7
+ * 이 파일에는 **코드 분기가 없다.** 데이터 한 장이다. 그게 요점이다 —
8
+ * 제공자를 늘리는 일이 코드를 고치는 일이 되면, 목록에 없는 곳은 못 쓰는
9
+ * 곳이 된다 (providers/index.js 머리말).
10
+ */
11
+ export const 제공자 = {
12
+ id: 'openai',
13
+ 이름: 'OpenAI (GPT · Codex)',
14
+ 한줄: '벤더에서 바로. 열쇠 하나만 있으면 됩니다.',
15
+
16
+ // ── 축 셋 (계획표 05절) ────────────────────────────────────────────
17
+ 규격: 'openai', // conn.kind
18
+ 인증: 'bearer', // backend/http.js 의 AUTH_STYLES id
19
+ 주소들: () => ['https://api.openai.com/v1'],
20
+ 리전들: null, // 리전을 고를 일이 없다
21
+
22
+ /*
23
+ * 이 규격을 deel 이 말할 줄 아나.
24
+ *
25
+ * 다섯이 다 true 다 — 말 못 하는 것은 아예 안 싣는다는 뜻이다. 그런데도
26
+ * 칸을 남겨 둔다. 새 제공자를 붙이다가 규격이 반쯤 붙은 상태로 목록에
27
+ * 올라가는 것이 제일 나쁘기 때문이다: 목록에 보이고, 열쇠를 넣고, 연결도
28
+ * 되고, 첫 한마디에서 400 이 난다. 그 화면으로는 무엇이 잘못됐는지 알
29
+ * 길이 없다. false 로 두면 설치 화면이 「아직」 이라고 또렷하게 말하고
30
+ * 저장까지 안 간다 (setup.js).
31
+ */
32
+ 규격됐나: true,
33
+ 빈칸: ['열쇠'],
34
+ 열쇠받는곳: 'https://platform.openai.com/api-keys',
35
+
36
+ /*
37
+ * 열쇠 앞머리.
38
+ *
39
+ * `sk-proj-` 를 `sk-` 보다 먼저 두는 것이 뜻이 있는 게 아니다 — 고르는 쪽이
40
+ * **제일 긴 무늬**를 집는다(index.js 의 어디것일까). 그래야 `sk-ant-` 가
41
+ * `sk-` 에 먼저 걸려 Anthropic 열쇠가 OpenAI 로 가는 일이 안 생긴다.
42
+ */
43
+ 키앞머리: [/^sk-proj-/, /^sk-/],
44
+
45
+ /*
46
+ * 요금.
47
+ *
48
+ * **비워 둔다.** 아는 척하지 않는다.
49
+ *
50
+ * 단가는 반드시 낡는다. 옛 단가로 「$0.42」 를 찍으면 사람은 그 숫자를 믿고
51
+ * 큰 작업을 돌린다 — 0원으로 찍는 것 다음으로 나쁜 것이 낡은 값을 확인한
52
+ * 낯으로 내미는 것이다. 아는 것이 없으면 토큰만 적고 「요금 모름」 이라고
53
+ * 말한다. 쓰는 사람이 제 단가를 설정에 넣으면 그때부터 찍는다.
54
+ */
55
+ 요금: {},
56
+ 요금기준: null,
57
+ 요금표주소: 'https://openai.com/api/pricing/',
58
+
59
+ 오류읽기({ status, 서버말 }) {
60
+ if (status === 429 && /quota|billing/i.test(서버말)) {
61
+ return '이 열쇠의 한도를 다 썼거나 결제가 안 걸려 있습니다 — 사용량·결제 화면을 보세요.';
62
+ }
63
+ if (status === 404 && /model/i.test(서버말)) {
64
+ return '그 모델 이름을 이 열쇠로는 못 씁니다 — 이름이 틀렸거나, 아직 안 열린 모델입니다.';
65
+ }
66
+ return null;
67
+ },
68
+ };
package/src/repl.js CHANGED
@@ -24,7 +24,9 @@ import { History } from './safety/undo.js';
24
24
  import { Audit } from './safety/audit.js';
25
25
  import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식 } from './config.js';
26
26
  import { discover } from './skills/discover.js';
27
- import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
27
+ import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
28
+ import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
29
+ import { 세션요금, 돈셈, 돈말 } from './backend/price.js';
28
30
  import { Store, latest, prune } from './agent/store.js';
29
31
  import { Threads } from './agent/threads.js';
30
32
  import { 못박기 } from './agent/pins.js';
@@ -37,6 +39,7 @@ import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/appro
37
39
  import { 추천, 채울글 } from './ui/complete.js';
38
40
  import { 접어쓰기 } from './ui/wrap.js';
39
41
  import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들 } from './ui/pastechip.js';
42
+ import { 이력지킴이 } from './ui/histline.js';
40
43
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
41
44
  import { renderDiff, shortStat } from './ui/diff.js';
42
45
  import { expand as expandMentions } from './agent/mention.js';
@@ -195,10 +198,29 @@ export async function chatLoop(opts = {}) {
195
198
  vision: prof.vision ?? false,
196
199
  };
197
200
 
201
+ /*
202
+ * ── 주소와 허가를 뗀다 ───────────────────────────────────────────────
203
+ *
204
+ * 여태는 baseUrl 만 보고 그 자리를 열었다. 그러면 설정 파일 한 줄만 고쳐도
205
+ * 바깥으로 나간다 — 화면에 ↗ 가 뜨긴 하지만 그건 알리는 것이지 막는 것이
206
+ * 아니다. 「완전 로컬」 이 정체성인데 코드가 지키는 것은 그 절반이었다.
207
+ *
208
+ * 이제 **둘 다 있어야** 나간다: 주소(baseUrl)와 허가(profile.online).
209
+ * 주소만 바꿔서는 못 나간다 — 실수로는 안 되고, 마음먹어야 된다.
210
+ *
211
+ * 허가를 물어야 하는 자리면 여기서 안 연다. 머리말을 보여 준 **뒤에** 묻고,
212
+ * 그때 연다 — 어디로 나가는지를 보고 답하게 하는 것이 요점이다.
213
+ */
214
+ const 바깥연결 = 바깥인가(conn.base);
215
+ const 실행모드 = 지금모드({
216
+ online: opts.online === true,
217
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
218
+ offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
219
+ });
220
+ const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
198
221
  // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
199
- allowEndpoint(conn.base);
200
- // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
201
- if (opts.offline ?? prof.offline ?? cfg?.offline) setOffline(true);
222
+ if (!나감.물어볼까) allowEndpoint(conn.base);
223
+ if (실행모드.허가무시) setOffline(true);
202
224
 
203
225
  /*
204
226
  * ── 끝났을 때 알리기 ─────────────────────────────────────────────────
@@ -236,6 +258,17 @@ export async function chatLoop(opts = {}) {
236
258
  effort: opts.effort ?? 'save',
237
259
  maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
238
260
  });
261
+ // 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
262
+ // session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
263
+ session.실행모드 = 실행모드;
264
+ /*
265
+ * 요금표는 사람이 설정에 적은 것만 쓴다 (backend/price.js).
266
+ *
267
+ * 여기서 한 번만 읽는다. 상태줄은 글자를 칠 때마다 다시 그려지는데,
268
+ * 그때마다 설정 파일을 열면 디스크를 초당 수십 번 두드리게 된다.
269
+ */
270
+ session.요금표 = cfg?.요금 ?? null;
271
+ session.제공자 = prof?.제공자 ?? null;
239
272
 
240
273
  // ── 대화 이어하기 ─────────────────────────────────────────────────────
241
274
  // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
@@ -449,6 +482,11 @@ export async function chatLoop(opts = {}) {
449
482
  // 붙여넣는 중인가 (bracketed paste). 키 처리와 'line' 이 같이 본다.
450
483
  let 붙여넣는중 = false;
451
484
 
485
+ // 위/아래로 이력을 뒤지는 동안 치던 글을 붙잡아 둔다. 왜 우리가 갖는지는
486
+ // ui/histline.js 에 — 한 줄로 줄이면, readline 의 화살표 동작이 Node 판마다
487
+ // 다르고 옛 판에서는 치던 글이 그냥 없어지기 때문이다.
488
+ const 이력지킴 = 이력지킴이();
489
+
452
490
  /*
453
491
  * ── 붙여넣은 덩이를 접어 둔다 ────────────────────────────────────────
454
492
  *
@@ -475,6 +513,9 @@ export async function chatLoop(opts = {}) {
475
513
  };
476
514
 
477
515
  rl.on('line', (원래줄) => {
516
+ // 줄이 나갔으니 붙잡아 둔 「치던 글」도 놓는다. 안 놓으면 다음 줄을 치다가
517
+ // 위/아래를 눌렀을 때 아까 보낸 글이 되살아난다.
518
+ 이력지킴.비우기();
478
519
  // 상자 안에서 바꾼 줄을 여기서 진짜 줄바꿈으로 되돌린다. 아래 코드는
479
520
  // 전부 평범한 \n 만 본다 — 줄표가 이 아래로는 한 글자도 안 새어 나간다.
480
521
  const l = 펴기(원래줄);
@@ -787,6 +828,24 @@ export async function chatLoop(opts = {}) {
787
828
  });
788
829
  return;
789
830
  }
831
+ /*
832
+ * 위/아래로 이력을 뒤져도 치던 글을 안 잃는다.
833
+ *
834
+ * readline 이 이 키를 처리한 **뒤에** 자리를 봐야 하므로 한 틱 미룬다.
835
+ * 아래 그리기보다 먼저 잡아 두는 자리이니, 되돌린 글은 이어서 도는
836
+ * 그리기가 그대로 화면에 올린다.
837
+ */
838
+ if (key && (key.name === 'up' || key.name === 'down')) {
839
+ setImmediate(() => {
840
+ const 되돌릴 = 이력지킴.되돌릴것(key.name, rl.historyIndex ?? -1);
841
+ if (되돌릴 === null || rl.line === 되돌릴) return;
842
+ rl.line = 되돌릴;
843
+ rl.cursor = 되돌릴.length;
844
+ if (입력기다림 && 묻는중 === null) {
845
+ 화면.입력갱신(session, 펴기(rl.line), rl.cursor, 지금추천(펴기(rl.line)));
846
+ }
847
+ });
848
+ }
790
849
  /*
791
850
  * 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
792
851
  *
@@ -798,6 +857,9 @@ export async function chatLoop(opts = {}) {
798
857
  그릴예정 = true;
799
858
  setImmediate(() => {
800
859
  그릴예정 = false;
860
+ // 이력을 안 뒤지는 동안의 줄을 적어 둔다 — 화살표로 돌아왔을 때
861
+ // 돌려줄 것이 이것이다. readline 이 다 처리한 뒤라야 제 값이 보인다.
862
+ 이력지킴.적기(rl.line ?? '', rl.historyIndex ?? -1);
801
863
  if (입력기다림) {
802
864
  화면.입력갱신(session, 펴기(rl.line), rl.cursor ?? 0, 지금추천(펴기(rl.line)));
803
865
  } else {
@@ -1159,10 +1221,8 @@ export async function chatLoop(opts = {}) {
1159
1221
  * 꼴이 되는데, 그건 안 보여 주느니만 못하다.
1160
1222
  */
1161
1223
  {
1162
- let 바깥 = false;
1163
- try { 바깥 = !isLocalHost(new URL(conn.base).hostname); } catch { 바깥 = false; }
1164
1224
  say('');
1165
- await 인트로({ 바깥, 곁말: 기본곁말(바깥), 쓰기: (t) => process.stdout.write(t) });
1225
+ await 인트로({ 바깥: 바깥연결, 곁말: 기본곁말(바깥연결), 쓰기: (t) => process.stdout.write(t) });
1166
1226
  }
1167
1227
 
1168
1228
  // ── 머리말 ────────────────────────────────────────────────────────────
@@ -1207,6 +1267,46 @@ export async function chatLoop(opts = {}) {
1207
1267
  // 끝나 버려서, 급히 멈추려던 사람이 대화를 통째로 닫는 일이 실제로 있었다.
1208
1268
  say(` ${c.gray('/help 명령 목록')} ${c.gray('ESC 중단')} ${c.gray('Ctrl+C 중단·끝내기')}`);
1209
1269
 
1270
+ /*
1271
+ * ── 바깥으로 나가도 되는지 한 번 묻는다 ─────────────────────────────
1272
+ *
1273
+ * 여기서 묻는 까닭은 **머리말 다음**이어야 하기 때문이다. 바로 위 화면에
1274
+ * `보냄 바깥 api.anthropic.com` 이 찍혀 있다. 그것을 보고 답하는 것과,
1275
+ * 켜자마자 "나가도 될까요?" 를 받는 것은 전혀 다른 물음이다.
1276
+ *
1277
+ * 한 번만 묻는다. 답하면 그 프로필에 적어 두고 다음부터 안 묻는다 — 기본값을
1278
+ * 뒤집으면서 쓰던 사람을 안 깨뜨리는 길이 이것이다. 막는 것이 목적이 아니라
1279
+ * **모르고 나가는 것**을 없애는 것이 목적이다.
1280
+ */
1281
+ if (나감.물어볼까) {
1282
+ const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
1283
+ say('');
1284
+ say(` ${c.yellow('↗')} ${c.bold('이 연결은 이 컴퓨터 밖으로 나갑니다.')}`);
1285
+ say(` ${c.gray('보낼 곳')} ${c.white(주소가리기(어디))}`);
1286
+ say(` ${c.gray('보내는 것 — 시킨 말, 그리고 모델이 읽은 파일의 내용입니다.')}`);
1287
+ say(` ${c.gray('로컬 모델(127.0.0.1)이나 사내망 주소였다면 이 물음이 안 나옵니다.')}`);
1288
+ say('');
1289
+ const 답 = String(await ask(`나가도 될까요? ${c.gray('⏎ 허락하고 기억 · n 그만')}`, { def: 'y' }))
1290
+ .trim().toLowerCase();
1291
+ if (['n', 'no', 'ㄴ', '아니', '아니요', '아니오', '취소', '그만'].includes(답)) {
1292
+ say('');
1293
+ say(` ${c.gray('나가지 않았습니다. 아무것도 안 보냈습니다.')}`);
1294
+ say(` ${c.gray('이 컴퓨터 안의 모델을 붙이려면')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')} ${c.gray('를 쓰세요.')}`);
1295
+ rl.close();
1296
+ return 0;
1297
+ }
1298
+ prof.online = true;
1299
+ try {
1300
+ saveCfg(cfg);
1301
+ say(` ${c.gray('허락을 적어 뒀습니다 — 이 프로필은 다음부터 안 묻습니다.')}`
1302
+ + ` ${c.gray('되돌리려면 설정에서')} ${c.white('online')} ${c.gray('을 지우면 됩니다.')}`);
1303
+ } catch (e) {
1304
+ // 못 적어도 이번 판은 나간다. 사람이 방금 허락했다 — 그걸 무르는 것이 더 놀랍다.
1305
+ say(` ${mark.warn} ${c.gray(`허락을 저장하지 못했습니다 (${e.message}). 이번에만 나갑니다.`)}`);
1306
+ }
1307
+ allowEndpoint(conn.base);
1308
+ }
1309
+
1210
1310
  /*
1211
1311
  * 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
1212
1312
  * 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 상자화면은 테두리를 두른 칸을 그린다.
@@ -1968,6 +2068,17 @@ export async function chatLoop(opts = {}) {
1968
2068
  const dIn = session.usage.in - before.in;
1969
2069
  const dOut = session.usage.out - before.out;
1970
2070
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
2071
+ /*
2072
+ * 이번 턴에 나간 돈.
2073
+ *
2074
+ * 누적은 상태줄에 있다. 그런데 누적만 보면 **어느 한마디가 비쌌는지**를
2075
+ * 알 수가 없다. 긴 파일을 한 번 붙였을 때 그 한 턴이 앞의 스무 턴보다
2076
+ * 비싼 일이 흔하고, 사람이 버릇을 고칠 수 있는 건 그걸 봤을 때다.
2077
+ *
2078
+ * 요금을 모르면 안 적는다 (backend/price.js — 지어낸 요금은 안 찍는다).
2079
+ */
2080
+ const 이번돈 = 돈셈({ in: dIn, out: dOut }, 세션요금(session));
2081
+ if (이번돈 && 이번돈.달러 > 0) bits.push(돈말(이번돈.달러));
1971
2082
  say('');
1972
2083
  say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
1973
2084
 
package/src/report.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { c, say, rule, pad, width, mark } from './ui/ansi.js';
3
3
  import { 주소가리기 } from './safety/secrets.js';
4
4
  import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
5
+ import { 규격이름 } from './backend/adapter.js';
5
6
  import { 보관방식 } from './safety/keystore.js';
6
7
  import { load, activeProfile } from './config.js';
7
8
 
@@ -32,7 +33,7 @@ export function renderLine(r) {
32
33
  export function renderHeader(facts) {
33
34
  say('');
34
35
  rule('연결', 74);
35
- const kindName = facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환';
36
+ const kindName = 규격이름(facts.shape);
36
37
  const rows = [
37
38
  ['규격', kindName],
38
39
  ['주소', 주소가리기(facts.base)],
@@ -124,7 +125,7 @@ export function plainReport(facts, results, v) {
124
125
  'deel 연결 진단 보고서',
125
126
  `생성 시각 ${at}`,
126
127
  '',
127
- `규격 ${facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환'}`,
128
+ `규격 ${규격이름(facts.shape)}`,
128
129
  `주소 ${주소가리기(facts.base)}`,
129
130
  ...(프록시줄(facts.base) ? [`프록시 ${프록시줄(facts.base)}`] : []),
130
131
  `인증 ${facts.auth === 'none' ? '없음' : facts.auth}`,