@echofiles/echo-pdf 0.2.0

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,207 @@
1
+ import { init } from "@embedpdf/pdfium"
2
+ import { encode as encodePng } from "@cf-wasm/png/workerd"
3
+ import type { WrappedPdfiumModule } from "@embedpdf/pdfium"
4
+ import type { EchoPdfConfig } from "./pdf-types"
5
+ import { toDataUrl } from "./file-utils"
6
+ import compiledPdfiumModule from "@embedpdf/pdfium/dist/pdfium.wasm"
7
+
8
+ let moduleInstance: WrappedPdfiumModule | null = null
9
+ let libraryInitialized = false
10
+
11
+ const toUint8 = (value: ArrayBuffer): Uint8Array => new Uint8Array(value)
12
+ const textDecoder = new TextDecoder()
13
+
14
+ const ensureWasmFunctionShim = (): void => {
15
+ const wasmApi = WebAssembly as unknown as {
16
+ Function?: unknown
17
+ }
18
+ if (typeof wasmApi.Function === "function") return
19
+ ;(wasmApi as { Function: (sig: unknown, fn: unknown) => unknown }).Function = (
20
+ _sig: unknown,
21
+ fn: unknown
22
+ ) => fn
23
+ }
24
+
25
+ const ensurePdfium = async (config: EchoPdfConfig): Promise<WrappedPdfiumModule> => {
26
+ ensureWasmFunctionShim()
27
+ if (!moduleInstance) {
28
+ const maybeModule = compiledPdfiumModule as unknown
29
+ if (maybeModule instanceof WebAssembly.Module) {
30
+ moduleInstance = await init({
31
+ instantiateWasm: (
32
+ imports: WebAssembly.Imports,
33
+ successCallback: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void
34
+ ): WebAssembly.Exports => {
35
+ const instance = new WebAssembly.Instance(maybeModule, imports)
36
+ successCallback(instance, maybeModule)
37
+ return instance.exports
38
+ },
39
+ })
40
+ } else {
41
+ const wasmBinary = await fetch(config.pdfium.wasmUrl).then((res) => res.arrayBuffer())
42
+ moduleInstance = await init({ wasmBinary })
43
+ }
44
+ }
45
+ if (!libraryInitialized) {
46
+ moduleInstance.FPDF_InitLibrary()
47
+ libraryInitialized = true
48
+ }
49
+ return moduleInstance
50
+ }
51
+
52
+ const makeDoc = (pdfium: WrappedPdfiumModule, bytes: Uint8Array): {
53
+ readonly doc: number
54
+ readonly memPtr: number
55
+ } => {
56
+ const memPtr = pdfium.pdfium.wasmExports.malloc(bytes.length)
57
+ ;(pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8.set(bytes, memPtr)
58
+ const doc = pdfium.FPDF_LoadMemDocument(memPtr, bytes.length, "")
59
+ if (!doc) {
60
+ pdfium.pdfium.wasmExports.free(memPtr)
61
+ throw new Error("Failed to load PDF document")
62
+ }
63
+ return { doc, memPtr }
64
+ }
65
+
66
+ const closeDoc = (pdfium: WrappedPdfiumModule, doc: number, memPtr: number): void => {
67
+ pdfium.FPDF_CloseDocument(doc)
68
+ pdfium.pdfium.wasmExports.free(memPtr)
69
+ }
70
+
71
+ const bgraToRgba = (bgra: Uint8Array): Uint8Array => {
72
+ const rgba = new Uint8Array(bgra.length)
73
+ for (let i = 0; i < bgra.length; i += 4) {
74
+ rgba[i] = bgra[i + 2] ?? 0
75
+ rgba[i + 1] = bgra[i + 1] ?? 0
76
+ rgba[i + 2] = bgra[i] ?? 0
77
+ rgba[i + 3] = bgra[i + 3] ?? 255
78
+ }
79
+ return rgba
80
+ }
81
+
82
+ const decodeUtf16Le = (buf: Uint8Array): string => {
83
+ const view = new Uint16Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 2))
84
+ const chars: number[] = []
85
+ for (const code of view) {
86
+ if (code === 0) break
87
+ chars.push(code)
88
+ }
89
+ return String.fromCharCode(...chars)
90
+ }
91
+
92
+ export const getPdfPageCount = async (config: EchoPdfConfig, bytes: Uint8Array): Promise<number> => {
93
+ const pdfium = await ensurePdfium(config)
94
+ const { doc, memPtr } = makeDoc(pdfium, bytes)
95
+ try {
96
+ return pdfium.FPDF_GetPageCount(doc)
97
+ } finally {
98
+ closeDoc(pdfium, doc, memPtr)
99
+ }
100
+ }
101
+
102
+ export const renderPdfPageToPng = async (
103
+ config: EchoPdfConfig,
104
+ bytes: Uint8Array,
105
+ pageIndex: number,
106
+ scale = config.service.defaultRenderScale
107
+ ): Promise<{
108
+ width: number
109
+ height: number
110
+ png: Uint8Array
111
+ }> => {
112
+ const pdfium = await ensurePdfium(config)
113
+ const { doc, memPtr } = makeDoc(pdfium, bytes)
114
+ let page = 0
115
+ let bitmap = 0
116
+ try {
117
+ page = pdfium.FPDF_LoadPage(doc, pageIndex)
118
+ if (!page) {
119
+ throw new Error(`Failed to load page ${pageIndex}`)
120
+ }
121
+ const width = Math.max(1, Math.round(pdfium.FPDF_GetPageWidthF(page) * scale))
122
+ const height = Math.max(1, Math.round(pdfium.FPDF_GetPageHeightF(page) * scale))
123
+ bitmap = pdfium.FPDFBitmap_Create(width, height, 1)
124
+ if (!bitmap) {
125
+ throw new Error("Failed to create bitmap")
126
+ }
127
+ pdfium.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, 0xffffffff)
128
+ pdfium.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, 0)
129
+
130
+ const stride = pdfium.FPDFBitmap_GetStride(bitmap)
131
+ const bufferPtr = pdfium.FPDFBitmap_GetBuffer(bitmap)
132
+ const heap = (pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8
133
+ const bgra = heap.slice(bufferPtr, bufferPtr + stride * height)
134
+ const rgba = bgraToRgba(bgra)
135
+ const png = encodePng(rgba, width, height)
136
+ return { width, height, png }
137
+ } finally {
138
+ if (bitmap) pdfium.FPDFBitmap_Destroy(bitmap)
139
+ if (page) pdfium.FPDF_ClosePage(page)
140
+ closeDoc(pdfium, doc, memPtr)
141
+ }
142
+ }
143
+
144
+ export const extractPdfPageText = async (
145
+ config: EchoPdfConfig,
146
+ bytes: Uint8Array,
147
+ pageIndex: number
148
+ ): Promise<string> => {
149
+ const pdfium = await ensurePdfium(config)
150
+ const { doc, memPtr } = makeDoc(pdfium, bytes)
151
+ let page = 0
152
+ let textPage = 0
153
+ let outPtr = 0
154
+ try {
155
+ page = pdfium.FPDF_LoadPage(doc, pageIndex)
156
+ if (!page) {
157
+ throw new Error(`Failed to load page ${pageIndex}`)
158
+ }
159
+ textPage = pdfium.FPDFText_LoadPage(page)
160
+ if (!textPage) return ""
161
+ const chars = pdfium.FPDFText_CountChars(textPage)
162
+ if (chars <= 0) return ""
163
+ const bytesLen = (chars + 1) * 2
164
+ outPtr = pdfium.pdfium.wasmExports.malloc(bytesLen)
165
+ pdfium.FPDFText_GetText(textPage, 0, chars, outPtr)
166
+ const heap = (pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8
167
+ const raw = heap.slice(outPtr, outPtr + bytesLen)
168
+ return decodeUtf16Le(raw).trim()
169
+ } finally {
170
+ if (outPtr) pdfium.pdfium.wasmExports.free(outPtr)
171
+ if (textPage) pdfium.FPDFText_ClosePage(textPage)
172
+ if (page) pdfium.FPDF_ClosePage(page)
173
+ closeDoc(pdfium, doc, memPtr)
174
+ }
175
+ }
176
+
177
+ export const toBytes = async (value: string): Promise<Uint8Array> => {
178
+ const response = await fetch(value)
179
+ if (!response.ok) {
180
+ throw new Error(`Failed to fetch source: HTTP ${response.status}`)
181
+ }
182
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase()
183
+ const bytes = toUint8(await response.arrayBuffer())
184
+ const signature = textDecoder.decode(bytes.subarray(0, Math.min(8, bytes.length)))
185
+
186
+ if (contentType.includes("application/pdf") || signature.startsWith("%PDF-")) {
187
+ return bytes
188
+ }
189
+
190
+ const html = textDecoder.decode(bytes)
191
+ const pdfMatch = html.match(/https?:\/\/[^"' )]+\.pdf[^"' )]*/i)
192
+ if (!pdfMatch || pdfMatch.length === 0) {
193
+ throw new Error("URL does not point to a PDF and no PDF link was found in the page")
194
+ }
195
+
196
+ const resolvedUrl = pdfMatch[0].replace(/&amp;/g, "&")
197
+ const pdfResponse = await fetch(resolvedUrl)
198
+ if (!pdfResponse.ok) {
199
+ throw new Error(`Failed to fetch resolved PDF url: HTTP ${pdfResponse.status}`)
200
+ }
201
+ const pdfBytes = toUint8(await pdfResponse.arrayBuffer())
202
+ const pdfSignature = textDecoder.decode(pdfBytes.subarray(0, Math.min(8, pdfBytes.length)))
203
+ if (!pdfSignature.startsWith("%PDF-")) {
204
+ throw new Error("Resolved file is not a valid PDF")
205
+ }
206
+ return pdfBytes
207
+ }
@@ -0,0 +1,176 @@
1
+ import type { Env } from "./types"
2
+ import type { EchoPdfConfig, EchoPdfProviderConfig } from "./pdf-types"
3
+ import { resolveProviderApiKey } from "./provider-keys"
4
+
5
+ const defaultBaseUrl = (provider: EchoPdfProviderConfig): string => {
6
+ if (provider.baseUrl) return provider.baseUrl
7
+ switch (provider.type) {
8
+ case "openrouter":
9
+ return "https://openrouter.ai/api/v1"
10
+ case "vercel-ai-gateway":
11
+ return "https://ai-gateway.vercel.sh/v1"
12
+ case "openai":
13
+ default:
14
+ return "https://api.openai.com/v1"
15
+ }
16
+ }
17
+
18
+ const noTrailingSlash = (url: string): string => url.replace(/\/+$/, "")
19
+
20
+ const resolveEndpoint = (
21
+ provider: EchoPdfProviderConfig,
22
+ kind: "chatCompletionsPath" | "modelsPath"
23
+ ): string => {
24
+ const configured = provider.endpoints?.[kind]
25
+ if (configured?.startsWith("http://") || configured?.startsWith("https://")) {
26
+ return configured
27
+ }
28
+ const fallback = kind === "chatCompletionsPath" ? "/chat/completions" : "/models"
29
+ const path = configured && configured.length > 0 ? configured : fallback
30
+ return `${noTrailingSlash(defaultBaseUrl(provider))}${path.startsWith("/") ? path : `/${path}`}`
31
+ }
32
+
33
+ const toAuthHeader = (
34
+ config: EchoPdfConfig,
35
+ providerAlias: string,
36
+ provider: EchoPdfProviderConfig,
37
+ env: Env,
38
+ runtimeApiKeys?: Record<string, string>
39
+ ): Record<string, string> => {
40
+ const token = resolveProviderApiKey({
41
+ config,
42
+ env,
43
+ providerAlias,
44
+ provider,
45
+ runtimeApiKeys,
46
+ })
47
+ return { Authorization: `Bearer ${token}` }
48
+ }
49
+
50
+ const withTimeout = async (
51
+ url: string,
52
+ init: RequestInit,
53
+ timeoutMs: number
54
+ ): Promise<Response> => {
55
+ const ctrl = new AbortController()
56
+ const timer = setTimeout(() => ctrl.abort("timeout"), timeoutMs)
57
+ try {
58
+ return await fetch(url, { ...init, signal: ctrl.signal })
59
+ } catch (error) {
60
+ if (error instanceof Error && error.name === "AbortError") {
61
+ throw new Error(`Request timeout after ${timeoutMs}ms for ${url}`)
62
+ }
63
+ throw error
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ }
68
+
69
+ const responseDetail = async (response: Response): Promise<string> => {
70
+ const contentType = response.headers.get("content-type") ?? ""
71
+ try {
72
+ if (contentType.includes("application/json")) {
73
+ return JSON.stringify(await response.json()).slice(0, 800)
74
+ }
75
+ return (await response.text()).slice(0, 800)
76
+ } catch {
77
+ return "<unable to parse response payload>"
78
+ }
79
+ }
80
+
81
+ const getProvider = (config: EchoPdfConfig, alias: string): EchoPdfProviderConfig => {
82
+ const provider = config.providers[alias]
83
+ if (!provider) {
84
+ throw new Error(`Provider "${alias}" not configured`)
85
+ }
86
+ return provider
87
+ }
88
+
89
+ export const listProviderModels = async (
90
+ config: EchoPdfConfig,
91
+ env: Env,
92
+ alias: string,
93
+ runtimeApiKeys?: Record<string, string>
94
+ ): Promise<ReadonlyArray<string>> => {
95
+ const provider = getProvider(config, alias)
96
+ const url = resolveEndpoint(provider, "modelsPath")
97
+ const response = await withTimeout(
98
+ url,
99
+ {
100
+ method: "GET",
101
+ headers: {
102
+ Accept: "application/json",
103
+ ...toAuthHeader(config, alias, provider, env, runtimeApiKeys),
104
+ ...(provider.headers ?? {}),
105
+ },
106
+ },
107
+ provider.timeoutMs ?? 30000
108
+ )
109
+
110
+ if (!response.ok) {
111
+ throw new Error(`Model list request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`)
112
+ }
113
+
114
+ const payload = await response.json()
115
+ const data = (payload as { data?: unknown }).data
116
+ if (!Array.isArray(data)) return []
117
+ return data
118
+ .map((item) => item as { id?: unknown })
119
+ .map((item) => (typeof item.id === "string" ? item.id : ""))
120
+ .filter((id) => id.length > 0)
121
+ }
122
+
123
+ export const visionRecognize = async (input: {
124
+ config: EchoPdfConfig
125
+ env: Env
126
+ providerAlias: string
127
+ model: string
128
+ prompt: string
129
+ imageDataUrl: string
130
+ runtimeApiKeys?: Record<string, string>
131
+ }): Promise<string> => {
132
+ const provider = getProvider(input.config, input.providerAlias)
133
+ const url = resolveEndpoint(provider, "chatCompletionsPath")
134
+ const response = await withTimeout(
135
+ url,
136
+ {
137
+ method: "POST",
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ ...toAuthHeader(input.config, input.providerAlias, provider, input.env, input.runtimeApiKeys),
141
+ ...(provider.headers ?? {}),
142
+ },
143
+ body: JSON.stringify({
144
+ model: input.model,
145
+ messages: [
146
+ {
147
+ role: "user",
148
+ content: [
149
+ { type: "text", text: input.prompt },
150
+ { type: "image_url", image_url: { url: input.imageDataUrl } },
151
+ ],
152
+ },
153
+ ],
154
+ }),
155
+ },
156
+ provider.timeoutMs ?? 30000
157
+ )
158
+
159
+ if (!response.ok) {
160
+ throw new Error(`Vision request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`)
161
+ }
162
+
163
+ const payload = await response.json()
164
+ const message = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message
165
+ if (!message) return ""
166
+ const content = message.content
167
+ if (typeof content === "string") return content
168
+ if (Array.isArray(content)) {
169
+ return content
170
+ .map((part) => part as { type?: string; text?: string })
171
+ .filter((part) => part.type === "text" && typeof part.text === "string")
172
+ .map((part) => part.text ?? "")
173
+ .join("")
174
+ }
175
+ return ""
176
+ }
@@ -0,0 +1,44 @@
1
+ import { readRequiredEnv } from "./pdf-config"
2
+ import type { EchoPdfConfig, EchoPdfProviderConfig } from "./pdf-types"
3
+ import type { Env } from "./types"
4
+
5
+ const normalizeKey = (value: string): string => value.trim()
6
+
7
+ const keyVariants = (value: string): string[] => {
8
+ const raw = normalizeKey(value)
9
+ if (raw.length === 0) return []
10
+ return Array.from(
11
+ new Set([
12
+ raw,
13
+ raw.replace(/-/g, "_"),
14
+ raw.replace(/_/g, "-"),
15
+ ])
16
+ )
17
+ }
18
+
19
+ export const runtimeProviderKeyCandidates = (
20
+ _config: EchoPdfConfig,
21
+ providerAlias: string,
22
+ provider: EchoPdfProviderConfig
23
+ ): string[] => {
24
+ const aliases = keyVariants(providerAlias)
25
+ const types = keyVariants(provider.type)
26
+ return Array.from(new Set([...aliases, ...types]))
27
+ }
28
+
29
+ export const resolveProviderApiKey = (input: {
30
+ config: EchoPdfConfig
31
+ env: Env
32
+ providerAlias: string
33
+ provider: EchoPdfProviderConfig
34
+ runtimeApiKeys?: Record<string, string>
35
+ }): string => {
36
+ const candidates = runtimeProviderKeyCandidates(input.config, input.providerAlias, input.provider)
37
+ for (const candidate of candidates) {
38
+ const value = input.runtimeApiKeys?.[candidate]
39
+ if (typeof value === "string" && value.trim().length > 0) {
40
+ return value.trim()
41
+ }
42
+ }
43
+ return readRequiredEnv(input.env, input.provider.apiKeyEnv)
44
+ }
@@ -0,0 +1,203 @@
1
+ import { normalizeReturnMode } from "./file-utils"
2
+ import { runFileOp } from "./file-ops"
3
+ import { runPdfAgent } from "./pdf-agent"
4
+ import type { EchoPdfConfig, PdfOperationRequest, ToolSchema } from "./pdf-types"
5
+ import type { Env, FileStore, JsonObject } from "./types"
6
+
7
+ export interface ToolRuntimeContext {
8
+ readonly config: EchoPdfConfig
9
+ readonly env: Env
10
+ readonly fileStore: FileStore
11
+ readonly providerApiKeys?: Record<string, string>
12
+ readonly trace?: (event: { kind: "step"; phase: "start" | "end" | "log"; name: string; payload?: unknown }) => void
13
+ }
14
+
15
+ interface ToolDefinition {
16
+ readonly schema: ToolSchema
17
+ run: (ctx: ToolRuntimeContext, args: JsonObject) => Promise<unknown>
18
+ }
19
+
20
+ const asNumberArray = (value: unknown): number[] =>
21
+ Array.isArray(value) ? value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0) : []
22
+
23
+ const asObject = (value: unknown): JsonObject =>
24
+ typeof value === "object" && value !== null && !Array.isArray(value)
25
+ ? (value as JsonObject)
26
+ : {}
27
+
28
+ const readString = (obj: JsonObject, key: string): string | undefined => {
29
+ const value = obj[key]
30
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
31
+ }
32
+
33
+ const toolDefinitions: ReadonlyArray<ToolDefinition> = [
34
+ {
35
+ schema: {
36
+ name: "pdf_extract_pages",
37
+ description: "Render specific PDF pages to image and return inline/file_id/url mode.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ fileId: { type: "string" },
42
+ url: { type: "string" },
43
+ base64: { type: "string" },
44
+ filename: { type: "string" },
45
+ pages: { type: "array", items: { type: "integer" } },
46
+ renderScale: { type: "number" },
47
+ returnMode: { type: "string", enum: ["inline", "file_id"] },
48
+ },
49
+ required: ["pages"],
50
+ },
51
+ source: { kind: "local", toolName: "pdf.extract_pages" },
52
+ },
53
+ run: async (ctx, args) => {
54
+ const req: PdfOperationRequest = {
55
+ operation: "extract_pages",
56
+ fileId: readString(args, "fileId"),
57
+ url: readString(args, "url"),
58
+ base64: readString(args, "base64"),
59
+ filename: readString(args, "filename"),
60
+ pages: asNumberArray(args.pages),
61
+ renderScale: typeof args.renderScale === "number" ? args.renderScale : undefined,
62
+ provider: undefined,
63
+ model: "not-required",
64
+ providerApiKeys: ctx.providerApiKeys,
65
+ returnMode: normalizeReturnMode(args.returnMode),
66
+ }
67
+ return runPdfAgent(ctx.config, ctx.env, req, {
68
+ fileStore: ctx.fileStore,
69
+ trace: ctx.trace,
70
+ })
71
+ },
72
+ },
73
+ {
74
+ schema: {
75
+ name: "pdf_ocr_pages",
76
+ description: "OCR specific PDF pages using configured multimodal model.",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ fileId: { type: "string" },
81
+ url: { type: "string" },
82
+ base64: { type: "string" },
83
+ filename: { type: "string" },
84
+ pages: { type: "array", items: { type: "integer" } },
85
+ renderScale: { type: "number" },
86
+ provider: { type: "string" },
87
+ model: { type: "string" },
88
+ prompt: { type: "string" },
89
+ },
90
+ required: ["pages"],
91
+ },
92
+ source: { kind: "local", toolName: "pdf.ocr_pages" },
93
+ },
94
+ run: async (ctx, args) => {
95
+ const req: PdfOperationRequest = {
96
+ operation: "ocr_pages",
97
+ fileId: readString(args, "fileId"),
98
+ url: readString(args, "url"),
99
+ base64: readString(args, "base64"),
100
+ filename: readString(args, "filename"),
101
+ pages: asNumberArray(args.pages),
102
+ renderScale: typeof args.renderScale === "number" ? args.renderScale : undefined,
103
+ provider: readString(args, "provider"),
104
+ model: readString(args, "model") ?? "",
105
+ prompt: readString(args, "prompt"),
106
+ providerApiKeys: ctx.providerApiKeys,
107
+ returnMode: "inline",
108
+ }
109
+ return runPdfAgent(ctx.config, ctx.env, req, {
110
+ fileStore: ctx.fileStore,
111
+ trace: ctx.trace,
112
+ })
113
+ },
114
+ },
115
+ {
116
+ schema: {
117
+ name: "pdf_tables_to_latex",
118
+ description: "Recognize tables from pages and return LaTeX tabular output.",
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ fileId: { type: "string" },
123
+ url: { type: "string" },
124
+ base64: { type: "string" },
125
+ filename: { type: "string" },
126
+ pages: { type: "array", items: { type: "integer" } },
127
+ renderScale: { type: "number" },
128
+ provider: { type: "string" },
129
+ model: { type: "string" },
130
+ prompt: { type: "string" },
131
+ },
132
+ required: ["pages"],
133
+ },
134
+ source: { kind: "local", toolName: "pdf.tables_to_latex" },
135
+ },
136
+ run: async (ctx, args) => {
137
+ const req: PdfOperationRequest = {
138
+ operation: "tables_to_latex",
139
+ fileId: readString(args, "fileId"),
140
+ url: readString(args, "url"),
141
+ base64: readString(args, "base64"),
142
+ filename: readString(args, "filename"),
143
+ pages: asNumberArray(args.pages),
144
+ renderScale: typeof args.renderScale === "number" ? args.renderScale : undefined,
145
+ provider: readString(args, "provider"),
146
+ model: readString(args, "model") ?? "",
147
+ prompt: readString(args, "prompt"),
148
+ providerApiKeys: ctx.providerApiKeys,
149
+ returnMode: "inline",
150
+ }
151
+ return runPdfAgent(ctx.config, ctx.env, req, {
152
+ fileStore: ctx.fileStore,
153
+ trace: ctx.trace,
154
+ })
155
+ },
156
+ },
157
+ {
158
+ schema: {
159
+ name: "file_ops",
160
+ description: "Basic file operations: list/read/delete/put for runtime file store.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ op: { type: "string", enum: ["list", "read", "delete", "put"] },
165
+ fileId: { type: "string" },
166
+ includeBase64: { type: "boolean" },
167
+ text: { type: "string" },
168
+ filename: { type: "string" },
169
+ mimeType: { type: "string" },
170
+ base64: { type: "string" },
171
+ returnMode: { type: "string", enum: ["inline", "file_id"] },
172
+ },
173
+ required: ["op"],
174
+ },
175
+ source: { kind: "local", toolName: "file.ops" },
176
+ },
177
+ run: async (ctx, args) =>
178
+ runFileOp(ctx.fileStore, {
179
+ op: (readString(args, "op") as "list" | "read" | "delete" | "put") ?? "list",
180
+ fileId: readString(args, "fileId"),
181
+ includeBase64: Boolean(args.includeBase64),
182
+ text: readString(args, "text"),
183
+ filename: readString(args, "filename"),
184
+ mimeType: readString(args, "mimeType"),
185
+ base64: readString(args, "base64"),
186
+ returnMode: normalizeReturnMode(args.returnMode),
187
+ }),
188
+ },
189
+ ]
190
+
191
+ export const listToolSchemas = (): ReadonlyArray<ToolSchema> => toolDefinitions.map((item) => item.schema)
192
+
193
+ export const callTool = async (
194
+ name: string,
195
+ args: unknown,
196
+ ctx: ToolRuntimeContext
197
+ ): Promise<unknown> => {
198
+ const definition = toolDefinitions.find((item) => item.schema.name === name)
199
+ if (!definition) {
200
+ throw new Error(`Unknown tool: ${name}`)
201
+ }
202
+ return definition.run(ctx, asObject(args))
203
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ export type JsonPrimitive = string | number | boolean | null
2
+ export type JsonValue = JsonPrimitive | JsonObject | JsonArray
3
+ export type JsonArray = JsonValue[]
4
+ export interface JsonObject {
5
+ [key: string]: JsonValue
6
+ }
7
+
8
+ export type ProviderType = "openai" | "openrouter" | "vercel-ai-gateway"
9
+ export type ReturnMode = "inline" | "file_id" | "url"
10
+
11
+ export interface Env {
12
+ readonly ECHO_PDF_CONFIG_JSON?: string
13
+ readonly ASSETS?: Fetcher
14
+ readonly FILE_STORE_DO?: DurableObjectNamespace
15
+ readonly [key: string]: string | Fetcher | DurableObjectNamespace | undefined
16
+ }
17
+
18
+ export interface StoredFileMeta {
19
+ readonly id: string
20
+ readonly filename: string
21
+ readonly mimeType: string
22
+ readonly sizeBytes: number
23
+ readonly createdAt: string
24
+ }
25
+
26
+ export interface StoredFileRecord extends StoredFileMeta {
27
+ readonly bytes: Uint8Array
28
+ }
29
+
30
+ export interface FileStore {
31
+ put(input: {
32
+ readonly filename: string
33
+ readonly mimeType: string
34
+ readonly bytes: Uint8Array
35
+ }): Promise<StoredFileMeta>
36
+ get(fileId: string): Promise<StoredFileRecord | null>
37
+ list(): Promise<ReadonlyArray<StoredFileMeta>>
38
+ delete(fileId: string): Promise<boolean>
39
+ }
package/src/wasm.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "*.wasm" {
2
+ const wasmModule: WebAssembly.Module
3
+ export default wasmModule
4
+ }
package/wrangler.toml ADDED
@@ -0,0 +1,15 @@
1
+ name = "echo-pdf"
2
+ main = "src/index.ts"
3
+ compatibility_date = "2026-03-06"
4
+
5
+ [assets]
6
+ directory = "./assets"
7
+ binding = "ASSETS"
8
+
9
+ [[durable_objects.bindings]]
10
+ name = "FILE_STORE_DO"
11
+ class_name = "FileStoreDO"
12
+
13
+ [[migrations]]
14
+ tag = "v1"
15
+ new_sqlite_classes = ["FileStoreDO"]