bloby-bot 0.47.4 → 0.47.5
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/package.json
CHANGED
|
@@ -18,35 +18,70 @@ import type {
|
|
|
18
18
|
} from './types.js';
|
|
19
19
|
|
|
20
20
|
/** Walk an SSE byte stream and yield each parsed JSON event. */
|
|
21
|
-
async function* parseSse(res: Response): AsyncIterable<any> {
|
|
21
|
+
async function* parseSse(res: Response, dbg: { firstBytes: string }): AsyncIterable<any> {
|
|
22
22
|
if (!res.body) return;
|
|
23
23
|
const reader = res.body.getReader();
|
|
24
24
|
const decoder = new TextDecoder();
|
|
25
25
|
let buffer = '';
|
|
26
|
+
let totalBytes = 0;
|
|
26
27
|
try {
|
|
27
28
|
while (true) {
|
|
28
29
|
const { value, done } = await reader.read();
|
|
29
30
|
if (done) break;
|
|
31
|
+
if (value) totalBytes += value.byteLength;
|
|
30
32
|
buffer += decoder.decode(value, { stream: true });
|
|
31
|
-
|
|
33
|
+
if (!dbg.firstBytes && buffer.length > 0) {
|
|
34
|
+
dbg.firstBytes = buffer.slice(0, 800);
|
|
35
|
+
}
|
|
36
|
+
// SSE event boundary is a blank line. Accept both LF and CRLF separators.
|
|
32
37
|
let idx;
|
|
33
|
-
while (
|
|
38
|
+
while (
|
|
39
|
+
(idx = (() => {
|
|
40
|
+
const a = buffer.indexOf('\n\n');
|
|
41
|
+
const b = buffer.indexOf('\r\n\r\n');
|
|
42
|
+
if (a < 0) return b;
|
|
43
|
+
if (b < 0) return a;
|
|
44
|
+
return Math.min(a, b);
|
|
45
|
+
})()) !== -1
|
|
46
|
+
) {
|
|
47
|
+
const isCrlf = buffer.slice(idx, idx + 4) === '\r\n\r\n';
|
|
34
48
|
const raw = buffer.slice(0, idx);
|
|
35
|
-
buffer = buffer.slice(idx + 2);
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
const data = dataLines.map((l) => l.slice(5).trimStart()).join('\n');
|
|
39
|
-
if (!data || data === '[DONE]') continue;
|
|
40
|
-
try {
|
|
41
|
-
yield JSON.parse(data);
|
|
42
|
-
} catch {
|
|
43
|
-
// Skip malformed chunks rather than killing the whole turn.
|
|
44
|
-
}
|
|
49
|
+
buffer = buffer.slice(idx + (isCrlf ? 4 : 2));
|
|
50
|
+
const parsed = parseSseEvent(raw);
|
|
51
|
+
if (parsed !== undefined) yield parsed;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
// Flush whatever remains — Gemini's final event may not have a trailing blank line.
|
|
55
|
+
buffer += decoder.decode();
|
|
56
|
+
if (buffer.trim()) {
|
|
57
|
+
const parsed = parseSseEvent(buffer);
|
|
58
|
+
if (parsed !== undefined) yield parsed;
|
|
59
|
+
}
|
|
47
60
|
} finally {
|
|
48
61
|
try { reader.releaseLock(); } catch {}
|
|
62
|
+
dbg.firstBytes = dbg.firstBytes || `(zero bytes — total=${totalBytes})`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseSseEvent(raw: string): any | undefined {
|
|
67
|
+
// Standard SSE: one or more `data:` lines per event. Concatenate their payloads.
|
|
68
|
+
const lines = raw.split(/\r?\n/);
|
|
69
|
+
const dataLines = lines
|
|
70
|
+
.filter((l) => l.startsWith('data:'))
|
|
71
|
+
.map((l) => l.slice(5).trimStart());
|
|
72
|
+
if (!dataLines.length) {
|
|
73
|
+
// Fallback: some servers omit the `data:` prefix and send pure JSON per event.
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (!trimmed || trimmed === '[DONE]') return undefined;
|
|
76
|
+
// Strip a leading JSON-array delimiter if Gemini is returning array-stream
|
|
77
|
+
// instead of SSE (alt=sse not honored).
|
|
78
|
+
const candidate = trimmed.replace(/^[\[,]/, '').replace(/[\],]$/, '').trim();
|
|
79
|
+
if (!candidate) return undefined;
|
|
80
|
+
try { return JSON.parse(candidate); } catch { return undefined; }
|
|
49
81
|
}
|
|
82
|
+
const data = dataLines.join('\n');
|
|
83
|
+
if (!data || data === '[DONE]') return undefined;
|
|
84
|
+
try { return JSON.parse(data); } catch { return undefined; }
|
|
50
85
|
}
|
|
51
86
|
|
|
52
87
|
function toGeminiRole(role: PiMessage['role']): 'user' | 'model' {
|
|
@@ -155,9 +190,10 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
155
190
|
let thoughtPartCount = 0;
|
|
156
191
|
let emptyTextPartCount = 0;
|
|
157
192
|
let firstChunkSummary = '';
|
|
193
|
+
const dbg = { firstBytes: '' };
|
|
158
194
|
|
|
159
195
|
try {
|
|
160
|
-
for await (const chunk of parseSse(res)) {
|
|
196
|
+
for await (const chunk of parseSse(res, dbg)) {
|
|
161
197
|
chunkCount++;
|
|
162
198
|
if (chunkCount === 1) {
|
|
163
199
|
try { firstChunkSummary = JSON.stringify(chunk).slice(0, 600); } catch {}
|
|
@@ -206,7 +242,8 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
206
242
|
if (chunkCount > 0 && !accumulated) {
|
|
207
243
|
log.info(`[pi/google] first chunk (truncated): ${firstChunkSummary}`);
|
|
208
244
|
} else if (chunkCount === 0) {
|
|
209
|
-
log.warn(`[pi/google] SSE stream parsed zero chunks —
|
|
245
|
+
log.warn(`[pi/google] SSE stream parsed zero chunks — content-type=${res.headers.get('content-type') || '?'}`);
|
|
246
|
+
log.warn(`[pi/google] first raw bytes: ${JSON.stringify(dbg.firstBytes)}`);
|
|
210
247
|
}
|
|
211
248
|
|
|
212
249
|
// Prompt-level block: nothing came back at all.
|