@chispear/vibekit 0.4.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/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
- "Jawablah SANGAT ringkas, hanya poin-poin penting, dalam format poin (bullet list) bila cocok.",
19
- "DILARANG: basa-basi, pembuka/penutup sopan-santun, mengulang atau parafrase pertanyaan,",
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
- "Untuk kode: berikan kodenya saja ditambah penjelasan maksimal satu baris bila perlu.",
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: opsi.config.gaya.maksTokenJawaban,
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
- try {
157
- data = JSON.parse(teks);
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
- catch {
160
- const alasan = "respons bukan JSON yang valid";
161
- entri.alasanTerakhir = alasan;
162
- simpanState(opsi.lokasiState, state);
163
- catatan.push({ nama: provider.nama, model: provider.model, status: "gagal", alasan });
164
- continue;
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 {