Compare commits

..
Author SHA1 Message Date
buzzert 16104a30bb Preserve response model attribution on the deployed backend base
Backend and API documentation changes from b9ff0a7; retains the currently deployed 5d738f8 backend feature set.
2026-11-02 21:19:00 -08:00
18 changed files with 175 additions and 772 deletions
-1
View File
@@ -31,7 +31,6 @@ RUN apt-get update \
RUN npm ci --omit=dev --no-audit --no-fund
COPY --from=server-build /app/server/dist ./dist
COPY server/src/llm/pdf-text-worker.mjs ./dist/llm/pdf-text-worker.mjs
ENV NODE_ENV=production
ENV HOST=0.0.0.0
+6 -2
View File
@@ -180,7 +180,7 @@ Behavior notes:
Behavior notes:
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments, tool-call metadata, and per-response `metadata.generation` are retained. For older replies with an exact call-log match, generation metadata is recovered before copying. LLM call logs, star/project memberships, and other child threads are not copied.
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
@@ -233,6 +233,9 @@ Behavior notes:
### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }`
- Saved ordinary assistant responses can include `metadata.generation: { "provider": "openai", "model": "gpt-4.1-mini" }`. These are the provider/model identifiers used for that completion request, captured separately from the chat's mutable last-used selection. Model aliases remain the requested alias; this does not claim an underlying provider snapshot ID.
- Newly generated responses store this metadata for both streaming and non-streaming completions. It survives reloads and forks. Inline Sybil Agent replies retain their separate `metadata.agentAside` identity.
- For older assistant messages without generation metadata, this response recovers it only when `metadata.clientRequestId` matches exactly one completed, successful call in the same chat. This read does not rewrite stored history. Missing or ambiguous records, including some older forks/imports, remain unattributed; clients must not substitute the chat's current or last-used model. The web footer displays “Model not recorded” in that case.
### `GET /v1/chats/:chatId/messages`
- Requires the same authenticated owner/admin credential as other chat routes. Responses use `Cache-Control: no-store`.
@@ -338,6 +341,7 @@ Behavior notes:
- Before the Agent is invoked, the backend paginates and supplies the complete safe visible-thread projection as required initial context; the Agent does not have to choose `read_chat_history` for the current thread. User/assistant asides and full supported attachment representations are included, while system/tool rows and unrelated metadata are excluded. Historical content remains untrusted context, not new authority.
- The initial context transfer is limited to 30 MiB. A stored text attachment marked truncated, incomplete history, or a transfer/provider context-limit failure returns an explicit error and no answer from partial history; providers may reject a complete thread that exceeds their model context window.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- Saved ordinary assistant responses also store the completion's provider/model in `metadata.generation`, exposed by `GET /v1/chats/:chatId`; changing the chat selection does not relabel previous responses.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
@@ -359,7 +363,7 @@ Behavior notes:
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `fetch_url` fetches an HTTP(S) URL with browser-like navigation headers and returns plaintext page content. HTML is converted to text. PDF files are detected by media type or PDF signature and have selectable text extracted with page markers (no OCR); at most 10 MiB and 30 pages are read per call. The optional `pages` array selects up to 30 page numbers for follow-up reads, and `maxCharacters` bounds PDF text collection; a brief truncation hint may follow the extracted text. PDF parsing runs in a worker terminated after 8 seconds. Responses identify page count, selected pages, truncation, encrypted PDFs, empty PDFs, and PDFs without extractable text; unavailable extraction returns `ok:false` with `pdfStatus`. Other URL responses also have a 10 MiB download limit.
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
- Devbox tool configuration:
+3 -1
View File
@@ -146,6 +146,8 @@ Each tool invocation can emit multiple `tool_call` events with the same `toolCal
For `persist: false` streams, `chatId` and `callId` are `null`.
When an ordinary persisted completion finishes, its assistant message stores the same request-time provider/model in `metadata.generation`, available in the canonical `GET /v1/chats/:chatId` refresh. Clients must use per-message generation metadata for historical response labels, not the current chat selection. Older messages can lack attribution; see [chat detail](rest.md#get-v1chatschatid). This adds no SSE events or fields, and does not change non-persistent Quick Questions or inline Agent identity.
### `delta`
```json
@@ -213,7 +215,7 @@ Terminal tool-call event:
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients. It converts HTML to text and extracts selectable PDF text page by page (without OCR), including when the response media type is inaccurate but the PDF signature is present. URL downloads are limited to 10 MiB, PDF extraction to 30 pages per call and an isolated worker terminated after 8 seconds, and accumulated PDF text to `maxCharacters` (default 12,000; maximum 50,000), plus a brief truncation hint. PDF tool results include `pdfStatus`, `pageCount`, and selected `pages`; call with `pages:[...]` to retrieve specific pages. Encrypted, page-free, and text-free PDFs return explicit status and text with `ok:false`. Oversized responses fail with a size-limit error.
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
-263
View File
@@ -21,7 +21,6 @@
"html-to-text": "^9.0.5",
"marked": "17.0.2",
"openai": "^6.16.0",
"pdfjs-dist": "5.4.394",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
@@ -776,256 +775,6 @@
"node": ">=8"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -2190,18 +1939,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.394",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz",
"integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.81"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
-1
View File
@@ -30,7 +30,6 @@
"html-to-text": "^9.0.5",
"marked": "17.0.2",
"openai": "^6.16.0",
"pdfjs-dist": "5.4.394",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
-36
View File
@@ -1,36 +0,0 @@
export const MAX_FETCH_RESPONSE_BYTES = 10 * 1024 * 1024;
export async function readBoundedResponseBytes(response: Response, maxBytes: number) {
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
await response.body?.cancel();
throw new Error(`URL response exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MiB download limit.`);
}
const reader = response.body?.getReader();
if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`URL response exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MiB download limit.`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
+40 -70
View File
@@ -6,12 +6,10 @@ import { promisify } from "node:util";
import { convert as htmlToText } from "html-to-text";
import { z } from "zod";
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
import { MAX_FETCH_RESPONSE_BYTES, readBoundedResponseBytes } from "./bounded-response.js";
import { env } from "../env.js";
import { searchBrave } from "../search/brave.js";
import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import { extractPdfText, hasPdfMagic, MAX_PDF_PAGES_PER_FETCH } from "./pdf-text.js";
import type { ChatMessage } from "./types.js";
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
@@ -47,7 +45,6 @@ const FetchUrlArgsSchema = z
.object({
url: z.string().trim().url(),
maxCharacters: z.coerce.number().int().min(500).max(MAX_FETCH_MAX_CHARACTERS).optional(),
pages: z.array(z.coerce.number().int().min(1).max(1_000_000)).min(1).max(MAX_PDF_PAGES_PER_FETCH).optional(),
})
.strict();
@@ -166,7 +163,7 @@ const BASE_CHAT_TOOLS: any[] = [
function: {
name: "fetch_url",
description:
"Fetch a webpage by URL and return readable plaintext content. Text PDFs are extracted page by page; use pages:[3,4] to retrieve selected pages after a truncated result. Scanned PDFs have no extractable text.",
"Fetch a webpage by URL and return readable plaintext content extracted from the page for deeper inspection.",
parameters: {
type: "object",
properties: {
@@ -177,12 +174,6 @@ const BASE_CHAT_TOOLS: any[] = [
maximum: MAX_FETCH_MAX_CHARACTERS,
description: "Maximum response text characters returned (default 12000).",
},
pages: {
type: "array",
items: { type: "integer", minimum: 1, maximum: 1000000 },
maxItems: MAX_PDF_PAGES_PER_FETCH,
description: "For PDFs, extract these page numbers only (up to 30 per fetch).",
},
},
required: ["url"],
additionalProperties: false,
@@ -570,69 +561,48 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
signal: controller.signal,
headers: buildBrowserLikeNavigationHeaders(),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Fetch failed with status ${response.status}.`);
}
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
const bytes = await readBoundedResponseBytes(response, MAX_FETCH_RESPONSE_BYTES);
const url = response.url || parsed.toString();
if (contentType.includes("application/pdf") || hasPdfMagic(bytes)) {
if (!hasPdfMagic(bytes)) {
return { ok: false, url, status: response.status, contentType: contentType || null, pdfStatus: "invalid", text: "The server declared a PDF, but the downloaded file does not have a valid PDF header." };
}
const result = await extractPdfText(bytes, args.pages, maxCharacters);
const text = result.truncated
? `${result.text}\n\n[truncated; re-fetch selected pages with pages:[N,...]]`
: result.text;
return {
ok: result.status === "ok",
url,
status: response.status,
contentType: contentType || "application/pdf",
pdfStatus: result.status,
pageCount: result.pageCount,
pages: result.pages,
pagesTruncated: result.pageCount !== null && result.pageCount > result.pages.length,
truncated: result.truncated,
text,
};
}
const body = new TextDecoder().decode(bytes);
const isHtml = contentType.includes("text/html") || /<!doctype html|<html[\s>]/i.test(body);
const extracted = isHtml
? htmlToText(body, {
wordwrap: false,
preserveNewlines: true,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "script", format: "skip" },
{ selector: "style", format: "skip" },
{ selector: "noscript", format: "skip" },
{ selector: "a", options: { ignoreHref: true } },
],
})
: body;
const normalized = compactWhitespace(extracted);
const truncated = normalized.length > maxCharacters;
const text = truncated
? `${normalized.slice(0, maxCharacters)}\n\n[truncated ${normalized.length - maxCharacters} characters]`
: normalized;
return {
ok: true,
url,
status: response.status,
contentType: contentType || null,
title: isHtml ? extractHtmlTitle(body) : null,
truncated,
text,
};
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}.`);
}
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
const body = await response.text();
const isHtml = contentType.includes("text/html") || /<!doctype html|<html[\s>]/i.test(body);
let extracted = body;
if (isHtml) {
extracted = htmlToText(body, {
wordwrap: false,
preserveNewlines: true,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "script", format: "skip" },
{ selector: "style", format: "skip" },
{ selector: "noscript", format: "skip" },
{ selector: "a", options: { ignoreHref: true } },
],
});
}
const normalized = compactWhitespace(extracted);
const truncated = normalized.length > maxCharacters;
const text = truncated
? `${normalized.slice(0, maxCharacters)}\n\n[truncated ${normalized.length - maxCharacters} characters]`
: normalized;
return {
ok: true,
url: response.url || parsed.toString(),
status: response.status,
contentType: contentType || null,
title: isHtml ? extractHtmlTitle(body) : null,
truncated,
text,
};
}
function shellQuote(value: string) {
+1
View File
@@ -85,6 +85,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
chatId: call.chatId,
role: "assistant" as any,
content: outText,
metadata: { generation: { provider: req.provider, model: req.model } },
},
});
await tx.llmCall.update({
-100
View File
@@ -1,100 +0,0 @@
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { parentPort, workerData } from "node:worker_threads";
const { bytes, requestedPages, maxCharacters, maxPages } = workerData;
function isPasswordError(error) {
return Boolean(error && typeof error === "object" && error.name === "PasswordException");
}
async function extract() {
let loadingTask;
try {
loadingTask = getDocument({
data: bytes,
standardFontDataUrl: new URL("../../node_modules/pdfjs-dist/standard_fonts/", import.meta.url).href,
isEvalSupported: false,
disableFontFace: true,
stopAtErrors: true,
});
const document = await loadingTask.promise;
if (document.numPages === 0) {
return { status: "no_pages", pageCount: 0, pages: [], text: "This PDF has no pages.", truncated: false };
}
const pages = requestedPages?.length
? [...new Set(requestedPages)].slice(0, maxPages)
: Array.from({ length: Math.min(document.numPages, maxPages) }, (_, index) => index + 1);
const invalidPage = pages.find((page) => page > document.numPages);
if (invalidPage) {
return {
status: "error",
pageCount: document.numPages,
pages: [],
text: `Requested page ${invalidPage}, but this PDF has ${document.numPages} pages.`,
truncated: false,
};
}
const chunks = [];
let characterCount = 0;
let foundText = false;
let truncated = false;
const append = (text) => {
const remaining = maxCharacters - characterCount;
if (remaining <= 0) {
truncated = true;
return;
}
const part = text.slice(0, remaining);
chunks.push(part);
characterCount += part.length;
if (part.length < text.length) truncated = true;
};
for (const pageNumber of pages) {
append(`${chunks.length ? "\n\n" : ""}--- Page ${pageNumber} ---\n`);
if (truncated) break;
const page = await document.getPage(pageNumber);
const reader = page.streamTextContent().getReader();
try {
while (!truncated) {
const { done, value } = await reader.read();
if (done) break;
for (const item of value.items) {
if (!("str" in item) || !item.str) continue;
foundText = true;
append(item.str + (item.hasEOL ? "\n" : " "));
if (truncated) break;
}
}
} finally {
if (truncated) await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
if (truncated) break;
}
const text = chunks.join("").trimEnd();
if (!foundText) {
return {
status: "no_text",
pageCount: document.numPages,
pages,
text: "No extractable text was found in the selected pages.",
truncated: false,
};
}
return { status: "ok", pageCount: document.numPages, pages, text, truncated };
} catch (error) {
if (isPasswordError(error)) {
return { status: "encrypted", pageCount: null, pages: [], text: "This PDF is encrypted and requires a password.", truncated: false };
}
const message = error instanceof Error ? error.message : String(error);
return { status: "error", pageCount: null, pages: [], text: `PDF reading failed: ${message}`, truncated: false };
} finally {
await loadingTask?.destroy().catch(() => undefined);
}
}
parentPort?.postMessage(await extract());
-69
View File
@@ -1,69 +0,0 @@
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";
export const MAX_PDF_PAGES_PER_FETCH = 30;
export const PDF_PARSE_TIMEOUT_MS = 8_000;
const DEFAULT_PDF_TEXT_CHARACTERS = 12_000;
export type PdfTextResult =
| { status: "ok"; pageCount: number; pages: number[]; text: string; truncated: boolean }
| { status: "no_pages"; pageCount: 0; pages: []; text: "This PDF has no pages."; truncated: false }
| { status: "no_text"; pageCount: number; pages: number[]; text: "No extractable text was found in the selected pages."; truncated: false }
| { status: "encrypted"; pageCount: null; pages: []; text: "This PDF is encrypted and requires a password."; truncated: false }
| { status: "error"; pageCount: number | null; pages: number[]; text: string; truncated: false };
export function hasPdfMagic(bytes: Uint8Array) {
const headerEnd = Math.min(bytes.length, 1024);
return Buffer.from(bytes.subarray(0, headerEnd)).includes(Buffer.from("%PDF-"));
}
export function extractPdfText(
bytes: Uint8Array,
requestedPages?: number[],
maxCharacters = DEFAULT_PDF_TEXT_CHARACTERS,
timeoutMs = PDF_PARSE_TIMEOUT_MS,
): Promise<PdfTextResult> {
return new Promise((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout>;
let worker: Worker;
const finish = (result: PdfTextResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
void worker.terminate().catch(() => undefined);
resolve(result);
};
try {
worker = new Worker(fileURLToPath(new URL("./pdf-text-worker.mjs", import.meta.url)), {
workerData: { bytes: new Uint8Array(bytes), requestedPages, maxCharacters, maxPages: MAX_PDF_PAGES_PER_FETCH },
execArgv: [],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
resolve({ status: "error", pageCount: null, pages: [], text: `PDF reader could not start: ${message}`, truncated: false });
return;
}
timeout = setTimeout(() => {
finish({
status: "error",
pageCount: null,
pages: [],
text: `PDF reading exceeded its time limit (${timeoutMs} ms).`,
truncated: false,
});
}, timeoutMs);
worker.once("message", (result: PdfTextResult) => finish(result));
worker.once("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
finish({ status: "error", pageCount: null, pages: [], text: `PDF reading failed: ${message}`, truncated: false });
});
worker.once("exit", (code) => {
if (!settled) {
finish({ status: "error", pageCount: null, pages: [], text: `PDF reader exited unexpectedly (${code}).`, truncated: false });
}
});
});
}
+4 -1
View File
@@ -123,7 +123,10 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId,
role: "assistant" as any,
content: text,
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
metadata: {
...(req.clientRequestId ? { clientRequestId: req.clientRequestId } : {}),
generation: { provider: req.provider, model: req.model },
},
},
});
await tx.llmCall.update({
+31
View File
@@ -0,0 +1,31 @@
import { fromPrismaProvider } from "./llm/provider-ids.js";
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
type RecordedCall = {
provider: unknown;
model: string;
request: unknown;
error: string | null;
latencyMs: number | null;
};
/** Recover older streaming replies only through an unambiguous, completed request ID. */
export function withMessageGenerations<T extends { role: string; metadata: unknown }>(messages: T[], calls: RecordedCall[]): T[] {
const byRequest = new Map<string, { provider: string; model: string } | null>();
for (const call of calls) {
const requestId = record(call.request).clientRequestId;
if (typeof requestId !== "string" || !requestId || call.error !== null || call.latencyMs === null) continue;
const provider = fromPrismaProvider(call.provider);
if (!provider || !call.model.trim()) continue;
byRequest.set(requestId, byRequest.has(requestId) ? null : { provider, model: call.model });
}
return messages.map(message => {
const metadata = record(message.metadata);
if (message.role !== "assistant" || metadata.agentAside === true || metadata.generation !== undefined) return message;
const generation = typeof metadata.clientRequestId === "string" ? byRequest.get(metadata.clientRequestId) : null;
return generation ? { ...message, metadata: { ...metadata, generation } } : message;
});
}
+4 -2
View File
@@ -7,6 +7,7 @@ import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
import { prisma } from "./db.js";
import { withMessageGenerations } from "./message-generation.js";
import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js";
@@ -1173,11 +1174,12 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
enabledTools: true,
userId: true,
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
calls: { select: { provider: true, model: true, request: true, error: true, latencyMs: true } },
},
});
if (!source) return { status: "chat-not-found" as const };
let messages = source.messages;
let messages = withMessageGenerations(source.messages, source.calls);
let selectedMessage: (typeof source.messages)[number] | undefined;
if (parsed.data.messageId) {
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
@@ -1680,7 +1682,7 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeChatLike(chat) };
return { chat: serializeChatLike({ ...chat, messages: withMessageGenerations(chat.messages, chat.calls) }) };
});
app.post("/v1/chats/:chatId/messages", async (req) => {
-26
View File
@@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readBoundedResponseBytes } from "../src/llm/bounded-response.js";
const oneMiB = 1024 * 1024;
test("reads response bytes within the declared size limit", async () => {
const bytes = await readBoundedResponseBytes(new Response("small"), oneMiB);
assert.equal(new TextDecoder().decode(bytes), "small");
});
test("rejects oversized responses from Content-Length before buffering", async () => {
const response = new Response("x", { headers: { "content-length": String(oneMiB + 1) } });
await assert.rejects(readBoundedResponseBytes(response, oneMiB), /exceeds the 1 MiB download limit/);
});
test("rejects streamed responses when Content-Length is absent", async () => {
const response = new Response(new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(oneMiB));
controller.enqueue(new Uint8Array(1));
controller.close();
},
}));
await assert.rejects(readBoundedResponseBytes(response, oneMiB), /exceeds the 1 MiB download limit/);
});
+1 -100
View File
@@ -4,7 +4,6 @@ import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
import { createPdf } from "./pdf-fixture.js";
async function* streamFrom(events: any[]) {
for (const event of events) {
@@ -242,12 +241,10 @@ test("fetch_url sends browser-like navigation headers", async () => {
try {
let requestCount = 0;
const requestBodies: any[] = [];
const client = {
chat: {
completions: {
create: async (body: any) => {
requestBodies.push(body);
create: async () => {
requestCount += 1;
if (requestCount === 1) {
return {
@@ -298,108 +295,12 @@ test("fetch_url sends browser-like navigation headers", async () => {
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
});
const toolResult = requestBodies[1]?.messages?.find((message: any) => message.role === "tool");
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.match(fetched.text, /Consumer price index/);
assert.equal(fetched.title, "CPI");
assert.equal(result.toolEvents[0]?.status, "completed");
} finally {
globalThis.fetch = originalFetch;
}
});
test("fetch_url extracts a mislabeled PDF and returns page handles", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const pdf = createPdf(["Revenue is 42.", "Costs are 18."]);
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response(pdf, { status: 200, headers: { "content-type": "text/plain" } });
}) as typeof fetch;
try {
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestBodies.push(body);
if (requestBodies.length === 1) {
return {
content: [{
type: "tool_use",
id: "pdf_call",
name: "fetch_url",
input: { url: "https://example.com/report.pdf", pages: [2] },
}],
usage: { input_tokens: 1, output_tokens: 1 },
};
}
return { content: [{ type: "text", text: "Read page two" }], usage: { input_tokens: 1, output_tokens: 1 } };
},
},
};
const result = await completeWithMessagesApi({
client: client as any,
model: "claude-test",
messages: [{ role: "user", content: "Read the report" }],
});
const toolResult = requestBodies[1]?.messages?.at(-1)?.content?.[0];
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.equal(result.text, "Read page two");
assert.equal(fetchCalls.length, 1);
assert.equal(fetched.ok, true);
assert.equal(fetched.pdfStatus, "ok");
assert.equal(fetched.pageCount, 2);
assert.deepEqual(fetched.pages, [2]);
assert.equal(fetched.pagesTruncated, true);
assert.match(fetched.text, /--- Page 2 ---/);
assert.match(fetched.text, /Costs are 18/);
assert.doesNotMatch(fetched.text, /Revenue is 42/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("fetch_url marks encrypted and text-free PDFs as unavailable", async () => {
const originalFetch = globalThis.fetch;
let responseBytes = createPdf([""]);
globalThis.fetch = (async () => new Response(responseBytes, {
status: 200,
headers: { "content-type": "application/pdf" },
})) as typeof fetch;
try {
for (const [pdf, expectedStatus] of [[createPdf([""]), "no_text"], [createPdf(["Secret"], true), "encrypted"]] as const) {
responseBytes = pdf;
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestBodies.push(body);
if (requestBodies.length === 1) {
return {
content: [{ type: "tool_use", id: "pdf_status_call", name: "fetch_url", input: { url: "https://example.com/report.pdf" } }],
usage: { input_tokens: 1, output_tokens: 1 },
};
}
return { content: [{ type: "text", text: "Unavailable" }], usage: { input_tokens: 1, output_tokens: 1 } };
},
},
};
await completeWithMessagesApi({ client: client as any, model: "claude-test", messages: [{ role: "user", content: "Read PDF" }] });
const toolResult = requestBodies[1]?.messages?.at(-1)?.content?.[0];
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.equal(fetched.ok, false);
assert.equal(fetched.pdfStatus, expectedStatus);
assert.equal(toolResult?.is_error, true);
assert.ok(fetched.text.length > 0);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { withMessageGenerations } from "../src/message-generation.js";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-message-generation-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.ADMIN_TOKEN = "message-generation-test-token";
process.env.OPENAI_API_KEY = "synthetic-openai-key";
process.env.XAI_API_KEY = "synthetic-xai-key";
writeFileSync(join(databaseDir, "test.db"), "");
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
cwd: serverRoot, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" }, stdio: "pipe",
});
const [{ registerRoutes }, { prisma }, { runMultiplex }, { runMultiplexStream }] = await Promise.all([
import("../src/routes.js"), import("../src/db.js"), import("../src/llm/multiplexer.js"), import("../src/llm/streaming.js"),
]);
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
const auth = { authorization: "Bearer message-generation-test-token" };
test.after(async () => { await app.close(); await prisma.$disconnect(); rmSync(databaseDir, { recursive: true, force: true }); });
test("streaming and non-streaming replies retain their own model across selection changes and forks", async t => {
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
assert.ok(["https://api.openai.com/v1/chat/completions", "https://api.x.ai/v1/chat/completions"].includes(url), url);
const body = JSON.parse(input instanceof Request ? await input.text() : String(init?.body));
const content = `Reply from ${body.model}`;
return body.stream
? new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } })
: Response.json({ choices: [{ message: { role: "assistant", content } }] });
});
const chat = await prisma.chat.create({ data: { title: "Mixed models" } });
const messages = [{ role: "user" as const, content: "Answer" }];
const events = [];
for await (const event of runMultiplexStream({ chatId: chat.id, provider: "openai", model: "model-one", clientRequestId: "first-turn", messages, enabledTools: [] })) events.push(event);
assert.equal(events.at(-1)?.type, "done");
await runMultiplex({ chatId: chat.id, provider: "xai", model: "model-two", messages, enabledTools: [] });
await prisma.chat.update({ where: { id: chat.id }, data: { lastUsedProvider: "gemini", lastUsedModel: "model-next" } });
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
assert.equal(detail.statusCode, 200);
const saved = detail.json().chat.messages;
assert.deepEqual(saved.map((m: any) => m.metadata.generation), [{ provider: "openai", model: "model-one" }, { provider: "xai", model: "model-two" }]);
assert.equal(saved[0].metadata.clientRequestId, "first-turn");
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: { messageId: saved[1].id } });
assert.equal(fork.statusCode, 200);
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
assert.deepEqual(forkDetail.json().chat.messages.map((m: any) => m.metadata.generation), saved.map((m: any) => m.metadata.generation));
assert.equal(forkDetail.json().chat.messages[0].metadata.clientRequestId, undefined);
});
test("legacy call matches are projected without rewriting history and survive a new fork", async () => {
const chat = await prisma.chat.create({ data: {
title: "Older reply", lastUsedProvider: "gemini", lastUsedModel: "different-current-model",
messages: { create: { role: "assistant", content: "Older reply", metadata: { clientRequestId: "legacy-request", citations: ["https://example.test"] } } },
calls: { create: { provider: "anthropic", model: "original-model", request: { clientRequestId: "legacy-request" }, latencyMs: 10 } },
}, include: { messages: true } });
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
assert.deepEqual(detail.json().chat.messages[0].metadata.generation, { provider: "anthropic", model: "original-model" });
assert.deepEqual((await prisma.message.findUniqueOrThrow({ where: { id: chat.messages[0]!.id } })).metadata, chat.messages[0]!.metadata);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: {} });
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
assert.deepEqual(forkDetail.json().chat.messages[0].metadata, { citations: ["https://example.test"], generation: { provider: "anthropic", model: "original-model" } });
});
test("missing, ambiguous, failed and unfinished calls never guess a reply's model", () => {
const call = { provider: "openai", model: "model-one", request: { clientRequestId: "request" }, error: null, latencyMs: 10 };
const message = { role: "assistant", metadata: { clientRequestId: "request" } };
for (const calls of [[], [call, { ...call, provider: "xai" }], [{ ...call, error: "failed" }], [{ ...call, latencyMs: null }]]) {
assert.deepEqual(withMessageGenerations([message], calls), [message]);
}
const existing = { ...message, metadata: { ...message.metadata, generation: { provider: "gemini", model: "original" } } };
const aside = { ...message, metadata: { ...message.metadata, agentAside: true } };
const user = { ...message, role: "user" };
assert.deepEqual(withMessageGenerations([existing, aside, user], [call]), [existing, aside, user]);
});
-37
View File
@@ -1,37 +0,0 @@
export function createPdf(pages: string[], encrypted = false): Uint8Array {
const fontId = 3 + pages.length * 2;
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${pages.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] /Count ${pages.length} >>`,
];
for (let index = 0; index < pages.length; index += 1) {
const pageId = 3 + index * 2;
const contentId = pageId + 1;
const text = pages[index]!;
const operators = Array.from({ length: Math.ceil(text.length / 100) }, (_, partIndex) => {
const part = text.slice(partIndex * 100, (partIndex + 1) * 100);
const escaped = part.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
return `(${escaped}) Tj`;
});
const content = text ? `BT /F1 12 Tf 72 720 Td ${operators.join(" ")} ET` : "";
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${contentId} 0 R >>`,
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`,
);
}
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
const encryptionId = objects.length + 1;
if (encrypted) objects.push(`<< /Filter /Standard /V 1 /R 2 /O <${"00".repeat(32)}> /U <${"00".repeat(32)}> /P -4 >>`);
let output = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(output));
output += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xrefOffset = Buffer.byteLength(output);
output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) output += `${String(offset).padStart(10, "0")} 00000 n \n`;
output += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R${encrypted ? ` /Encrypt ${encryptionId} 0 R` : ""} >>\nstartxref\n${xrefOffset}\n%%EOF`;
return new TextEncoder().encode(output);
}
-63
View File
@@ -1,63 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractPdfText, hasPdfMagic } from "../src/llm/pdf-text.js";
import { createPdf } from "./pdf-fixture.js";
test("extracts selectable text with page markers and selected-page support", async () => {
const bytes = createPdf(["Quarterly revenue was 42.", "Costs were 18."]);
const result = await extractPdfText(bytes, [2]);
assert.equal(result.status, "ok");
assert.equal(result.pageCount, 2);
assert.deepEqual(result.pages, [2]);
assert.match(result.text, /--- Page 2 ---/);
assert.match(result.text, /Costs were 18\./);
assert.doesNotMatch(result.text, /Quarterly revenue/);
});
test("reports PDFs with no selectable text clearly", async () => {
const result = await extractPdfText(createPdf([""]));
assert.equal(result.status, "no_text");
assert.match(result.text, /No extractable text/);
});
test("reports encrypted PDFs clearly", async () => {
const result = await extractPdfText(createPdf(["Secret"], true));
assert.equal(result.status, "encrypted");
assert.match(result.text, /encrypted and requires a password/);
});
test("reports page-free PDFs and rejects invalid requested pages", async () => {
const empty = await extractPdfText(createPdf([]));
assert.equal(empty.status, "no_pages");
assert.match(empty.text, /no pages/);
const outOfRange = await extractPdfText(createPdf(["Only page"]), [2]);
assert.equal(outOfRange.status, "error");
assert.match(outOfRange.text, /has 1 pages/);
});
test("limits each extraction to 30 pages and preserves the full page count", async () => {
const result = await extractPdfText(createPdf(Array.from({ length: 31 }, (_, index) => `Text on page ${index + 1}.`)));
assert.equal(result.status, "ok");
assert.equal(result.pageCount, 31);
assert.equal(result.pages.length, 30);
assert.equal(result.pages.at(-1), 30);
});
test("caps accumulated extracted text at the caller limit", async () => {
const result = await extractPdfText(createPdf(["Long sentence. ".repeat(200)]), undefined, 50);
assert.equal(result.status, "ok");
assert.equal(result.truncated, true);
assert.ok(result.text.length <= 50);
});
test("terminates an over-time worker and returns an explicit timeout result", async () => {
const result = await extractPdfText(createPdf(["Small document"]), undefined, 12_000, 1);
assert.equal(result.status, "error");
assert.match(result.text, /time limit/);
});
test("recognizes PDF magic in a mislabeled or prefixed response", () => {
assert.equal(hasPdfMagic(new TextEncoder().encode("%PDF-1.4")), true);
assert.equal(hasPdfMagic(new TextEncoder().encode(`${" ".repeat(500)}%PDF-1.7`)), true);
assert.equal(hasPdfMagic(new TextEncoder().encode("<html>not a PDF</html>")), false);
});