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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.47.3",
3
+ "version": "0.47.5",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -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
- // SSE event boundary is a blank line. Process every complete event in buffer.
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 ((idx = buffer.indexOf('\n\n')) !== -1) {
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 dataLines = raw.split('\n').filter((l) => l.startsWith('data:'));
36
- if (!dataLines.length) continue;
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
- yield { type: 'error', error: finishReasonMessage(reason) };
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
  }