bunnyquery 1.9.7 → 1.9.10

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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
14
14
  import { isServerExtractable, isPagedReadFile, isImageVisionFile, isWindowedReadFile, makeExtractPlaceholder, makeRenderPlaceholder, makeWindowPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
15
- import { chatEngineConfig, pollOpt, windowedIndexingEnabled } from './config';
15
+ import { chatEngineConfig, pollOpt, windowedIndexingEnabled, liveStreamingEnabled, liveStreamingRealtimeEnabled } from './config';
16
16
  // Output sizing lives in budget.ts so the request cap and the reserve the input
17
17
  // budget subtracts cannot drift; getMaxOutputTokens also clamps per model.
18
18
  import { getMaxOutputTokens } from './budget';
@@ -72,6 +72,85 @@ function mcpEndpointFor(
72
72
  }
73
73
  const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
74
74
 
75
+ /**
76
+ * THE two `stream` flags of a streamed chat turn, produced together or not at all.
77
+ *
78
+ * There are two of them and they are NOT the same flag:
79
+ *
80
+ * * `transport.stream` is SKAPI's. It tells the polling worker to read the
81
+ * destination's response incrementally and append the raw bytes to the chunk
82
+ * table, and it is never sent on to the destination.
83
+ * * `body.stream` is the DESTINATION's own field, and BunnyQuery is the party
84
+ * that may set it: skapi relays bytes and knows no vendor, so it cannot know
85
+ * that Anthropic Messages and OpenAI Responses both happen to spell it
86
+ * `stream` at the top level of the body.
87
+ *
88
+ * Setting one without the other fails QUIETLY, which is why they are produced by
89
+ * one function from one boolean and returned as one object:
90
+ *
91
+ * * body streams, skapi buffers -> the row stores an SSE TRANSCRIPT where
92
+ * extractClaudeText / extractOpenAIText expect a parsed document, so the turn
93
+ * reads back as an empty answer with nothing in the logs to say why.
94
+ * * skapi streams, the body never asked -> the destination sends one plain
95
+ * document, the relay chops it into chunks, the frame parser finds no framing
96
+ * at all, and the row settles with a status and no body.
97
+ *
98
+ * Two frozen constants rather than a fresh object per call: the pair is a
99
+ * CONSTANT, and an object literal built at each call site is exactly the shape
100
+ * that drifts when someone edits one arm.
101
+ */
102
+ export type ChatStreamWiring = {
103
+ /** Spread into the clientSecretRequest OPTIONS (skapi's relay switch). `realtime`
104
+ * belongs here and never in `body`: it is skapi's, not the destination's. */
105
+ transport: { stream?: true; realtime?: true };
106
+ /** Spread into `data` (the destination's own switch). */
107
+ body: { stream?: true };
108
+ };
109
+ const CHAT_STREAM_ON: ChatStreamWiring = Object.freeze({
110
+ transport: Object.freeze({ stream: true as true }),
111
+ body: Object.freeze({ stream: true as true }),
112
+ }) as ChatStreamWiring;
113
+ const CHAT_STREAM_OFF: ChatStreamWiring = Object.freeze({
114
+ transport: Object.freeze({}),
115
+ body: Object.freeze({}),
116
+ }) as ChatStreamWiring;
117
+
118
+ /**
119
+ * Both stream flags for a CHAT turn, from the one `liveStreaming` opt-in.
120
+ *
121
+ * Chat turns only. Deliberately NOT called from:
122
+ * * the model LISTING calls (listClaudeModels / listOpenAIModels) - plain GETs
123
+ * with no body at all, and a listing has nothing to stream;
124
+ * * notifyAgentSaveAttachment (background INDEXING) - see the note there.
125
+ *
126
+ * `queue` is asked for because a chat turn sent WITH ATTACHMENTS runs on the
127
+ * background queue ("<userId>-bg") so it waits behind its own files, and that one
128
+ * must not stream either. Not for the indexing reasons above: the reason is
129
+ * RECOVERY. A streamed row keeps no body, so its only recovery after a reload is a
130
+ * poll re-attached with a reader, and the re-attach loop classifies everything on
131
+ * the bg queue as a background poll (flat cadence, no reader, that is what the
132
+ * MAX_CONCURRENT_BG_POLLS budget is for). Such a turn would therefore settle on an
133
+ * empty envelope and the answer would be gone. Losing the live rendering on the
134
+ * one kind of turn that already waited minutes for its files is the cheaper half of
135
+ * that trade.
136
+ */
137
+ const CHAT_STREAM_ON_REALTIME: ChatStreamWiring = Object.freeze({
138
+ // `realtime` rides on the TRANSPORT arm only. It is a skapi option, not a field
139
+ // the destination understands, so it must never reach `data`: the body arm stays
140
+ // exactly what it is with the socket off.
141
+ transport: Object.freeze({ stream: true as true, realtime: true as true }),
142
+ body: Object.freeze({ stream: true as true }),
143
+ }) as ChatStreamWiring;
144
+
145
+ export function chatStreamWiring(queue?: string): ChatStreamWiring {
146
+ if (!liveStreamingEnabled()) return CHAT_STREAM_OFF;
147
+ if (isBgIndexingQueue(queue)) return CHAT_STREAM_OFF;
148
+ // Socket delivery is a separate opt-in from streaming itself: see
149
+ // liveStreamingRealtime for why a host that does not own its skapi instance
150
+ // should leave it off.
151
+ return liveStreamingRealtimeEnabled() ? CHAT_STREAM_ON_REALTIME : CHAT_STREAM_ON;
152
+ }
153
+
75
154
  // Resolve the per-image `detail` for OpenAI. The version match tolerates a
76
155
  // trailing variant/date suffix (`gpt-5.4-nano`, `-mini`, `-2026-01-01`, …):
77
156
  // previously the pattern was anchored with no suffix allowed, so EVERY suffixed
@@ -455,6 +534,17 @@ export type CallClaudeWithMcpParams = {
455
534
  // engine's own poll sites and imported by agent.vue; the widget carries its own
456
535
  // copy in src/index.js that must be kept in step.
457
536
  export const POLL_INTERVAL = 3000;
537
+ // Poll cadence while a turn is STREAMING, i.e. while the poll is also reading the
538
+ // relayed chunks. Faster than POLL_INTERVAL because on a streaming poll the tick is
539
+ // not just "is it done yet", it is the delivery of the answer: at 3s the reader
540
+ // watches the text arrive in three-second steps.
541
+ //
542
+ // 1s and not less, because the relay coalesces its writes to about one per second
543
+ // (the worker's byte budget, see the polling worker's _STREAM_FLUSH_INTERVAL_S), so
544
+ // a faster poll reads the same rows again and buys nothing but requests. It is also
545
+ // what makes "roughly one paint per second" fall out of the transport rather than
546
+ // out of a timer the paint path has to guess at.
547
+ export const STREAM_POLL_INTERVAL = 1000;
458
548
  // Ceiling on how many BACKGROUND indexing polls may be attached at once, across
459
549
  // every poll site (the engine's drain, the engine's history load, and each
460
550
  // client's own fallback poller).
@@ -500,12 +590,20 @@ export async function callClaudeWithMcp({
500
590
  mcpServerDefinition.authorization_token = mcpServer.authorizationToken;
501
591
  }
502
592
 
593
+ // ONE decision, spread in TWO places. See chatStreamWiring: the transport half
594
+ // is skapi's relay switch and the body half is Anthropic's own, and a turn that
595
+ // carries one without the other fails silently rather than loudly. The queue is
596
+ // the same expression the request uses below, so an attachment turn (which runs
597
+ // on the bg queue) is recognised and left buffered.
598
+ const stream = chatStreamWiring(userId || service);
599
+
503
600
  return clientSecretRequest({
504
601
  clientSecretName: 'claude',
505
602
  queue: userId || service,
506
603
  service,
507
604
  owner,
508
605
  ...pollOpt(),
606
+ ...stream.transport,
509
607
  url: ANTHROPIC_MESSAGES_API_URL,
510
608
  method: 'POST',
511
609
  headers: {
@@ -517,6 +615,9 @@ export async function callClaudeWithMcp({
517
615
  data: {
518
616
  model,
519
617
  max_tokens: maxTokens,
618
+ // Top level beside model/messages/mcp_servers, which is where the
619
+ // Messages API takes it.
620
+ ...stream.body,
520
621
  ...(extractContent && extractContent.length
521
622
  ? { _skapi_extract: extractContent }
522
623
  : {}),
@@ -648,12 +749,16 @@ export async function callOpenAIWithPublicMcp(
648
749
  })),
649
750
  ];
650
751
 
752
+ // ONE decision, spread in TWO places - see chatStreamWiring.
753
+ const stream = chatStreamWiring(userId || service);
754
+
651
755
  return clientSecretRequest({
652
756
  clientSecretName: 'openai',
653
757
  queue: userId || service,
654
758
  service,
655
759
  owner,
656
760
  ...pollOpt(),
761
+ ...stream.transport,
657
762
  url: OPENAI_RESPONSES_API_URL,
658
763
  method: 'POST',
659
764
  headers: {
@@ -663,6 +768,9 @@ export async function callOpenAIWithPublicMcp(
663
768
  data: {
664
769
  model: resolvedModel,
665
770
  max_output_tokens: getMaxOutputTokens('openai', resolvedModel),
771
+ // Top level beside model/input/tools, which is where the Responses API
772
+ // takes it.
773
+ ...stream.body,
666
774
  ...(extractContent && extractContent.length
667
775
  ? { _skapi_extract: extractContent }
668
776
  : {}),
@@ -945,6 +1053,22 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
945
1053
  accessGroup: attachment.accessGroup,
946
1054
  });
947
1055
 
1056
+ // INDEXING NEVER STREAMS, on either platform, whatever `liveStreaming` says.
1057
+ // chatStreamWiring is deliberately not called below, and neither `stream` flag
1058
+ // appears in either branch. Three reasons, any one of which is sufficient:
1059
+ //
1060
+ // 1. Nobody is watching. A pass runs on the background queue behind a chain
1061
+ // that can span days; there is no bubble to paint it into, so the only
1062
+ // thing streaming would buy is a chunk table to clean up.
1063
+ // 2. The worker has to READ this response. `auto_continue` (render and window
1064
+ // passes) decides whether to enqueue the next window from the reply, and
1065
+ // the truncation and auth-outage checks that stop a chain read it too.
1066
+ // Those need a real JSON body, and a streamed row settles with a status and
1067
+ // no body at all.
1068
+ // 3. The worker degrades such a pass anyway (it clears `strm` before the call
1069
+ // fires), so asking would be a request the backend is guaranteed to refuse
1070
+ // to honour - and one that leaves the row briefly marked as streaming,
1071
+ // which is exactly the state csr-finalize gates on.
948
1072
  if (platform === 'openai') {
949
1073
  const resolvedModel = info.model || DEFAULT_OPENAI_MODEL;
950
1074
  const imageDetail = getOpenAIImageDetail(resolvedModel);
@@ -1112,6 +1236,11 @@ export function extractOpenAIText(response: any) {
1112
1236
  return '';
1113
1237
  }
1114
1238
 
1239
+ // MODEL LISTINGS NEVER STREAM. Both are plain GETs with no body: there is no
1240
+ // destination-side `stream` field to pair skapi's with, and a listing is one small
1241
+ // document that a caller reads whole. Neither carries `poll` either, so there is
1242
+ // not even a reader to hand chunks to. chatStreamWiring is deliberately not called
1243
+ // here - the pair is chat-turn-only.
1115
1244
  export async function listClaudeModels(service: string, owner: string) {
1116
1245
  return clientSecretRequest({
1117
1246
  clientSecretName: 'claude',