bloby-bot 0.47.3 → 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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Endpoint: POST {baseUrl}/models/{modelId}:streamGenerateContent?alt=sse&key={apiKey}
|
|
9
9
|
* Stream: SSE — each `data: {...}` is one candidate update.
|
|
10
10
|
*/
|
|
11
|
+
import { log } from '../../../../shared/logger.js';
|
|
11
12
|
import type {
|
|
12
13
|
PiStreamRequest,
|
|
13
14
|
PiStreamEvent,
|
|
@@ -17,37 +18,72 @@ import type {
|
|
|
17
18
|
} from './types.js';
|
|
18
19
|
|
|
19
20
|
/** Walk an SSE byte stream and yield each parsed JSON event. */
|
|
20
|
-
async function* parseSse(res: Response): AsyncIterable<any> {
|
|
21
|
+
async function* parseSse(res: Response, dbg: { firstBytes: string }): AsyncIterable<any> {
|
|
21
22
|
if (!res.body) return;
|
|
22
23
|
const reader = res.body.getReader();
|
|
23
24
|
const decoder = new TextDecoder();
|
|
24
25
|
let buffer = '';
|
|
26
|
+
let totalBytes = 0;
|
|
25
27
|
try {
|
|
26
28
|
while (true) {
|
|
27
29
|
const { value, done } = await reader.read();
|
|
28
30
|
if (done) break;
|
|
31
|
+
if (value) totalBytes += value.byteLength;
|
|
29
32
|
buffer += decoder.decode(value, { stream: true });
|
|
30
|
-
|
|
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.
|
|
31
37
|
let idx;
|
|
32
|
-
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';
|
|
33
48
|
const raw = buffer.slice(0, idx);
|
|
34
|
-
buffer = buffer.slice(idx + 2);
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
const data = dataLines.map((l) => l.slice(5).trimStart()).join('\n');
|
|
38
|
-
if (!data || data === '[DONE]') continue;
|
|
39
|
-
try {
|
|
40
|
-
yield JSON.parse(data);
|
|
41
|
-
} catch {
|
|
42
|
-
// Skip malformed chunks rather than killing the whole turn.
|
|
43
|
-
}
|
|
49
|
+
buffer = buffer.slice(idx + (isCrlf ? 4 : 2));
|
|
50
|
+
const parsed = parseSseEvent(raw);
|
|
51
|
+
if (parsed !== undefined) yield parsed;
|
|
44
52
|
}
|
|
45
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
|
+
}
|
|
46
60
|
} finally {
|
|
47
61
|
try { reader.releaseLock(); } catch {}
|
|
62
|
+
dbg.firstBytes = dbg.firstBytes || `(zero bytes — total=${totalBytes})`;
|
|
48
63
|
}
|
|
49
64
|
}
|
|
50
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; }
|
|
81
|
+
}
|
|
82
|
+
const data = dataLines.join('\n');
|
|
83
|
+
if (!data || data === '[DONE]') return undefined;
|
|
84
|
+
try { return JSON.parse(data); } catch { return undefined; }
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
function toGeminiRole(role: PiMessage['role']): 'user' | 'model' {
|
|
52
88
|
return role === 'assistant' ? 'model' : 'user';
|
|
53
89
|
}
|
|
@@ -149,9 +185,19 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
149
185
|
let lastFinish: string | undefined;
|
|
150
186
|
let promptBlockReason: string | undefined;
|
|
151
187
|
let usage: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
188
|
+
// Debug counters — drop once this stabilises.
|
|
189
|
+
let chunkCount = 0;
|
|
190
|
+
let thoughtPartCount = 0;
|
|
191
|
+
let emptyTextPartCount = 0;
|
|
192
|
+
let firstChunkSummary = '';
|
|
193
|
+
const dbg = { firstBytes: '' };
|
|
152
194
|
|
|
153
195
|
try {
|
|
154
|
-
for await (const chunk of parseSse(res)) {
|
|
196
|
+
for await (const chunk of parseSse(res, dbg)) {
|
|
197
|
+
chunkCount++;
|
|
198
|
+
if (chunkCount === 1) {
|
|
199
|
+
try { firstChunkSummary = JSON.stringify(chunk).slice(0, 600); } catch {}
|
|
200
|
+
}
|
|
155
201
|
// The whole prompt can be rejected before we even get a candidate.
|
|
156
202
|
if (chunk?.promptFeedback?.blockReason) {
|
|
157
203
|
promptBlockReason = chunk.promptFeedback.blockReason;
|
|
@@ -161,10 +207,12 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
161
207
|
for (const part of parts) {
|
|
162
208
|
// Thinking models emit reasoning parts with `thought: true`. They
|
|
163
209
|
// shouldn't be shown to the user as part of the visible answer.
|
|
164
|
-
if (part?.thought) continue;
|
|
210
|
+
if (part?.thought) { thoughtPartCount++; continue; }
|
|
165
211
|
if (typeof part?.text === 'string' && part.text.length > 0) {
|
|
166
212
|
accumulated += part.text;
|
|
167
213
|
yield { type: 'text_delta', delta: part.text };
|
|
214
|
+
} else {
|
|
215
|
+
emptyTextPartCount++;
|
|
168
216
|
}
|
|
169
217
|
}
|
|
170
218
|
if (candidate?.finishReason) lastFinish = candidate.finishReason;
|
|
@@ -185,6 +233,19 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
185
233
|
return;
|
|
186
234
|
}
|
|
187
235
|
|
|
236
|
+
log.info(
|
|
237
|
+
`[pi/google] stream done — chunks=${chunkCount} text=${accumulated.length} ` +
|
|
238
|
+
`thoughtParts=${thoughtPartCount} emptyTextParts=${emptyTextPartCount} ` +
|
|
239
|
+
`finishReason=${lastFinish || 'none'} ` +
|
|
240
|
+
`promptTok=${usage?.inputTokens ?? '?'} outTok=${usage?.outputTokens ?? '?'}`,
|
|
241
|
+
);
|
|
242
|
+
if (chunkCount > 0 && !accumulated) {
|
|
243
|
+
log.info(`[pi/google] first chunk (truncated): ${firstChunkSummary}`);
|
|
244
|
+
} else if (chunkCount === 0) {
|
|
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)}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
188
249
|
// Prompt-level block: nothing came back at all.
|
|
189
250
|
if (promptBlockReason) {
|
|
190
251
|
yield { type: 'error', error: `Gemini blocked the prompt (${promptBlockReason}).` };
|
|
@@ -199,7 +260,10 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
199
260
|
const reason = lastFinish && lastFinish !== 'STOP' && lastFinish !== 'FINISH_REASON_STOP'
|
|
200
261
|
? lastFinish
|
|
201
262
|
: undefined;
|
|
202
|
-
|
|
263
|
+
const hint = thoughtPartCount > 0 && !lastFinish
|
|
264
|
+
? ' (model emitted thinking but never the final answer — try a non-thinking model like gemini-2.5-flash, or raise maxOutputTokens)'
|
|
265
|
+
: '';
|
|
266
|
+
yield { type: 'error', error: finishReasonMessage(reason) + hint };
|
|
203
267
|
yield { type: 'done', stopReason: mapStopReason(lastFinish), usage };
|
|
204
268
|
return;
|
|
205
269
|
}
|