@eottabom/tokenizer-core 1.0.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.md ADDED
@@ -0,0 +1,115 @@
1
+ # @eottabom/tokenizer-core
2
+
3
+ 오프라인 환경에서 LLM 3사(OpenAI, Anthropic, Google)의 토큰 수를 계산하는 순수 JavaScript 토크나이저입니다.
4
+
5
+ API 키 없이, 외부 의존성 없이, 로컬에서 바로 동작합니다.
6
+
7
+ ## 특징
8
+
9
+ - **의존성 제로**: tiktoken WASM, HuggingFace transformers 등 무거운 라이브러리 없이 순수 JS로 BPE 알고리즘을 직접 구현
10
+ - **오프라인 동작**: 토크나이저 데이터(vocab, merges)를 프로젝트에 내장
11
+ - **3사 통합**: OpenAI(tiktoken), Anthropic(Claude), Google(Gemma3) 토크나이저를 하나의 인터페이스로 제공
12
+
13
+ ## 정확도
14
+
15
+ | Provider | 토크나이저 출처 | 정확도 |
16
+ |---|---|---|
17
+ | OpenAI | tiktoken 공식 인코딩 데이터 포팅 | 공식 tiktoken과 동일 |
18
+ | Anthropic | Xenova/claude-tokenizer 데이터 | 근사치 (~10% 오차 가능) |
19
+ | Google | unsloth/gemma-3-1b-it 데이터 | Gemma3 토크나이저와 동일 |
20
+
21
+ > Anthropic은 공식 오프라인 토크나이저를 공개하지 않아, 공개된 토크나이저 데이터 기반의 근사치입니다.
22
+
23
+ ## CLI 사용법
24
+
25
+ ```bash
26
+ # 워크스페이스 루트에서 설치
27
+ npm install
28
+
29
+ # 텍스트 직접 입력
30
+ npx tokenizer-core -t "Hello, world! 안녕하세요."
31
+
32
+ # 파일에서 읽기
33
+ npx tokenizer-core -f ./document.txt
34
+
35
+ # 특정 프로바이더만 출력
36
+ npx tokenizer-core -t "텍스트" -p openai
37
+ npx tokenizer-core -t "텍스트" -p anthropic
38
+ npx tokenizer-core -t "텍스트" -p google
39
+ ```
40
+
41
+ ### 옵션
42
+
43
+ | 옵션 | 설명 | 기본값 |
44
+ |---|---|---|
45
+ | `-t, --text <string>` | 토큰을 계산할 텍스트 (`-t` 또는 `-f` 중 하나 필수) | - |
46
+ | `-f, --file <path>` | 토큰을 계산할 파일 경로 (UTF-8) (`-t` 또는 `-f` 중 하나 필수) | - |
47
+ | `-p, --provider <type>` | (선택) 필터링 (`all`, `openai`, `anthropic`, `google`) | `all` |
48
+
49
+ ### 출력 예시
50
+
51
+ ```
52
+ ==================================================
53
+ 입력된 텍스트 길이: 1,882 글자
54
+ ==================================================
55
+
56
+ [ OpenAI ]
57
+ ▶ GPT-4.1, o3, o4-mini, GPT-4o (o200k_base) : 812 Tokens
58
+ ▶ GPT-4-turbo, GPT-4, GPT-3.5 (cl100k_base) : 995 Tokens
59
+ ▶ Codex, text-davinci (p50k_base) : 1,857 Tokens
60
+
61
+ [ Anthropic ]
62
+ ▶ Claude 4.6 ~ 3 Family : ~1,095 Tokens (근사치)
63
+
64
+ [ Google ]
65
+ ▶ Gemini 2.5 ~ 1.5, Gemma (Gemma3) : 842 Tokens
66
+
67
+ ※ Anthropic은 공식 오프라인 토크나이저가 없어 근사치입니다 (~10% 오차 가능)
68
+ ==================================================
69
+ ```
70
+
71
+ ## 라이브러리로 사용
72
+
73
+ ```javascript
74
+ import { countTokens, providers } from '@eottabom/tokenizer-core';
75
+
76
+ // 개별 프로바이더 토큰 수 계산
77
+ const count = countTokens("Hello, world!", "openai_o200k");
78
+
79
+ // 사용 가능한 프로바이더 목록
80
+ // ["openai_o200k", "openai_cl100k", "openai_p50k", "anthropic", "google"]
81
+ console.log(providers);
82
+ ```
83
+
84
+ ### 프로바이더 ID
85
+
86
+ | ID | 대상 모델 |
87
+ |---|---|
88
+ | `openai_o200k` | GPT-4.1, o3, o4-mini, GPT-4o |
89
+ | `openai_cl100k` | GPT-4-turbo, GPT-4, GPT-3.5 |
90
+ | `openai_p50k` | Codex, text-davinci |
91
+ | `anthropic` | Claude 4.6 ~ 3 전 계열 |
92
+ | `google` | Gemini 2.5 ~ 1.5, Gemma |
93
+
94
+ ## 구조
95
+
96
+ ```
97
+ packages/tokenizer-core/
98
+ ├── cli.js ← CLI 진입점
99
+ ├── index.js ← 통합 API (countTokens)
100
+ ├── bpe.js ← BPE 알고리즘 (rank 기반 + merge 목록 기반)
101
+ ├── tiktoken.js ← OpenAI tiktoken 포맷 처리
102
+ ├── hf-bpe.js ← HuggingFace tokenizer.json 포맷 처리
103
+ └── data/
104
+ ├── o200k_base.json (2.2MB)
105
+ ├── cl100k_base.json (1.0MB)
106
+ ├── p50k_base.json (0.5MB)
107
+ ├── claude.json (1.7MB)
108
+ └── gemma3.json (14MB)
109
+ ```
110
+
111
+ ## 토크나이저 데이터 출처
112
+
113
+ - **OpenAI**: [tiktoken](https://github.com/openai/tiktoken) 인코딩 데이터
114
+ - **Anthropic**: [Xenova/claude-tokenizer](https://huggingface.co/Xenova/claude-tokenizer) (HuggingFace)
115
+ - **Google**: [unsloth/gemma-3-1b-it](https://huggingface.co/unsloth/gemma-3-1b-it) (HuggingFace)
package/bpe.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * bpe.js - BPE (Byte Pair Encoding) 핵심 알고리즘
3
+ *
4
+ * BPE는 텍스트를 토큰으로 분할하는 알고리즘으로,
5
+ * "가장 우선순위가 높은 인접 쌍을 반복적으로 병합"하는 원리입니다.
6
+ *
7
+ * 두 가지 변형을 제공합니다:
8
+ * 1. bytePairEncode - rank 기반 (tiktoken/OpenAI용)
9
+ * 2. mergeBPE - merge 목록 기반 (HuggingFace/Claude,Gemma용)
10
+ */
11
+
12
+ // ============================================================
13
+ // 1. Rank-based BPE (tiktoken 방식 - OpenAI에서 사용)
14
+ // ============================================================
15
+
16
+ /**
17
+ * rank 기반 BPE 인코딩
18
+ *
19
+ * tiktoken은 모든 가능한 바이트 조합에 "rank(순위)"를 부여합니다.
20
+ * rank가 낮을수록 우선순위가 높아 먼저 병합됩니다.
21
+ *
22
+ * @param {number[]} piece - 입력 바이트 배열 (UTF-8 인코딩된 텍스트 조각)
23
+ * @param {Map<string, number>} ranks - 바이트 시퀀스 → rank 매핑
24
+ * key: 쉼표로 연결된 바이트열 (예: "72,101,108" = "Hel")
25
+ * value: rank 번호 (낮을수록 병합 우선)
26
+ * @returns {number[]} 토큰 ID 배열
27
+ */
28
+ export function bytePairEncode(piece, ranks) {
29
+ // 1바이트는 그 자체가 토큰이므로 바로 반환
30
+ if (piece.length === 1) {
31
+ return [ranks.get(String(piece[0]))];
32
+ }
33
+
34
+ // 병합 수행 후, 각 구간을 rank에서 찾아 토큰 ID로 변환
35
+ const parts = bytePairMerge(piece, ranks);
36
+ return parts
37
+ .map(p => ranks.get(piece.slice(p.start, p.end).join(',')))
38
+ .filter(x => x != null);
39
+ }
40
+
41
+ /**
42
+ * rank 기반 BPE의 핵심 병합 루프
43
+ *
44
+ * 동작 원리:
45
+ * 1. 각 바이트를 개별 구간(part)으로 시작
46
+ * 2. 인접한 두 구간을 합쳤을 때의 rank를 확인
47
+ * 3. rank가 가장 낮은(우선순위 높은) 쌍을 병합
48
+ * 4. 더 이상 병합할 수 없을 때까지 반복
49
+ *
50
+ * @param {number[]} piece - 바이트 배열
51
+ * @param {Map<string, number>} ranks - rank 맵
52
+ * @returns {{start: number, end: number}[]} 병합된 구간 배열
53
+ */
54
+ function bytePairMerge(piece, ranks) {
55
+ // 초기 상태: 각 바이트가 하나의 구간
56
+ // 예: [72, 101, 108] → [{0,1}, {1,2}, {2,3}]
57
+ let parts = Array.from({ length: piece.length }, (_, i) => ({
58
+ start: i,
59
+ end: i + 1,
60
+ }));
61
+
62
+ while (parts.length > 1) {
63
+ let minRank = null;
64
+
65
+ // 인접 구간 쌍 중 rank가 가장 낮은 것을 찾기
66
+ for (let i = 0; i < parts.length - 1; i++) {
67
+ // 두 구간을 합친 바이트열의 rank를 조회
68
+ const key = piece.slice(parts[i].start, parts[i + 1].end).join(',');
69
+ const rank = ranks.get(key);
70
+ if (rank != null && (minRank == null || rank < minRank[0])) {
71
+ minRank = [rank, i];
72
+ }
73
+ }
74
+
75
+ // 병합 가능한 쌍이 없으면 종료
76
+ if (minRank == null) break;
77
+
78
+ // 가장 우선순위 높은 쌍을 하나의 구간으로 병합
79
+ const i = minRank[1];
80
+ parts[i] = { start: parts[i].start, end: parts[i + 1].end };
81
+ parts.splice(i + 1, 1);
82
+ }
83
+
84
+ return parts;
85
+ }
86
+
87
+ // ============================================================
88
+ // 2. Merge-list BPE (HuggingFace 방식 - Claude, Gemma에서 사용)
89
+ // ============================================================
90
+
91
+ /**
92
+ * merge 목록 기반 BPE 인코딩
93
+ *
94
+ * HuggingFace tokenizer.json에는 merge 규칙이 순서대로 나열되어 있습니다.
95
+ * 목록의 앞에 있을수록 우선순위가 높아 먼저 병합됩니다.
96
+ *
97
+ * 예) merges: ["▁ t", "e r", "▁t h", ...]
98
+ * → "▁"와 "t"를 먼저 병합, 그 다음 "e"와 "r" 병합, ...
99
+ *
100
+ * @param {string[]} symbols - 초기 심볼 배열 (개별 문자 또는 바이트 문자)
101
+ * @param {Map<string, number>} mergeRanks - "심볼A 심볼B" → 우선순위(인덱스) 매핑
102
+ * @returns {string[]} 병합이 완료된 심볼 배열 (각 심볼 = 하나의 토큰)
103
+ */
104
+ export function mergeBPE(symbols, mergeRanks) {
105
+ if (symbols.length <= 1) return symbols;
106
+
107
+ let word = [...symbols];
108
+
109
+ while (word.length > 1) {
110
+ // 현재 인접 쌍 중 우선순위가 가장 높은(인덱스가 가장 낮은) 것 찾기
111
+ let bestPair = null;
112
+ let bestRank = Infinity;
113
+
114
+ for (let i = 0; i < word.length - 1; i++) {
115
+ const pair = word[i] + ' ' + word[i + 1];
116
+ const rank = mergeRanks.get(pair);
117
+ if (rank !== undefined && rank < bestRank) {
118
+ bestRank = rank;
119
+ bestPair = [word[i], word[i + 1]];
120
+ }
121
+ }
122
+
123
+ // merge 목록에 해당하는 쌍이 없으면 종료
124
+ if (bestPair == null) break;
125
+
126
+ // 해당 쌍의 모든 출현을 병합
127
+ // 예: ["a", "b", "a", "b"] + merge("a","b") → ["ab", "ab"]
128
+ const [first, second] = bestPair;
129
+ const merged = first + second;
130
+ const newWord = [];
131
+ let i = 0;
132
+
133
+ while (i < word.length) {
134
+ if (i < word.length - 1 && word[i] === first && word[i + 1] === second) {
135
+ newWord.push(merged);
136
+ i += 2; // 두 심볼을 건너뜀
137
+ } else {
138
+ newWord.push(word[i]);
139
+ i++;
140
+ }
141
+ }
142
+
143
+ word = newWord;
144
+ }
145
+
146
+ return word;
147
+ }
package/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cli.js - 오프라인 LLM 3사 모델별 토큰 계산 CLI
5
+ *
6
+ * 사용법:
7
+ * node cli.js -t "텍스트" # 직접 텍스트 입력
8
+ * node cli.js -f ./file.txt # 파일에서 읽기
9
+ * node cli.js -t "텍스트" -p openai # 특정 프로바이더만 출력
10
+ *
11
+ * 옵션:
12
+ * -t, --text <string> 토큰을 계산할 텍스트
13
+ * -f, --file <path> 토큰을 계산할 파일 경로 (UTF-8)
14
+ * -p, --provider <type> 필터링 (all | openai | anthropic | google)
15
+ */
16
+
17
+ import { program } from 'commander';
18
+ import fs from 'fs';
19
+ import { countTokens } from './index.js';
20
+
21
+ // CLI 옵션 정의
22
+ program
23
+ .name('tokenizer-core')
24
+ .description('오프라인 LLM 3사 모델별 토큰 계산 CLI (의존성 제로)')
25
+ .option('-t, --text <string>', '토큰을 계산할 직접 텍스트 입력')
26
+ .option('-f, --file <path>', '토큰을 계산할 텍스트 파일 경로 (UTF-8)')
27
+ .option('-p, --provider <type>', '특정 제공자 필터링 (all, openai, anthropic, google)', 'all')
28
+ .parse();
29
+
30
+ const opts = program.opts();
31
+
32
+ // ── 입력 검증 ──────────────────────────────────────────────
33
+
34
+ if (!opts.text && !opts.file) {
35
+ console.error('오류: -t (텍스트) 또는 -f (파일) 옵션 중 하나를 반드시 제공해야 합니다.');
36
+ process.exit(1);
37
+ }
38
+
39
+ const validProviders = ['all', 'openai', 'anthropic', 'google'];
40
+ if (!validProviders.includes(opts.provider)) {
41
+ console.error(`오류: --provider 값은 ${validProviders.join(', ')} 중 하나여야 합니다.`);
42
+ process.exit(1);
43
+ }
44
+
45
+ // ── 텍스트 로드 ────────────────────────────────────────────
46
+
47
+ let text;
48
+ if (opts.file) {
49
+ if (!fs.existsSync(opts.file)) {
50
+ console.error(`오류: 파일을 찾을 수 없습니다: ${opts.file}`);
51
+ process.exit(1);
52
+ }
53
+ text = fs.readFileSync(opts.file, 'utf-8');
54
+ } else {
55
+ text = opts.text;
56
+ }
57
+
58
+ // ── 숫자 포맷팅 (천단위 콤마) ──────────────────────────────
59
+
60
+ function fmt(n) {
61
+ return n.toLocaleString('ko-KR');
62
+ }
63
+
64
+ // ── 결과 출력 ──────────────────────────────────────────────
65
+
66
+ const separator = '='.repeat(50);
67
+ const provider = opts.provider;
68
+
69
+ console.log(separator);
70
+ console.log(`입력된 텍스트 길이: ${fmt(text.length)} 글자`);
71
+ console.log(separator);
72
+
73
+ // [ OpenAI ] - 인코딩별로 대응하는 모델 그룹이 다름
74
+ if (provider === 'all' || provider === 'openai') {
75
+ console.log('');
76
+ console.log('[ OpenAI ]');
77
+ console.log(`▶ GPT-4.1, o3, o4-mini, GPT-4o (o200k_base) : ${fmt(countTokens(text, 'openai_o200k'))} Tokens`);
78
+ console.log(`▶ GPT-4-turbo, GPT-4, GPT-3.5 (cl100k_base) : ${fmt(countTokens(text, 'openai_cl100k'))} Tokens`);
79
+ console.log(`▶ Codex, text-davinci (p50k_base) : ${fmt(countTokens(text, 'openai_p50k'))} Tokens`);
80
+ }
81
+
82
+ // [ Anthropic ] - Claude 3 이후 전 계열 공통 토크나이저
83
+ // 공식 오프라인 토크나이저가 없어 근사치 (실제 API 대비 ~10% 오차 가능)
84
+ if (provider === 'all' || provider === 'anthropic') {
85
+ console.log('');
86
+ console.log('[ Anthropic ]');
87
+ console.log(`▶ Claude 4.6 ~ 3 Family : ~${fmt(countTokens(text, 'anthropic'))} Tokens (근사치)`);
88
+ }
89
+
90
+ // [ Google ] - Gemini/Gemma 전 계열 공통 (Gemma3 토크나이저 기반)
91
+ if (provider === 'all' || provider === 'google') {
92
+ console.log('');
93
+ console.log('[ Google ]');
94
+ console.log(`▶ Gemini 2.5 ~ 1.5, Gemma (Gemma3) : ${fmt(countTokens(text, 'google'))} Tokens`);
95
+ }
96
+
97
+ console.log('');
98
+ console.log('※ Anthropic은 공식 오프라인 토크나이저가 없어 근사치입니다 (~10% 오차 가능)');
99
+ console.log(separator);