luang-cli 0.2.0 → 0.3.2

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/lib/models.js ADDED
@@ -0,0 +1,343 @@
1
+ 'use strict';
2
+
3
+ // lib/models.js — daftar model AI per provider untuk `luang-cli models`
4
+ // dan untuk sinkronisasi ke server (field `models` pada payload heartbeat
5
+ // POST /api/v1/agent/heartbeat dan pada `providers` frame hello WS —
6
+ // kontrak baru; backend menerima field opsional ini).
7
+ //
8
+ // Urutan sumber data:
9
+ // 1. Dinamis (hanya whitelist AMAN): opencode → coba perintah
10
+ // `opencode models`, gagal/ kosong → parse config opencode di
11
+ // ~/.config/opencode/ atau ~/.opencode, gagal juga → statis.
12
+ // 2. Registry statis = daftar saran per tipe provider (claude_code, codex,
13
+ // opencode, gemini_cli, custom). Ditulis apa adanya meski versi lama —
14
+ // ini datalist/saran, user tetap bebas mengetik model manual.
15
+ //
16
+ // KEBIJAKAN KETAT: binary `claude`, `gemini`, dan `codex` TIDAK PERNAH
17
+ // dieksekusi di sini — apa pun alasannya. Cukup registry statis / parse file.
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ // Batas payload ke server (kontrak heartbeat/hello).
25
+ const MAX_MODELS = 50;
26
+ const MAX_MODEL_LEN = 120;
27
+ const BIN_TIMEOUT_MS = 3000;
28
+ const CACHE_TTL_MS = 10 * 60 * 1000; // deteksi dinamis tidak dijalankan tiap heartbeat
29
+
30
+ // ── Registry statis (saran / datalist per tipe provider) ─────────────────────
31
+
32
+ const MODEL_REGISTRY = {
33
+ claude_code: [
34
+ 'claude-opus-4-1',
35
+ 'claude-sonnet-4-5',
36
+ 'claude-haiku-4-5',
37
+ 'claude-opus-4-1-20250805',
38
+ 'claude-sonnet-4-5-20250929',
39
+ 'claude-haiku-4-5-20251001',
40
+ 'claude-sonnet-4-20250514',
41
+ 'claude-opus-4-20250514',
42
+ 'claude-3-7-sonnet-latest',
43
+ 'claude-3-7-sonnet-20250219',
44
+ 'claude-3-5-sonnet-latest',
45
+ 'claude-3-5-sonnet-20241022',
46
+ 'claude-3-5-haiku-latest',
47
+ 'claude-3-5-haiku-20241022',
48
+ 'claude-3-opus-latest',
49
+ 'claude-3-sonnet-20240229',
50
+ ],
51
+ codex: [
52
+ 'gpt-5-codex',
53
+ 'gpt-5.1-codex',
54
+ 'gpt-5-codex-spark',
55
+ 'gpt-5-mini',
56
+ 'gpt-5-nano',
57
+ 'gpt-4.1',
58
+ 'gpt-4.1-mini',
59
+ 'gpt-4.1-nano',
60
+ 'o3',
61
+ 'o3-mini',
62
+ 'o4-mini',
63
+ 'codex-mini-latest',
64
+ ],
65
+ opencode: [
66
+ 'anthropic/claude-sonnet-4-5',
67
+ 'anthropic/claude-opus-4-1',
68
+ 'anthropic/claude-haiku-4-5',
69
+ 'openai/gpt-5-codex',
70
+ 'openai/gpt-5.1-codex',
71
+ 'openai/o4-mini',
72
+ 'google/gemini-2.5-pro',
73
+ 'google/gemini-2.5-flash',
74
+ 'opencode/grok-code-fast-1',
75
+ 'qwen/qwen3-coder',
76
+ 'deepseek/deepseek-v3.1',
77
+ 'mistral/codestral-latest',
78
+ ],
79
+ gemini_cli: [
80
+ 'gemini-2.5-pro',
81
+ 'gemini-2.5-flash',
82
+ 'gemini-2.5-flash-lite',
83
+ 'gemini-2.0-flash',
84
+ 'gemini-2.0-flash-lite',
85
+ 'gemini-1.5-pro',
86
+ 'gemini-1.5-flash',
87
+ 'gemini-1.0-pro',
88
+ 'gemma-3-27b-it',
89
+ 'gemma-3-12b-it',
90
+ ],
91
+ custom: [], // provider custom: tanpa saran — user ketik manual
92
+ };
93
+
94
+ // Whitelist binary yang BOLEH dijalankan untuk deteksi dinamis.
95
+ // Kebijakan ketat: claude / gemini / codex tidak pernah ada di daftar ini.
96
+ const DYNAMIC_OK = new Set(['opencode']);
97
+
98
+ const MODEL_TOKEN_RE = /^[\w.:+/-]+$/;
99
+ const PROVIDER_SLASH_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
100
+ // Kata umum yang bukan model (dipakai saat parse longgar).
101
+ const NON_MODEL_WORDS = new Set([
102
+ 'provider', 'providers', 'model', 'models', 'name', 'names', 'id', 'ids',
103
+ 'type', 'types', 'label', 'description', 'path', 'version', 'default',
104
+ 'baseurl', 'base_url', 'apikey', 'api_key',
105
+ ]);
106
+
107
+ // ── Util ─────────────────────────────────────────────────────────────────────
108
+
109
+ // Batas wire: maks 50 item, tiap item ≤120 karakter, unik, terpotong rapi.
110
+ function sanitizeModels(list) {
111
+ const out = [];
112
+ const seen = new Set();
113
+ for (const m of Array.isArray(list) ? list : []) {
114
+ if (out.length >= MAX_MODELS) break;
115
+ const s = String(m === null || m === undefined ? '' : m).trim().slice(0, MAX_MODEL_LEN);
116
+ if (!s || seen.has(s)) continue;
117
+ seen.add(s);
118
+ out.push(s);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function makeCollector() {
124
+ const list = [];
125
+ const seen = new Set();
126
+ const add = (v) => {
127
+ if (typeof v !== 'string') return;
128
+ const s = v.trim().slice(0, MAX_MODEL_LEN);
129
+ if (!s || seen.has(s)) return;
130
+ if (!MODEL_TOKEN_RE.test(s)) return;
131
+ if (NON_MODEL_WORDS.has(s.toLowerCase())) return;
132
+ seen.add(s);
133
+ list.push(s);
134
+ };
135
+ return { list, add };
136
+ }
137
+
138
+ function whichBinLocal(bin) {
139
+ try {
140
+ const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
141
+ encoding: 'utf8',
142
+ timeout: BIN_TIMEOUT_MS,
143
+ });
144
+ if (r.status === 0 && r.stdout) {
145
+ const first = String(r.stdout).split(/\r?\n/)[0].trim();
146
+ if (first) return first;
147
+ }
148
+ } catch (e) {
149
+ // abaikan
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // Kebijakan ketat: hanya binary yang NAMANYA mengandung "opencode" yang boleh
155
+ // dijalankan — pengaman ekstra agar path config yang aneh tidak pernah
156
+ // mengeksekusi claude/gemini/codex.
157
+ function resolveOpencodeBin(configuredPath) {
158
+ if (typeof configuredPath === 'string' && configuredPath.trim()) {
159
+ const p = configuredPath.trim();
160
+ if (path.basename(p).toLowerCase().includes('opencode') && fs.existsSync(p)) return p;
161
+ }
162
+ return whichBinLocal('opencode');
163
+ }
164
+
165
+ // ── Parse output / config ────────────────────────────────────────────────────
166
+
167
+ // Walker JSON longgar: kunci "models"/"model_ids"/"modelIds", field
168
+ // id/name/model, array berisi string, dan kunci berbentuk provider/model.
169
+ function walkForModels(node, add, depth) {
170
+ if (depth > 8 || node === null || node === undefined) return;
171
+ if (Array.isArray(node)) {
172
+ for (const v of node) {
173
+ if (typeof v === 'string') add(v);
174
+ else walkForModels(v, add, depth + 1);
175
+ }
176
+ return;
177
+ }
178
+ if (typeof node !== 'object') return;
179
+ for (const [k, v] of Object.entries(node)) {
180
+ if (k === 'models' || k === 'model_ids' || k === 'modelIds') {
181
+ if (typeof v === 'string') add(v);
182
+ else if (Array.isArray(v)) {
183
+ for (const x of v) {
184
+ if (typeof x === 'string') add(x);
185
+ else if (x && typeof x === 'object') {
186
+ add(x.id || x.name || x.model);
187
+ walkForModels(x, add, depth + 1);
188
+ }
189
+ }
190
+ } else if (v && typeof v === 'object') {
191
+ for (const [mk, mv] of Object.entries(v)) {
192
+ add(mk);
193
+ if (typeof mv === 'string') add(mv);
194
+ else walkForModels(mv, add, depth + 1);
195
+ }
196
+ }
197
+ continue;
198
+ }
199
+ if (typeof v === 'string') {
200
+ if (k === 'id' || k === 'name' || k === 'model') add(v);
201
+ if (PROVIDER_SLASH_RE.test(k)) add(k);
202
+ continue;
203
+ }
204
+ if (PROVIDER_SLASH_RE.test(k)) add(k);
205
+ walkForModels(v, add, depth + 1);
206
+ }
207
+ }
208
+
209
+ // Ambil id model dari output perintah: JSON bila bisa, kalau tidak baris teks
210
+ // (token pertama yang mengandung angka "/" — sengaja konservatif agar header
211
+ // tabel/teks bebas tidak dianggap model).
212
+ function extractModelIds(text) {
213
+ const col = makeCollector();
214
+ const raw = String(text || '').trim();
215
+ if (!raw) return col.list;
216
+ let parsed = null;
217
+ try {
218
+ parsed = JSON.parse(raw);
219
+ } catch (e) {
220
+ parsed = null;
221
+ }
222
+ if (parsed !== null && typeof parsed === 'object') {
223
+ walkForModels(parsed, col.add, 0);
224
+ } else {
225
+ for (const line of raw.split(/\r?\n/)) {
226
+ const tok = line.trim().split(/\s+/)[0];
227
+ if (tok && /[0-9/]/.test(tok)) col.add(tok);
228
+ }
229
+ }
230
+ return col.list;
231
+ }
232
+
233
+ function stripJsonComments(s) {
234
+ return String(s).replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1');
235
+ }
236
+
237
+ // Parse config opencode (bila ada): ~/.config/opencode/opencode[.jsonc] → ~/.opencode.
238
+ function opencodeConfigModels() {
239
+ const home = os.homedir();
240
+ const candidates = [
241
+ path.join(home, '.config', 'opencode', 'opencode.json'),
242
+ path.join(home, '.config', 'opencode', 'opencode.jsonc'),
243
+ path.join(home, '.opencode', 'opencode.json'),
244
+ ];
245
+ for (const p of candidates) {
246
+ try {
247
+ if (!fs.existsSync(p)) continue;
248
+ const txt = fs.readFileSync(p, 'utf8');
249
+ let obj = null;
250
+ try {
251
+ obj = JSON.parse(txt);
252
+ } catch (e) {
253
+ try {
254
+ obj = JSON.parse(stripJsonComments(txt));
255
+ } catch (e2) {
256
+ obj = null;
257
+ }
258
+ }
259
+ if (obj && typeof obj === 'object') {
260
+ const col = makeCollector();
261
+ walkForModels(obj, col.add, 0);
262
+ if (col.list.length) return col.list;
263
+ }
264
+ } catch (e) {
265
+ // file tidak terbaca → coba kandidat berikutnya
266
+ }
267
+ }
268
+ return [];
269
+ }
270
+
271
+ function runOpencodeModels(bin) {
272
+ try {
273
+ const r = spawnSync(bin, ['models'], {
274
+ encoding: 'utf8',
275
+ timeout: BIN_TIMEOUT_MS,
276
+ stdio: ['ignore', 'pipe', 'pipe'],
277
+ windowsHide: true,
278
+ });
279
+ if (r.error) return '';
280
+ return String(r.stdout || r.stderr || '');
281
+ } catch (e) {
282
+ return '';
283
+ }
284
+ }
285
+
286
+ // ── API publik ───────────────────────────────────────────────────────────────
287
+
288
+ const _cache = new Map(); // type → { at, result }
289
+
290
+ function staticResult(type) {
291
+ const list = Array.isArray(MODEL_REGISTRY[type]) ? MODEL_REGISTRY[type] : [];
292
+ return { provider: String(type), source: 'static', models: sanitizeModels(list) };
293
+ }
294
+
295
+ /**
296
+ * Daftar model satu provider.
297
+ * @param {string} type tipe provider (claude_code|codex|opencode|gemini_cli|custom|…)
298
+ * @param {{path?:string, refresh?:boolean}} opts path = path provider dari config;
299
+ * refresh = lewati cache (dipakai command `luang-cli models`).
300
+ * @returns {{provider:string, source:'static'|'binary'|'config', models:string[]}}
301
+ */
302
+ function modelsForProvider(type, opts = {}) {
303
+ const t = String(type || '');
304
+ if (!opts.refresh) {
305
+ const hit = _cache.get(t);
306
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.result;
307
+ }
308
+ let result;
309
+ if (DYNAMIC_OK.has(t)) {
310
+ const bin = resolveOpencodeBin(opts.path);
311
+ const dynamic = bin ? extractModelIds(runOpencodeModels(bin)) : [];
312
+ if (dynamic.length) {
313
+ result = { provider: t, source: 'binary', models: sanitizeModels(dynamic) };
314
+ } else {
315
+ const fromCfg = sanitizeModels(opencodeConfigModels());
316
+ result = fromCfg.length
317
+ ? { provider: t, source: 'config', models: fromCfg }
318
+ : staticResult(t);
319
+ }
320
+ } else {
321
+ result = staticResult(t);
322
+ }
323
+ _cache.set(t, { at: Date.now(), result });
324
+ return result;
325
+ }
326
+
327
+ /** Semua tipe provider dalam registry (atau daftar tipe tertentu). */
328
+ function listAllModels(types, opts = {}) {
329
+ const keys = Array.isArray(types) && types.length ? types : Object.keys(MODEL_REGISTRY);
330
+ const out = {};
331
+ for (const t of keys) out[t] = modelsForProvider(t, opts);
332
+ return out;
333
+ }
334
+
335
+ module.exports = {
336
+ MODEL_REGISTRY,
337
+ MAX_MODELS,
338
+ MAX_MODEL_LEN,
339
+ sanitizeModels,
340
+ extractModelIds,
341
+ modelsForProvider,
342
+ listAllModels,
343
+ };