@eeacms/volto-eea-chatbot 4.1.3 → 4.1.4

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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Resolve the real document text behind a chat answer.
3
+ *
4
+ * The Onyx v3 stream ships documents as search *blurbs* (~600 chars) — it has no
5
+ * `content` field at all (see `SearchDoc` in Onyx `context/search/models.py`).
6
+ * Sending blurbs to the fact-checker makes supported claims look hallucinated,
7
+ * because the answer was written from full chunk text, not from those snippets.
8
+ *
9
+ * Onyx already exposes that text:
10
+ *
11
+ * GET /api/document/chunk-info?document_id=<id>&chunk_id=<n>
12
+ * -> { "content": "<chunk text>", "num_tokens": 512 }
13
+ *
14
+ * and the stream already carries both parameters for every document
15
+ * (`document_id` + `chunk_ind`). This module goes through the Volto `/_da`
16
+ * proxy so no extra credentials or CORS setup are needed.
17
+ *
18
+ * Notes:
19
+ * - One chunk is ~512 tokens (`DOC_EMBEDDING_CONTEXT_SIZE`). Onyx builds the
20
+ * answer from the matched chunk *plus its neighbourhood* (±2 for
21
+ * `INCLUDE_ADJACENT_SECTIONS`, ±5 for `FULL_DOCUMENT`), so the centre chunk
22
+ * alone is not enough — `CHUNK_EVIDENCE_WINDOW` defaults to 2.
23
+ * - A gateway in front of Onyx returns 429 under concurrency; one retry after a
24
+ * short delay clears it.
25
+ * - 404 simply means "no such chunk" (past the end of the document) and ends the
26
+ * expansion for that document.
27
+ */
28
+
29
+ export const CHUNK_EVIDENCE_WINDOW = 2;
30
+ /**
31
+ * Safety bound for the window. Onyx itself never expands past ±5
32
+ * (`FULL_DOC_NUM_CHUNKS_AROUND`), and every extra step costs two more chunk
33
+ * requests per document, so a nonsensical CMS value is clamped rather than
34
+ * turned into a request storm against a rate-limited gateway.
35
+ */
36
+ export const MAX_CHUNK_EVIDENCE_WINDOW = 20;
37
+ const DEFAULT_CONCURRENCY = 4;
38
+ const RETRY_DELAY_MS = 1000;
39
+
40
+ export interface ChunkEvidence {
41
+ /** Joined chunk text, empty string when nothing could be resolved. */
42
+ text: string;
43
+ /** How many chunks were fetched. */
44
+ chunks: number;
45
+ /**
46
+ * `chunk` – real Onyx chunk text, safe to verify against.
47
+ * `snippet` – only the search blurb is available; absence of a claim in it is
48
+ * NOT evidence of hallucination.
49
+ */
50
+ kind: 'chunk' | 'snippet';
51
+ }
52
+
53
+ export type ChunkEvidenceMap = Record<string, ChunkEvidence>;
54
+
55
+ type ChunkInfoBody = {
56
+ content?: string;
57
+ num_tokens?: number;
58
+ // Error envelopes: Onyx uses `detail`, the gateway in front of it uses `error`.
59
+ detail?: string;
60
+ error?: string;
61
+ };
62
+
63
+ type FetchLike = (url: string) => Promise<{
64
+ status: number;
65
+ json: () => Promise<ChunkInfoBody>;
66
+ }>;
67
+
68
+ const RATE_LIMIT_PATTERN = /rate limit|slow down/i;
69
+
70
+ const sleep = (ms: number) =>
71
+ new Promise((resolve) => {
72
+ setTimeout(resolve, ms);
73
+ });
74
+
75
+ /** Proxy path handled by `src/middleware.js` (`/_da/...` -> Onyx `/api/...`). */
76
+ export function chunkInfoUrl(documentId: string, chunkInd: number): string {
77
+ return `/_da/document/chunk-info?document_id=${encodeURIComponent(
78
+ documentId,
79
+ )}&chunk_id=${chunkInd}`;
80
+ }
81
+
82
+ /**
83
+ * Fetch a single chunk. Returns null for "no such chunk" and for any
84
+ * transport/HTTP failure, retrying once on 429.
85
+ *
86
+ * The response body is authoritative, not the status: the Volto proxy pipes
87
+ * Onyx bodies through without the upstream status, so a 404 arrives as
88
+ * `200 + {"detail": "Chunk not found"}` and a gateway throttle as
89
+ * `200 + {"error": "Rate limit exceeded…"}`. A chunk with no `content` is never
90
+ * usable evidence, whatever the status says.
91
+ */
92
+ export async function fetchChunk(
93
+ documentId: string,
94
+ chunkInd: number,
95
+ {
96
+ fetchImpl,
97
+ retryDelay = RETRY_DELAY_MS,
98
+ }: { fetchImpl?: FetchLike; retryDelay?: number } = {},
99
+ ): Promise<string | null> {
100
+ const doFetch: FetchLike = fetchImpl || ((url: string) => fetch(url));
101
+
102
+ const attempt = async (): Promise<{
103
+ status: number;
104
+ text: string;
105
+ } | null> => {
106
+ try {
107
+ const response = await doFetch(chunkInfoUrl(documentId, chunkInd));
108
+ if (response.status === 429) {
109
+ return { status: 429, text: '' };
110
+ }
111
+ if (response.status !== 200) {
112
+ return { status: response.status, text: '' };
113
+ }
114
+ const body = await response.json();
115
+ if (typeof body?.content === 'string' && body.content !== '') {
116
+ return { status: 200, text: body.content };
117
+ }
118
+ const message = String(body?.error || body?.detail || '');
119
+ return {
120
+ status: RATE_LIMIT_PATTERN.test(message) ? 429 : 404,
121
+ text: '',
122
+ };
123
+ } catch {
124
+ return null;
125
+ }
126
+ };
127
+
128
+ let result = await attempt();
129
+ if (result && result.status === 429) {
130
+ await sleep(retryDelay);
131
+ result = await attempt();
132
+ }
133
+ if (!result || result.status !== 200) {
134
+ return null;
135
+ }
136
+ return result.text;
137
+ }
138
+
139
+ /**
140
+ * Normalise a configured window: whole number between 0 and
141
+ * `MAX_CHUNK_EVIDENCE_WINDOW`, falling back to the default for junk input.
142
+ * The value comes from a Volto block field, so it can be anything an editor typed.
143
+ */
144
+ export function normalizeWindow(window: number): number {
145
+ const value = Number(window);
146
+ if (!Number.isFinite(value)) {
147
+ return CHUNK_EVIDENCE_WINDOW;
148
+ }
149
+ return Math.max(0, Math.min(Math.floor(value), MAX_CHUNK_EVIDENCE_WINDOW));
150
+ }
151
+
152
+ /**
153
+ * Fetch the matched chunk plus `window` chunks on each side, stopping at the
154
+ * first missing chunk above the centre (the end of the document).
155
+ */
156
+ export async function fetchDocumentEvidence(
157
+ documentId: string,
158
+ centerChunkInd: number,
159
+ {
160
+ window = CHUNK_EVIDENCE_WINDOW,
161
+ fetchImpl,
162
+ retryDelay,
163
+ }: { window?: number; fetchImpl?: FetchLike; retryDelay?: number } = {},
164
+ ): Promise<ChunkEvidence> {
165
+ const center = Math.max(0, Number(centerChunkInd) || 0);
166
+ const win = normalizeWindow(window);
167
+ const lower = Math.max(0, center - win);
168
+ const upper = center + win;
169
+
170
+ const inds: number[] = [];
171
+ for (let ind = lower; ind <= upper; ind++) {
172
+ inds.push(ind);
173
+ }
174
+
175
+ const texts: string[] = [];
176
+ for (const ind of inds) {
177
+ const content = await fetchChunk(documentId, ind, {
178
+ fetchImpl,
179
+ retryDelay,
180
+ });
181
+ if (content === null) {
182
+ // Past the end of the document — everything above is missing too.
183
+ if (ind > center) {
184
+ break;
185
+ }
186
+ continue;
187
+ }
188
+ texts.push(content);
189
+ }
190
+
191
+ return {
192
+ text: texts.join('\n'),
193
+ chunks: texts.length,
194
+ kind: texts.length > 0 ? 'chunk' : 'snippet',
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Resolve evidence for a set of documents, a few at a time.
200
+ *
201
+ * Accepts anything carrying `document_id` + `chunk_ind` (the Onyx documents, or
202
+ * the `halloumiSource`-shaped objects built for the fact-checker).
203
+ */
204
+ export async function fetchEvidence(
205
+ docs: any[],
206
+ {
207
+ window = CHUNK_EVIDENCE_WINDOW,
208
+ concurrency = DEFAULT_CONCURRENCY,
209
+ fetchImpl,
210
+ retryDelay,
211
+ }: {
212
+ window?: number;
213
+ concurrency?: number;
214
+ fetchImpl?: FetchLike;
215
+ retryDelay?: number;
216
+ } = {},
217
+ ): Promise<ChunkEvidenceMap> {
218
+ const unique: any[] = [];
219
+ const seen = new Set<string>();
220
+ (docs || []).forEach((doc: any) => {
221
+ const documentId = doc?.document_id;
222
+ if (!documentId || seen.has(documentId)) {
223
+ return;
224
+ }
225
+ seen.add(documentId);
226
+ unique.push(doc);
227
+ });
228
+
229
+ const evidence: ChunkEvidenceMap = {};
230
+ let cursor = 0;
231
+
232
+ const worker = async () => {
233
+ while (cursor < unique.length) {
234
+ const current = cursor++;
235
+ const doc = unique[current];
236
+ evidence[doc.document_id] = await fetchDocumentEvidence(
237
+ doc.document_id,
238
+ doc.chunk_ind,
239
+ { window, fetchImpl, retryDelay },
240
+ );
241
+ }
242
+ };
243
+
244
+ const workers = Array.from(
245
+ { length: Math.max(1, Math.min(concurrency, unique.length)) },
246
+ worker,
247
+ );
248
+ await Promise.all(workers);
249
+
250
+ return evidence;
251
+ }
@@ -813,19 +813,47 @@ mark {
813
813
  text-transform: uppercase;
814
814
  }
815
815
 
816
- .score-progress-bar {
817
- overflow: hidden;
816
+ // Three discrete positions instead of a proportional fill: a per-claim score is
817
+ // a verdict, not a probability. The old bar made not_enough_info read as
818
+ // "40% verified", which was never measured.
819
+ .verdict-scale {
820
+ display: flex;
818
821
  width: 100%;
822
+ gap: 4px;
823
+ }
824
+
825
+ .verdict-step {
819
826
  height: 6px;
827
+ flex: 1;
820
828
  border-radius: 3px;
821
829
  background-color: @gray-200;
830
+ // An inset ring rather than a border: the green of `not_enough_info` is about
831
+ // 2.8:1 against white, so without an outline the middle position - the one that
832
+ // needs pointing out most - reads as empty. No layout shift, unlike a border.
833
+ box-shadow: inset 0 0 0 1px @gray-300;
822
834
  }
823
835
 
824
- .score-progress-fill {
825
- height: 100%;
826
- border-radius: 3px;
827
- background-color: var(--claim-color);
828
- transition: width 0.3s ease;
836
+ .verdict-scale[data-verdict='contradicted'] .verdict-step:nth-child(1) {
837
+ background-color: @red-500;
838
+ }
839
+
840
+ // Not red: sources saying nothing is not the same finding as sources saying
841
+ // something else. green-500 rather than the chip's green-300 because a solid bar
842
+ // on white needs more weight than a pale background does (green-300 is 1.7:1).
843
+ .verdict-scale[data-verdict='not_enough_info'] .verdict-step:nth-child(2) {
844
+ background-color: @green-500;
845
+ }
846
+
847
+ .verdict-scale[data-verdict='supported'] .verdict-step:nth-child(3) {
848
+ background-color: @green-700;
849
+ }
850
+
851
+ // Checked over search snippets only: no position can be claimed.
852
+ .verdict-scale[data-verdict='unknown'] .verdict-step {
853
+ height: 4px;
854
+ border: 1px dashed @gray-400;
855
+ background-color: transparent;
856
+ box-shadow: none; // the dashed outline already carries the shape
829
857
  }
830
858
 
831
859
  .rationale-section {
@@ -153,6 +153,12 @@ export interface ChatMessageProps {
153
153
  persona?: number;
154
154
  maxContextSegments?: number;
155
155
  batchSize?: number;
156
+ /**
157
+ * How many Onyx chunks to pull around the matched one when resolving real
158
+ * document text for the fact-checker (0 = centre chunk only, 5 = the widest
159
+ * expansion Onyx itself uses). See `services/chunkEvidence.ts`.
160
+ */
161
+ chunkEvidenceWindow?: number;
156
162
  isLastMessage?: boolean;
157
163
  className?: string;
158
164
  chatWindowRef?: React.RefObject<HTMLDivElement>;
@@ -59,13 +59,63 @@ export const useCopyToClipboard = (text: string): [boolean, () => void] => {
59
59
  };
60
60
 
61
61
  /**
62
- * Map a 0-1 claim score to a categorical label.
63
- * Scores: 1.0 = supported, 0.4 = not_enough_info, 0.0 = contradicted.
62
+ * The fact-checker collapses its three verdicts into a per-claim score and does
63
+ * not send the verdict itself (`verdict_scores` in `rag_facts_check/server.py`:
64
+ * supported 1.0, not_enough_info 0.4, contradicted 0.0). The thresholds below are
65
+ * therefore part of the contract — keep them in step with the backend.
64
66
  */
65
- export function scoreToLabel(score: number): string {
66
- if (score >= 0.8) return 'High';
67
- if (score >= 0.1) return 'Low';
68
- return 'Failed';
67
+ export type ClaimVerdict = 'supported' | 'not_enough_info' | 'contradicted';
68
+
69
+ export function scoreToVerdict(score: number): ClaimVerdict {
70
+ if (score >= 0.8) return 'supported';
71
+ if (score >= 0.1) return 'not_enough_info';
72
+ return 'contradicted';
73
+ }
74
+
75
+ /**
76
+ * Modal header copy. Names the outcome, never the process: "Verified Claim"
77
+ * reads as "this is true" even when the check contradicted the claim, and the
78
+ * old High/Low/Failed badge read as confidence levels for what are three
79
+ * discrete verdicts.
80
+ */
81
+ const VERDICT_HEADER: Record<ClaimVerdict, string> = {
82
+ supported: 'Supported by the sources',
83
+ not_enough_info: 'Not confirmed by the sources',
84
+ contradicted: 'Contradicted by the sources',
85
+ };
86
+
87
+ const VERDICT_BADGE: Record<ClaimVerdict, string> = {
88
+ supported: 'Supported',
89
+ not_enough_info: 'Not confirmed',
90
+ contradicted: 'Contradicted',
91
+ };
92
+
93
+ export function claimHeaderLabel(
94
+ score: number,
95
+ contextLimited = false,
96
+ ): string {
97
+ // Checked over search snippets only: the supporting text may simply have been
98
+ // missing, so the verdict is not trustworthy enough to name.
99
+ if (contextLimited) return "Couldn't be checked";
100
+ return VERDICT_HEADER[scoreToVerdict(score)];
101
+ }
102
+
103
+ export function claimBadgeLabel(score: number, contextLimited = false): string {
104
+ if (contextLimited) return 'Unverified';
105
+ return VERDICT_BADGE[scoreToVerdict(score)];
106
+ }
107
+
108
+ /**
109
+ * Which of the three positions the verdict indicator marks, or `unknown` when the
110
+ * check ran over snippets only and no verdict can be named.
111
+ */
112
+ export type ClaimScaleState = ClaimVerdict | 'unknown';
113
+
114
+ export function claimScaleState(
115
+ score: number,
116
+ contextLimited = false,
117
+ ): ClaimScaleState {
118
+ return contextLimited ? 'unknown' : scoreToVerdict(score);
69
119
  }
70
120
 
71
121
  export function convertToPercentage(
@@ -14,6 +14,97 @@ Frontend (useQualityMarkers.js)
14
14
 
15
15
  The rag-fact-checker's `/halloumi/generate` endpoint is a drop-in replacement for the original Halloumi API — it accepts the same request format (`{answer, sources}`) and returns a compatible response (`{claims, segments}`), so all existing frontend components (`HalloumiFeedback`, `useQualityMarkers`, `ClaimSegments`) work without changes.
16
16
 
17
+ ## Multi-evidence claims
18
+
19
+ A claim can be supported by several evidence quotes (the fact-checker returns up to
20
+ 3 per claim), so `claim.segmentIds` is a list and each segment carries offsets into
21
+ the **joined** sources string:
22
+
23
+ ```json
24
+ {
25
+ "claims": [{ "segmentIds": ["0", "1", "2"], "score": 1.0 }],
26
+ "segments": {
27
+ "0": { "id": 0, "startOffset": 120, "endOffset": 190 },
28
+ "1": { "id": 1, "startOffset": 402, "endOffset": 455 },
29
+ "2": { "id": 2, "startOffset": 980, "endOffset": 1030 }
30
+ }
31
+ }
32
+ ```
33
+
34
+ `ClaimSegments` groups those segments per source (one tab per cited document, one
35
+ "Jump to citation" chip per segment) and `RenderClaimView` highlights them inside the
36
+ source text. Segments are half-open `[start, end)`; overlapping ones are clipped so
37
+ the source text is never rendered twice.
38
+
39
+ ## Source text: Onyx chunks, not search blurbs
40
+
41
+ The Onyx v3 chat stream carries documents as `SearchDoc` objects, which have **no
42
+ `content` field** — only a ~600-character `blurb`. Sending blurbs makes supported
43
+ claims look hallucinated, because the answer was written from full chunk text.
44
+
45
+ `ChatBlock/services/chunkEvidence.ts` therefore resolves the real text before the
46
+ fact-check runs, through the existing `/_da` proxy:
47
+
48
+ ```
49
+ GET /_da/document/chunk-info?document_id=<id>&chunk_id=<n>
50
+ -> Onyx GET /api/document/chunk-info -> { "content": "<chunk text>", "num_tokens": 512 }
51
+ ```
52
+
53
+ - Both parameters are already present on every streamed document (`document_id`,
54
+ `chunk_ind`), so no extra data is needed from Onyx.
55
+ - The matched chunk is fetched together with `chunkEvidenceWindow` chunks on each
56
+ side (default **2**, i.e. what Onyx's `INCLUDE_ADJACENT_SECTIONS` expansion uses).
57
+ The centre chunk alone is not enough — the claims usually sit in its
58
+ neighbourhood. Onyx's widest expansion is ±5; raise the value to match it.
59
+ - The window is also a block setting — **Evidence window** in the chat block sidebar —
60
+ so it can be tuned per block without a deploy. Values outside 0–20 are clamped
61
+ (`MAX_CHUNK_EVIDENCE_WINDOW`): each extra step costs two more chunk requests per
62
+ document against a rate-limited gateway.
63
+ - A gateway in front of Onyx returns **429** under concurrency, so requests run
64
+ through a small pool with one retry after 1s. **404** means "no such chunk" and
65
+ simply ends the expansion for that document.
66
+ - `useChunkEvidence` gates the fact-check call on `evidenceSettled`, so a check is
67
+ never fired on blurbs in the same commit that started the fetch. If resolution
68
+ fails, verification still runs on blurbs rather than never running.
69
+
70
+ Each source sent to the fact-checker carries `kind`:
71
+
72
+ | `kind` | meaning |
73
+ | --------- | --------------------------------------------------------------------- |
74
+ | `chunk` | real Onyx chunk text — a claim missing from it is meaningful evidence |
75
+ | `snippet` | only the search blurb is available — a missing claim proves nothing |
76
+
77
+ The fact-checker answers with a `context_quality` block describing what it was
78
+ actually given:
79
+
80
+ ```json
81
+ {
82
+ "level": "partial",
83
+ "sources": 4,
84
+ "chunk_sources": 3,
85
+ "snippet_sources": 1,
86
+ "note": "1 of 4 sources were search snippets, not full document text…"
87
+ }
88
+ ```
89
+
90
+ | `level` | meaning |
91
+ | --------- | ----------------------------------------------------------- |
92
+ | `full` | every source was `chunk` text — the score is a real verdict |
93
+ | `partial` | at least one source was a `snippet` |
94
+ | `unknown` | the backend reported no level (older deployment) |
95
+ | `none` | no sources reached the checker |
96
+
97
+ In `partial` mode each `not_enough_info` claim additionally carries
98
+ `context_limited: true`. Both signals are rendered: `HalloumiFeedback` shows a
99
+ "Partial context" notice under the score, and `ClaimModal` labels such a claim
100
+ `Unverified / Partial context` instead of `Low / Verification`. The numeric score is
101
+ deliberately left alone — the label carries the caveat, the number stays comparable
102
+ across runs.
103
+
104
+ `halloumiContext` (used for segment highlighting) and `halloumiSource.text` (used by
105
+ the fact-checker for offsets) are always the same nbsp-cleaned string. Changing one
106
+ without the other breaks highlighting.
107
+
17
108
  ## Configuration
18
109
 
19
110
  | Environment Variable | Default | Description |
package/src/index.js CHANGED
@@ -57,5 +57,6 @@ export * from './ChatBlock/packets';
57
57
  export { default as UserActionsToolbar } from './ChatBlock/components/UserActionsToolbar';
58
58
  export { default as FeedbackModal } from './ChatBlock/components/FeedbackModal';
59
59
  export { default as ChatMessageFeedback } from './ChatBlock/components/ChatMessageFeedback';
60
+ export { isAISearchInputRestricted } from './AISearchInput';
60
61
 
61
62
  export default applyConfig;
package/src/middleware.js CHANGED
@@ -18,6 +18,9 @@ const ALLOWED_PROXY_PATHS = [
18
18
  { path: '/chat/send-message', methods: ['POST'] },
19
19
  { path: '/chat/send-chat-message', methods: ['POST'] },
20
20
  { path: '/chat/create-chat-message-feedback', methods: ['POST'] },
21
+ // Real document text behind an answer, used to give the fact-checker the same
22
+ // text the answer generator read (see ChatBlock/services/chunkEvidence.ts).
23
+ { pathPattern: /^\/document\/chunk-info$/, methods: ['GET'] },
21
24
  ];
22
25
 
23
26
  /**
@@ -259,6 +262,12 @@ async function send_onyx_request(
259
262
  log(`Dumped LLM response to: ${filePath}`);
260
263
  }
261
264
 
265
+ // Propagate the upstream status. Without this, Express defaults to 200 and
266
+ // every Onyx error (404 chunk not found, 429 rate limit, 401 revoked key)
267
+ // reaches the browser as a successful response, which makes the evidence
268
+ // client unable to tell "missing" from "found".
269
+ res.status(response.status);
270
+
262
271
  if (!api_key) {
263
272
  if (response.headers.get('transfer-encoding') === 'chunked') {
264
273
  res.set('Content-Type', 'text/event-stream');
@@ -174,6 +174,62 @@ describe('src/middleware', () => {
174
174
  expect(lastCall[1].body).toBeUndefined();
175
175
  });
176
176
 
177
+ // Express defaults to 200 when the proxy never sets a status, so every Onyx
178
+ // error reached the browser as a success. The evidence client cannot tell a
179
+ // missing chunk from a found one (or a gateway throttle from a response) unless
180
+ // the upstream status survives the proxy.
181
+ it('propagates an upstream 404 instead of flattening it to 200', async () => {
182
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
183
+ process.env.ONYX_URL = 'http://localhost:3000';
184
+ req.method = 'GET';
185
+ req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=9';
186
+ req.body = null;
187
+ nodeFetch.mockResolvedValueOnce({
188
+ status: 404,
189
+ headers: {
190
+ get: jest.fn().mockReturnValue('application/json'),
191
+ raw: jest.fn().mockReturnValue({}),
192
+ },
193
+ body: { pipe: jest.fn() },
194
+ });
195
+
196
+ await middleware(req, res, next);
197
+
198
+ expect(res.status).toHaveBeenCalledWith(404);
199
+ });
200
+
201
+ it('propagates an upstream 429 so the client can retry it', async () => {
202
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
203
+ process.env.ONYX_URL = 'http://localhost:3000';
204
+ req.method = 'GET';
205
+ req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=0';
206
+ req.body = null;
207
+ nodeFetch.mockResolvedValueOnce({
208
+ status: 429,
209
+ headers: {
210
+ get: jest.fn().mockReturnValue('application/json'),
211
+ raw: jest.fn().mockReturnValue({}),
212
+ },
213
+ body: { pipe: jest.fn() },
214
+ });
215
+
216
+ await middleware(req, res, next);
217
+
218
+ expect(res.status).toHaveBeenCalledWith(429);
219
+ });
220
+
221
+ it('answers 200 for a successful proxied chunk', async () => {
222
+ process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
223
+ process.env.ONYX_URL = 'http://localhost:3000';
224
+ req.method = 'GET';
225
+ req.url = '/_da/document/chunk-info?document_id=doc-1&chunk_id=0';
226
+ req.body = null;
227
+
228
+ await middleware(req, res, next);
229
+
230
+ expect(res.status).toHaveBeenCalledWith(200);
231
+ });
232
+
177
233
  it('uses mock create-chat-session when MOCK_LLM_FILE_PATH is set', async () => {
178
234
  process.env.ONYX_API_KEY = 'test-key'; //betterleaks:allow
179
235
  process.env.ONYX_URL = 'http://localhost:3000';