@chispear/vibekit 0.3.0 → 0.6.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 +196 -7
- package/dist/agent.js +299 -7
- package/dist/brief.js +276 -0
- package/dist/cli.js +407 -19
- package/dist/config.js +47 -1
- package/dist/mcp.js +389 -0
- package/dist/prompt.js +4 -4
- package/dist/router.js +186 -12
- package/dist/sesi.js +77 -0
- package/dist/state.js +104 -0
- package/examples/vibekit.config.json +1 -1
- package/package.json +1 -1
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client MCP (Model Context Protocol) — ditulis dari nol di atas JSON-RPC 2.0,
|
|
3
|
+
* tanpa dependency baru. VibeKit berperan sebagai MCP *client*: tool dari
|
|
4
|
+
* server MCP mana pun (lokal lewat stdio, remote lewat Streamable HTTP)
|
|
5
|
+
* digabung ke daftar tool agent dengan nama ter-namespace
|
|
6
|
+
* `mcp__<namaServer>__<namaTool>`.
|
|
7
|
+
*
|
|
8
|
+
* Alur handshake per server: request `initialize` (menawarkan versi protokol
|
|
9
|
+
* di bawah; versi jawaban server diterima apa adanya) → notifikasi
|
|
10
|
+
* `notifications/initialized` → `tools/list`. Pemanggilan tool lewat
|
|
11
|
+
* `tools/call`.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
/** Versi protokol MCP yang ditawarkan saat initialize. */
|
|
15
|
+
const VERSI_PROTOKOL = "2025-03-26";
|
|
16
|
+
/** Samakan dengan "version" di package.json (nama/versi clientInfo handshake). */
|
|
17
|
+
const VERSI_KLIEN = "0.6.0";
|
|
18
|
+
/** Batas waktu satu request JSON-RPC (kedua transport). */
|
|
19
|
+
const TIMEOUT_REQUEST_MS = 60_000;
|
|
20
|
+
/** Ubah pesan error JSON-RPC menjadi Error yang terbaca manusia. */
|
|
21
|
+
function errorDariPesan(pesan) {
|
|
22
|
+
const e = pesan.error;
|
|
23
|
+
return new Error(`error JSON-RPC${e?.code !== undefined ? ` ${e.code}` : ""}: ${e?.message ?? "tanpa pesan"}`);
|
|
24
|
+
}
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
/* Transport stdio: proses anak, pesan JSON dipisah newline (NDJSON). */
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
function buatKoneksiStdio(cfg) {
|
|
29
|
+
const perintah = cfg.perintah;
|
|
30
|
+
const anak = spawn(perintah, cfg.argumen ?? [], {
|
|
31
|
+
env: { ...process.env, ...(cfg.env ?? {}) },
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
});
|
|
34
|
+
// stderr server tidak dipakai protokol; tampung & buang agar pipa tidak penuh.
|
|
35
|
+
anak.stderr.on("data", () => { });
|
|
36
|
+
const menunggu = new Map();
|
|
37
|
+
let idBerikut = 0;
|
|
38
|
+
let sisaBaris = "";
|
|
39
|
+
let ditutup = false;
|
|
40
|
+
let gagalFatal = null;
|
|
41
|
+
const gagalkanSemua = (err) => {
|
|
42
|
+
if (!gagalFatal)
|
|
43
|
+
gagalFatal = err;
|
|
44
|
+
for (const m of menunggu.values()) {
|
|
45
|
+
clearTimeout(m.pengatur);
|
|
46
|
+
m.reject(gagalFatal);
|
|
47
|
+
}
|
|
48
|
+
menunggu.clear();
|
|
49
|
+
};
|
|
50
|
+
anak.on("error", (e) => {
|
|
51
|
+
gagalkanSemua(new Error(`gagal menjalankan server MCP "${cfg.nama}" (${perintah}): ${e.message}`));
|
|
52
|
+
});
|
|
53
|
+
anak.on("exit", (kode) => {
|
|
54
|
+
if (!ditutup) {
|
|
55
|
+
gagalkanSemua(new Error(`server MCP "${cfg.nama}" berhenti sebelum menjawab (kode keluar ${kode ?? "?"})`));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
anak.stdout.on("data", (potongan) => {
|
|
59
|
+
sisaBaris += potongan.toString("utf8");
|
|
60
|
+
let idx;
|
|
61
|
+
// Respons bisa terpotong antar-chunk: proses hanya baris yang sudah utuh.
|
|
62
|
+
while ((idx = sisaBaris.indexOf("\n")) >= 0) {
|
|
63
|
+
const baris = sisaBaris.slice(0, idx).trim();
|
|
64
|
+
sisaBaris = sisaBaris.slice(idx + 1);
|
|
65
|
+
if (!baris)
|
|
66
|
+
continue;
|
|
67
|
+
let pesan;
|
|
68
|
+
try {
|
|
69
|
+
pesan = JSON.parse(baris);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
continue; // baris bukan JSON (mis. log server) — abaikan
|
|
73
|
+
}
|
|
74
|
+
if (pesan.id === undefined)
|
|
75
|
+
continue; // notifikasi dari server — abaikan
|
|
76
|
+
const idNum = typeof pesan.id === "number" ? pesan.id : Number(pesan.id);
|
|
77
|
+
const m = menunggu.get(idNum);
|
|
78
|
+
if (!m)
|
|
79
|
+
continue; // respons tak dikenal / permintaan dari server — abaikan
|
|
80
|
+
menunggu.delete(idNum);
|
|
81
|
+
clearTimeout(m.pengatur);
|
|
82
|
+
if (pesan.error)
|
|
83
|
+
m.reject(errorDariPesan(pesan));
|
|
84
|
+
else
|
|
85
|
+
m.resolve(pesan.result);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const tulis = (obj) => {
|
|
89
|
+
anak.stdin.write(JSON.stringify(obj) + "\n");
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
minta: (method, params) => {
|
|
93
|
+
if (gagalFatal)
|
|
94
|
+
return Promise.reject(gagalFatal);
|
|
95
|
+
if (ditutup)
|
|
96
|
+
return Promise.reject(new Error(`koneksi MCP "${cfg.nama}" sudah ditutup`));
|
|
97
|
+
const id = ++idBerikut;
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const pengatur = setTimeout(() => {
|
|
100
|
+
menunggu.delete(id);
|
|
101
|
+
reject(new Error(`server MCP "${cfg.nama}" tidak menjawab "${method}" dalam 60 detik (timeout)`));
|
|
102
|
+
}, TIMEOUT_REQUEST_MS);
|
|
103
|
+
menunggu.set(id, { resolve, reject, pengatur });
|
|
104
|
+
try {
|
|
105
|
+
tulis({ jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) });
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
clearTimeout(pengatur);
|
|
109
|
+
menunggu.delete(id);
|
|
110
|
+
reject(new Error(`gagal mengirim ke server MCP "${cfg.nama}": ${e.message}`));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
beriTahu: async (method, params) => {
|
|
115
|
+
if (ditutup || gagalFatal)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
tulis({ jsonrpc: "2.0", method, ...(params !== undefined ? { params } : {}) });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Notifikasi gagal terkirim tidak menggagalkan koneksi.
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
tutup: async () => {
|
|
125
|
+
ditutup = true;
|
|
126
|
+
gagalkanSemua(new Error(`koneksi MCP "${cfg.nama}" ditutup`));
|
|
127
|
+
try {
|
|
128
|
+
anak.stdin.end();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// abaikan
|
|
132
|
+
}
|
|
133
|
+
anak.kill();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/* ------------------------------------------------------------------ */
|
|
138
|
+
/* Transport HTTP: Streamable HTTP — POST JSON-RPC, jawaban JSON / SSE */
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
function buatKoneksiHttp(cfg) {
|
|
141
|
+
const url = cfg.url;
|
|
142
|
+
let idBerikut = 0;
|
|
143
|
+
let idSesi = null;
|
|
144
|
+
let ditutup = false;
|
|
145
|
+
const headerDasar = () => ({
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
Accept: "application/json, text/event-stream",
|
|
148
|
+
...(cfg.headers ?? {}),
|
|
149
|
+
...(idSesi ? { "Mcp-Session-Id": idSesi } : {}),
|
|
150
|
+
});
|
|
151
|
+
/** Satu POST dengan timeout; simpan Mcp-Session-Id jawaban bila ada. */
|
|
152
|
+
async function kirimHttp(payload) {
|
|
153
|
+
const kontroler = new AbortController();
|
|
154
|
+
const pengatur = setTimeout(() => kontroler.abort(), TIMEOUT_REQUEST_MS);
|
|
155
|
+
try {
|
|
156
|
+
const respons = await fetch(url, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: headerDasar(),
|
|
159
|
+
body: JSON.stringify(payload),
|
|
160
|
+
signal: kontroler.signal,
|
|
161
|
+
});
|
|
162
|
+
const sesiBaru = respons.headers.get("mcp-session-id");
|
|
163
|
+
if (sesiBaru)
|
|
164
|
+
idSesi = sesiBaru;
|
|
165
|
+
return {
|
|
166
|
+
status: respons.status,
|
|
167
|
+
tipeKonten: respons.headers.get("content-type") ?? "",
|
|
168
|
+
teks: await respons.text(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (e.name === "AbortError") {
|
|
173
|
+
throw new Error(`server MCP "${cfg.nama}" tidak menjawab dalam 60 detik (timeout)`);
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`gagal menghubungi server MCP "${cfg.nama}" (${url}): ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(pengatur);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Cari pesan JSON-RPC ber-id `id` di dalam teks SSE (kumpulan baris `data:` per event). */
|
|
182
|
+
function cariDiSse(teks, id) {
|
|
183
|
+
let dataGabungan = [];
|
|
184
|
+
const kandidat = [];
|
|
185
|
+
for (const barisMentah of teks.split(/\r?\n/)) {
|
|
186
|
+
const baris = barisMentah.trimEnd();
|
|
187
|
+
if (baris === "") {
|
|
188
|
+
if (dataGabungan.length > 0)
|
|
189
|
+
kandidat.push(dataGabungan.join("\n"));
|
|
190
|
+
dataGabungan = [];
|
|
191
|
+
}
|
|
192
|
+
else if (baris.startsWith("data:")) {
|
|
193
|
+
dataGabungan.push(baris.slice("data:".length).trim());
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (dataGabungan.length > 0)
|
|
197
|
+
kandidat.push(dataGabungan.join("\n"));
|
|
198
|
+
for (const mentah of kandidat) {
|
|
199
|
+
try {
|
|
200
|
+
const pesan = JSON.parse(mentah);
|
|
201
|
+
if (pesan && (pesan.id === id || Number(pesan.id) === id))
|
|
202
|
+
return pesan;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// bukan JSON — lewati
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
minta: async (method, params) => {
|
|
212
|
+
if (ditutup)
|
|
213
|
+
throw new Error(`koneksi MCP "${cfg.nama}" sudah ditutup`);
|
|
214
|
+
const id = ++idBerikut;
|
|
215
|
+
const { status, tipeKonten, teks } = await kirimHttp({
|
|
216
|
+
jsonrpc: "2.0",
|
|
217
|
+
id,
|
|
218
|
+
method,
|
|
219
|
+
...(params !== undefined ? { params } : {}),
|
|
220
|
+
});
|
|
221
|
+
if (status < 200 || status >= 300) {
|
|
222
|
+
throw new Error(`server MCP "${cfg.nama}" menjawab HTTP ${status}${teks ? `: ${teks.replace(/\s+/g, " ").slice(0, 200)}` : ""}`);
|
|
223
|
+
}
|
|
224
|
+
let pesan = null;
|
|
225
|
+
if (tipeKonten.includes("text/event-stream")) {
|
|
226
|
+
pesan = cariDiSse(teks, id);
|
|
227
|
+
if (!pesan)
|
|
228
|
+
throw new Error(`server MCP "${cfg.nama}" tidak mengirim jawaban untuk "${method}" dalam aliran SSE-nya`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
if (!teks.trim())
|
|
232
|
+
throw new Error(`server MCP "${cfg.nama}" menjawab kosong untuk "${method}"`);
|
|
233
|
+
try {
|
|
234
|
+
pesan = JSON.parse(teks);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new Error(`server MCP "${cfg.nama}" menjawab bukan JSON yang valid untuk "${method}"`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (pesan.error)
|
|
241
|
+
throw errorDariPesan(pesan);
|
|
242
|
+
return pesan.result;
|
|
243
|
+
},
|
|
244
|
+
beriTahu: async (method, params) => {
|
|
245
|
+
if (ditutup)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
await kirimHttp({ jsonrpc: "2.0", method, ...(params !== undefined ? { params } : {}) });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Notifikasi (mis. notifications/initialized) sering dijawab 202 kosong;
|
|
252
|
+
// kegagalannya tidak menggagalkan koneksi.
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
tutup: async () => {
|
|
256
|
+
ditutup = true;
|
|
257
|
+
// Akhiri sesi secara sopan bila server memberi id sesi (best-effort).
|
|
258
|
+
if (idSesi) {
|
|
259
|
+
try {
|
|
260
|
+
const kontroler = new AbortController();
|
|
261
|
+
const pengatur = setTimeout(() => kontroler.abort(), 5000);
|
|
262
|
+
try {
|
|
263
|
+
await fetch(url, { method: "DELETE", headers: headerDasar(), signal: kontroler.signal });
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
clearTimeout(pengatur);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// abaikan — sesi akan kedaluwarsa di sisi server
|
|
271
|
+
}
|
|
272
|
+
idSesi = null;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/* ------------------------------------------------------------------ */
|
|
278
|
+
/* Handshake + pengelola gabungan */
|
|
279
|
+
/* ------------------------------------------------------------------ */
|
|
280
|
+
/** Sanitasi nama menjadi karakter yang sah untuk nama tool OpenAI: [a-zA-Z0-9_-]. */
|
|
281
|
+
function sanitasi(nama) {
|
|
282
|
+
return nama.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
283
|
+
}
|
|
284
|
+
/** Handshake MCP: initialize → notifications/initialized → tools/list. */
|
|
285
|
+
async function jabatTangan(koneksi) {
|
|
286
|
+
// Versi jawaban server diterima apa adanya (tidak divalidasi ketat): server
|
|
287
|
+
// yang lebih baru/lama tetap dicoba dipakai selama tools/list berhasil.
|
|
288
|
+
await koneksi.minta("initialize", {
|
|
289
|
+
protocolVersion: VERSI_PROTOKOL,
|
|
290
|
+
capabilities: {},
|
|
291
|
+
clientInfo: { name: "vibekit", version: VERSI_KLIEN },
|
|
292
|
+
});
|
|
293
|
+
await koneksi.beriTahu("notifications/initialized");
|
|
294
|
+
const daftar = (await koneksi.minta("tools/list", {}));
|
|
295
|
+
const mentah = Array.isArray(daftar?.tools) ? daftar.tools : [];
|
|
296
|
+
return mentah.filter((t) => !!t && typeof t === "object" && typeof t.name === "string" && t.name.trim() !== "");
|
|
297
|
+
}
|
|
298
|
+
/** Ubah hasil tools/call (content blocks) menjadi teks untuk model. */
|
|
299
|
+
function hasilKeTeks(hasil) {
|
|
300
|
+
const obj = (hasil ?? {});
|
|
301
|
+
const blok = Array.isArray(obj.content) ? obj.content : [];
|
|
302
|
+
const bagian = blok.map((b) => {
|
|
303
|
+
const blokObj = (b ?? {});
|
|
304
|
+
if (blokObj.type === "text" && typeof blokObj.text === "string")
|
|
305
|
+
return blokObj.text;
|
|
306
|
+
return `[konten ${typeof blokObj.type === "string" ? blokObj.type : "?"}]`;
|
|
307
|
+
});
|
|
308
|
+
const teks = bagian.join("\n");
|
|
309
|
+
if (obj.isError)
|
|
310
|
+
return `ERROR: ${teks || "(server melaporkan error tanpa teks)"}`;
|
|
311
|
+
return teks || "(tool selesai tanpa keluaran teks)";
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Sambungkan semua server MCP dari konfigurasi. Kegagalan SATU server tidak
|
|
315
|
+
* menggagalkan yang lain: server yang gagal dicatat di `peringatan`, server
|
|
316
|
+
* sehat tetap terdaftar tool-nya.
|
|
317
|
+
*/
|
|
318
|
+
export async function sambungkanMcpServers(configs) {
|
|
319
|
+
const tools = [];
|
|
320
|
+
const rincianServer = [];
|
|
321
|
+
const peringatan = [];
|
|
322
|
+
const rute = new Map();
|
|
323
|
+
const koneksiAktif = [];
|
|
324
|
+
for (const cfg of configs) {
|
|
325
|
+
let koneksi = null;
|
|
326
|
+
try {
|
|
327
|
+
koneksi = cfg.url ? buatKoneksiHttp(cfg) : buatKoneksiStdio(cfg);
|
|
328
|
+
const daftarTool = await jabatTangan(koneksi);
|
|
329
|
+
if (daftarTool.length === 0) {
|
|
330
|
+
peringatan.push(`${cfg.nama}: server tersambung tetapi tidak menyediakan tool yang valid — dilewati.`);
|
|
331
|
+
await koneksi.tutup().catch(() => { });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const ringkas = [];
|
|
335
|
+
for (const t of daftarTool) {
|
|
336
|
+
const namaLengkap = `mcp__${sanitasi(cfg.nama)}__${sanitasi(t.name)}`;
|
|
337
|
+
if (rute.has(namaLengkap)) {
|
|
338
|
+
peringatan.push(`${cfg.nama}: nama tool ter-namespace "${namaLengkap}" bertabrakan — yang kedua dilewati.`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
rute.set(namaLengkap, { koneksi, namaTool: t.name });
|
|
342
|
+
tools.push({
|
|
343
|
+
type: "function",
|
|
344
|
+
function: {
|
|
345
|
+
name: namaLengkap,
|
|
346
|
+
description: typeof t.description === "string" ? t.description : "",
|
|
347
|
+
parameters: t.inputSchema && typeof t.inputSchema === "object"
|
|
348
|
+
? t.inputSchema
|
|
349
|
+
: { type: "object", properties: {} },
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
ringkas.push({
|
|
353
|
+
nama: t.name,
|
|
354
|
+
namaLengkap,
|
|
355
|
+
deskripsi: typeof t.description === "string" ? t.description : "",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
koneksiAktif.push(koneksi);
|
|
359
|
+
rincianServer.push({ nama: cfg.nama, tools: ringkas });
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
peringatan.push(`${cfg.nama}: gagal tersambung — ${e.message}`);
|
|
363
|
+
if (koneksi)
|
|
364
|
+
await koneksi.tutup().catch(() => { });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
tools,
|
|
369
|
+
rincianServer,
|
|
370
|
+
peringatan,
|
|
371
|
+
panggilTool: async (namaLengkap, argumen) => {
|
|
372
|
+
const r = rute.get(namaLengkap);
|
|
373
|
+
if (!r)
|
|
374
|
+
throw new Error(`tool MCP tidak ditemukan: ${namaLengkap}`);
|
|
375
|
+
const hasil = await r.koneksi.minta("tools/call", { name: r.namaTool, arguments: argumen ?? {} });
|
|
376
|
+
return hasilKeTeks(hasil);
|
|
377
|
+
},
|
|
378
|
+
tutupSemua: async () => {
|
|
379
|
+
for (const k of koneksiAktif) {
|
|
380
|
+
try {
|
|
381
|
+
await k.tutup();
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// abaikan — penutupan best-effort
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
package/dist/prompt.js
CHANGED
|
@@ -15,11 +15,11 @@ export function systemPromptHemat(gaya) {
|
|
|
15
15
|
case "poin-penting":
|
|
16
16
|
default:
|
|
17
17
|
return [
|
|
18
|
-
"
|
|
19
|
-
"DILARANG:
|
|
20
|
-
"menjelaskan hal yang tidak ditanyakan, dan kalimat pengisi.",
|
|
18
|
+
"Jawabanmu WAJIB hanya berisi poin-poin penting dalam format poin (bullet list); tidak ada satu kata pun di luar poin.",
|
|
19
|
+
"DILARANG KERAS: kalimat pembuka atau penutup dalam bentuk apa pun — termasuk sapaan, \"Berikut...\", \"Tentu!\", \"Semoga membantu\", dan sejenisnya.",
|
|
20
|
+
"DILARANG: paragraf naratif, basa-basi, mengulang atau parafrase pertanyaan, menjelaskan hal yang tidak ditanyakan, dan kalimat pengisi.",
|
|
21
21
|
"Satu poin = satu ide, sesingkat mungkin sambil tetap akurat dan lengkap pada intinya.",
|
|
22
|
-
"
|
|
22
|
+
"Satu-satunya pengecualian: kode diberikan sebagai blok kode, tanpa penjelasan pendamping selain maksimal satu baris.",
|
|
23
23
|
].join(" ");
|
|
24
24
|
}
|
|
25
25
|
}
|
package/dist/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { selesaikanApiKey } from "./config.js";
|
|
2
2
|
import { systemPromptHemat } from "./prompt.js";
|
|
3
|
-
import { akhirHariIni, formatJam, muatState, sedangCooldown, simpanState, statePenyedia, } from "./state.js";
|
|
3
|
+
import { akhirHariIni, catatPemakaianHarian, formatJam, muatState, sedangCooldown, simpanState, statePenyedia, } from "./state.js";
|
|
4
4
|
import { formatAngka, hitungToken } from "./token.js";
|
|
5
5
|
const POLA_KUOTA = /insufficient_quota|resource_exhausted|quota exceeded|exceeded your quota|kuota|insufficient balance|balance is too low|credit balance|billing/i;
|
|
6
6
|
/** Klasifikasikan kegagalan HTTP: kuota habis, rate-limit singkat, atau error lain. */
|
|
@@ -17,6 +17,130 @@ export function klasifikasiError(status, bodyTeks) {
|
|
|
17
17
|
}
|
|
18
18
|
return { jenis: "lainnya", ringkas: `HTTP ${status} ${potongan}`.trim() };
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Baca aliran SSE (mode streaming) dari respons penyedia dan rakit menjadi
|
|
22
|
+
* objek `data` yang SETARA BENTUKNYA dengan respons non-streaming:
|
|
23
|
+
* `choices[0].message` berisi content terakumulasi dan tool_calls yang sudah
|
|
24
|
+
* digabung per `index` — termasuk `extra_content` (thought_signature Gemini)
|
|
25
|
+
* bila muncul pada delta tool_call, agar riwayat & kompaksi tetap bekerja.
|
|
26
|
+
*
|
|
27
|
+
* Hasil dibedakan menurut kapan kegagalan terjadi:
|
|
28
|
+
* - "pra-token": aliran gagal sebelum ada keluaran apa pun → pemanggil boleh
|
|
29
|
+
* memperlakukannya sebagai kegagalan penyedia biasa (failover).
|
|
30
|
+
* - "tengah": aliran putus SETELAH ada token keluar → pemanggil TIDAK BOLEH
|
|
31
|
+
* failover karena akan menggandakan keluaran ke pengguna.
|
|
32
|
+
*/
|
|
33
|
+
async function bacaAliranSSE(respons, provider, opsi, timeoutMs) {
|
|
34
|
+
const body = respons.body;
|
|
35
|
+
if (!body)
|
|
36
|
+
return { jenis: "pra-token", alasan: "respons streaming tidak memiliki body" };
|
|
37
|
+
const pembaca = body.getReader();
|
|
38
|
+
const dekoder = new TextDecoder();
|
|
39
|
+
let buffer = "";
|
|
40
|
+
let konten = "";
|
|
41
|
+
const perIndeks = new Map();
|
|
42
|
+
let usage;
|
|
43
|
+
let adaKeluaran = false;
|
|
44
|
+
let tokenPertamaDilaporkan = false;
|
|
45
|
+
const prosesBaris = (baris) => {
|
|
46
|
+
const rapi = baris.trim();
|
|
47
|
+
if (!rapi.startsWith("data:"))
|
|
48
|
+
return;
|
|
49
|
+
const muatan = rapi.slice("data:".length).trim();
|
|
50
|
+
if (!muatan || muatan === "[DONE]")
|
|
51
|
+
return;
|
|
52
|
+
let potong;
|
|
53
|
+
try {
|
|
54
|
+
potong = JSON.parse(muatan);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return; // baris data rusak/komentar: lewati, jangan gagalkan aliran
|
|
58
|
+
}
|
|
59
|
+
if (potong?.usage)
|
|
60
|
+
usage = potong.usage;
|
|
61
|
+
const delta = Array.isArray(potong?.choices) ? potong.choices[0]?.delta : undefined;
|
|
62
|
+
if (!delta)
|
|
63
|
+
return;
|
|
64
|
+
if (typeof delta.content === "string" && delta.content !== "") {
|
|
65
|
+
konten += delta.content;
|
|
66
|
+
adaKeluaran = true;
|
|
67
|
+
if (!tokenPertamaDilaporkan) {
|
|
68
|
+
tokenPertamaDilaporkan = true;
|
|
69
|
+
opsi.onTokenPertama?.(provider);
|
|
70
|
+
}
|
|
71
|
+
opsi.onToken?.(delta.content);
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
74
|
+
for (const dtc of delta.tool_calls) {
|
|
75
|
+
const indeks = typeof dtc?.index === "number" ? dtc.index : 0;
|
|
76
|
+
let entri = perIndeks.get(indeks);
|
|
77
|
+
if (!entri) {
|
|
78
|
+
entri = { id: "", nama: "", argumen: "" };
|
|
79
|
+
perIndeks.set(indeks, entri);
|
|
80
|
+
}
|
|
81
|
+
if (typeof dtc.id === "string" && dtc.id)
|
|
82
|
+
entri.id = dtc.id;
|
|
83
|
+
if (typeof dtc.function?.name === "string")
|
|
84
|
+
entri.nama += dtc.function.name;
|
|
85
|
+
if (typeof dtc.function?.arguments === "string")
|
|
86
|
+
entri.argumen += dtc.function.arguments;
|
|
87
|
+
if (dtc.extra_content !== undefined)
|
|
88
|
+
entri.extraContent = dtc.extra_content;
|
|
89
|
+
adaKeluaran = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
let waktuHabis = false;
|
|
94
|
+
const pengatur = setTimeout(() => {
|
|
95
|
+
waktuHabis = true;
|
|
96
|
+
pembaca.cancel().catch(() => { });
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
try {
|
|
99
|
+
for (;;) {
|
|
100
|
+
const { done, value } = await pembaca.read();
|
|
101
|
+
if (done)
|
|
102
|
+
break;
|
|
103
|
+
buffer += dekoder.decode(value, { stream: true });
|
|
104
|
+
let idx = buffer.indexOf("\n");
|
|
105
|
+
while (idx >= 0) {
|
|
106
|
+
prosesBaris(buffer.slice(0, idx));
|
|
107
|
+
buffer = buffer.slice(idx + 1);
|
|
108
|
+
idx = buffer.indexOf("\n");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (buffer.trim())
|
|
112
|
+
prosesBaris(buffer);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
const alasan = `aliran streaming terputus: ${e.message}`;
|
|
116
|
+
return adaKeluaran ? { jenis: "tengah", alasan } : { jenis: "pra-token", alasan };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(pengatur);
|
|
120
|
+
}
|
|
121
|
+
if (waktuHabis) {
|
|
122
|
+
const alasan = `aliran streaming melewati batas waktu ${timeoutMs} ms`;
|
|
123
|
+
return adaKeluaran ? { jenis: "tengah", alasan } : { jenis: "pra-token", alasan };
|
|
124
|
+
}
|
|
125
|
+
const toolCalls = [...perIndeks.entries()]
|
|
126
|
+
.sort((a, b) => a[0] - b[0])
|
|
127
|
+
.map(([, e]) => ({
|
|
128
|
+
...(e.id ? { id: e.id } : {}),
|
|
129
|
+
type: "function",
|
|
130
|
+
function: { name: e.nama, arguments: e.argumen },
|
|
131
|
+
...(e.extraContent !== undefined ? { extra_content: e.extraContent } : {}),
|
|
132
|
+
}));
|
|
133
|
+
const pesan = {
|
|
134
|
+
role: "assistant",
|
|
135
|
+
content: konten === "" ? null : konten,
|
|
136
|
+
};
|
|
137
|
+
if (toolCalls.length > 0)
|
|
138
|
+
pesan.tool_calls = toolCalls;
|
|
139
|
+
return {
|
|
140
|
+
jenis: "sukses",
|
|
141
|
+
data: { choices: [{ message: pesan }], ...(usage !== undefined ? { usage } : {}) },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
20
144
|
/**
|
|
21
145
|
* Mesin router: coba penyedia sesuai urutan prioritas konfigurasi.
|
|
22
146
|
* - Penyedia yang cooldown / anggaran hariannya tercapai dilewati tanpa dipanggil.
|
|
@@ -27,6 +151,10 @@ export function klasifikasiError(status, bodyTeks) {
|
|
|
27
151
|
* 2 kali dengan jeda (3 dtk, 8 dtk) sebelum menyerah ke penyedia berikutnya;
|
|
28
152
|
* 503 tidak memicu cooldown.
|
|
29
153
|
* - Error lain (401, 400, jaringan) tidak dianggap kuota habis; dicatat dan lanjut ke berikutnya.
|
|
154
|
+
* - Mode streaming (opsi.stream): aturan di atas berlaku untuk error yang
|
|
155
|
+
* terjadi SEBELUM token pertama (status HTTP / aliran kosong). Error di
|
|
156
|
+
* tengah aliran setelah token keluar TIDAK di-failover; hasilnya gagal
|
|
157
|
+
* dengan `streamTerputus: true`.
|
|
30
158
|
*/
|
|
31
159
|
export async function tanyaRouter(opsi) {
|
|
32
160
|
const fetchImpl = opsi.fetchImpl ?? fetch;
|
|
@@ -81,13 +209,22 @@ export async function tanyaRouter(opsi) {
|
|
|
81
209
|
continue;
|
|
82
210
|
}
|
|
83
211
|
const url = provider.baseUrl.replace(/\/+$/, "") + "/chat/completions";
|
|
212
|
+
// max_tokens hanya dikirim bila plafon diatur > 0. Nilai 0/kosong/tidak diisi
|
|
213
|
+
// = tanpa batas: field tidak dikirim sama sekali, batas bawaan model berlaku.
|
|
214
|
+
const maksToken = opsi.config.gaya.maksTokenJawaban;
|
|
84
215
|
const body = {
|
|
85
216
|
model: provider.model,
|
|
86
217
|
messages: semuaPesan,
|
|
87
|
-
max_tokens:
|
|
218
|
+
...(typeof maksToken === "number" && maksToken > 0 ? { max_tokens: maksToken } : {}),
|
|
88
219
|
...(opsi.tools && opsi.tools.length > 0 ? { tools: opsi.tools } : {}),
|
|
89
220
|
...(provider.extraBody ?? {}),
|
|
90
221
|
};
|
|
222
|
+
if (opsi.stream) {
|
|
223
|
+
// include_usage agar token terpakai tetap dilaporkan penyedia di akhir
|
|
224
|
+
// aliran; penyedia yang tidak mengirim usage diestimasi secara lokal.
|
|
225
|
+
body.stream = true;
|
|
226
|
+
body.stream_options = { include_usage: true };
|
|
227
|
+
}
|
|
91
228
|
let respons;
|
|
92
229
|
let teks = "";
|
|
93
230
|
let gagalJaringan = null;
|
|
@@ -115,10 +252,17 @@ export async function tanyaRouter(opsi) {
|
|
|
115
252
|
gagalJaringan = `gagal jaringan: ${e.message}`;
|
|
116
253
|
break;
|
|
117
254
|
}
|
|
118
|
-
teks = await respons.text();
|
|
119
255
|
// 503 = server penyedia sedang penuh sesaat: coba ulang penyedia yang sama
|
|
120
|
-
// dengan jeda sebelum menyerah ke penyedia berikutnya.
|
|
256
|
+
// dengan jeda sebelum menyerah ke penyedia berikutnya. Keputusan dibuat
|
|
257
|
+
// dari STATUS (header) sebelum body dibaca, supaya body — terutama aliran
|
|
258
|
+
// streaming — tidak terkonsumsi oleh percobaan yang akan diulang.
|
|
121
259
|
if (respons.status === 503 && percobaan503 < jedaRetry503.length) {
|
|
260
|
+
try {
|
|
261
|
+
await respons.body?.cancel();
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* abaikan: body percobaan gagal memang dibuang */
|
|
265
|
+
}
|
|
122
266
|
await tunggu(jedaRetry503[percobaan503]);
|
|
123
267
|
percobaan503++;
|
|
124
268
|
continue;
|
|
@@ -133,6 +277,7 @@ export async function tanyaRouter(opsi) {
|
|
|
133
277
|
continue;
|
|
134
278
|
}
|
|
135
279
|
if (!respons.ok) {
|
|
280
|
+
teks = await respons.text();
|
|
136
281
|
const { jenis, ringkas } = klasifikasiError(respons.status, teks);
|
|
137
282
|
entri.alasanTerakhir = ringkas;
|
|
138
283
|
if (jenis === "kuota") {
|
|
@@ -153,15 +298,43 @@ export async function tanyaRouter(opsi) {
|
|
|
153
298
|
continue;
|
|
154
299
|
}
|
|
155
300
|
let data;
|
|
156
|
-
|
|
157
|
-
|
|
301
|
+
if (opsi.stream && respons.body) {
|
|
302
|
+
const aliran = await bacaAliranSSE(respons, provider, opsi, timeoutMs);
|
|
303
|
+
if (aliran.jenis === "pra-token") {
|
|
304
|
+
// Belum ada token yang keluar: kegagalan penyedia biasa, failover lanjut.
|
|
305
|
+
entri.alasanTerakhir = aliran.alasan;
|
|
306
|
+
simpanState(opsi.lokasiState, state);
|
|
307
|
+
catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan: aliran.alasan });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (aliran.jenis === "tengah") {
|
|
311
|
+
// Token sudah keluar ke pengguna: JANGAN failover (keluaran akan
|
|
312
|
+
// ganda). Laporkan sebagai kegagalan giliran khusus.
|
|
313
|
+
entri.alasanTerakhir = aliran.alasan;
|
|
314
|
+
simpanState(opsi.lokasiState, state);
|
|
315
|
+
catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan: aliran.alasan });
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
pesan: `Aliran jawaban dari ${provider.nama} (${provider.model}) terputus di tengah jalan — ${aliran.alasan}. ` +
|
|
319
|
+
"Sebagian jawaban mungkin sudah tampil; permintaan TIDAK diulang ke penyedia lain agar keluaran tidak ganda.",
|
|
320
|
+
catatan,
|
|
321
|
+
streamTerputus: true,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
data = aliran.data;
|
|
158
325
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
326
|
+
else {
|
|
327
|
+
teks = await respons.text();
|
|
328
|
+
try {
|
|
329
|
+
data = JSON.parse(teks);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
const alasan = "respons bukan JSON yang valid";
|
|
333
|
+
entri.alasanTerakhir = alasan;
|
|
334
|
+
simpanState(opsi.lokasiState, state);
|
|
335
|
+
catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
165
338
|
}
|
|
166
339
|
const pesanMentah = data?.choices?.[0]?.message;
|
|
167
340
|
const jawaban = pesanMentah?.content ?? "";
|
|
@@ -204,6 +377,7 @@ export async function tanyaRouter(opsi) {
|
|
|
204
377
|
tokenSumber = "estimasi";
|
|
205
378
|
}
|
|
206
379
|
entri.totalToken += tokenTerpakai;
|
|
380
|
+
catatPemakaianHarian(state, provider.nama, tokenTerpakai, sekarang);
|
|
207
381
|
entri.alasanTerakhir = null;
|
|
208
382
|
simpanState(opsi.lokasiState, state);
|
|
209
383
|
return {
|