@echofiles/echo-pdf 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/agent-defaults.d.ts +3 -0
- package/dist/agent-defaults.js +18 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +24 -0
- package/dist/core/index.d.ts +50 -0
- package/dist/core/index.js +7 -0
- package/dist/file-ops.d.ts +11 -0
- package/dist/file-ops.js +36 -0
- package/dist/file-store-do.d.ts +36 -0
- package/dist/file-store-do.js +298 -0
- package/dist/file-utils.d.ts +6 -0
- package/dist/file-utils.js +36 -0
- package/dist/http-error.d.ts +9 -0
- package/dist/http-error.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.js +127 -0
- package/dist/pdf-agent.d.ts +18 -0
- package/dist/pdf-agent.js +217 -0
- package/dist/pdf-config.d.ts +4 -0
- package/dist/pdf-config.js +130 -0
- package/dist/pdf-storage.d.ts +8 -0
- package/dist/pdf-storage.js +86 -0
- package/dist/pdf-types.d.ts +79 -0
- package/dist/pdf-types.js +1 -0
- package/dist/pdfium-engine.d.ts +9 -0
- package/dist/pdfium-engine.js +180 -0
- package/dist/provider-client.d.ts +12 -0
- package/dist/provider-client.js +134 -0
- package/dist/provider-keys.d.ts +10 -0
- package/dist/provider-keys.js +27 -0
- package/dist/r2-file-store.d.ts +20 -0
- package/dist/r2-file-store.js +176 -0
- package/dist/response-schema.d.ts +15 -0
- package/dist/response-schema.js +159 -0
- package/dist/tool-registry.d.ts +16 -0
- package/dist/tool-registry.js +175 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +366 -0
- package/package.json +22 -4
- package/wrangler.toml +1 -1
- package/src/agent-defaults.ts +0 -25
- package/src/file-ops.ts +0 -50
- package/src/file-store-do.ts +0 -349
- package/src/file-utils.ts +0 -43
- package/src/http-error.ts +0 -21
- package/src/index.ts +0 -415
- package/src/mcp-server.ts +0 -171
- package/src/pdf-agent.ts +0 -252
- package/src/pdf-config.ts +0 -143
- package/src/pdf-storage.ts +0 -109
- package/src/pdf-types.ts +0 -85
- package/src/pdfium-engine.ts +0 -207
- package/src/provider-client.ts +0 -176
- package/src/provider-keys.ts +0 -44
- package/src/r2-file-store.ts +0 -195
- package/src/response-schema.ts +0 -182
- package/src/tool-registry.ts +0 -203
- package/src/types.ts +0 -40
- package/src/wasm.d.ts +0 -4
package/src/pdfium-engine.ts
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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(/&/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
|
-
}
|
package/src/provider-client.ts
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
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
|
-
}
|
package/src/provider-keys.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
}
|
package/src/r2-file-store.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import type { StoragePolicy } from "./pdf-types"
|
|
2
|
-
import type { FileStore, StoredFileMeta, StoredFileRecord } from "./types"
|
|
3
|
-
|
|
4
|
-
const PREFIX = "file/"
|
|
5
|
-
|
|
6
|
-
type MetaFields = {
|
|
7
|
-
filename?: string
|
|
8
|
-
mimeType?: string
|
|
9
|
-
createdAt?: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const toId = (key: string): string => key.startsWith(PREFIX) ? key.slice(PREFIX.length) : key
|
|
13
|
-
const toKey = (id: string): string => `${PREFIX}${id}`
|
|
14
|
-
|
|
15
|
-
const parseCreatedAt = (value: string | undefined, fallback: Date): string => {
|
|
16
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
17
|
-
const ms = Date.parse(value)
|
|
18
|
-
if (Number.isFinite(ms)) return new Date(ms).toISOString()
|
|
19
|
-
}
|
|
20
|
-
return fallback.toISOString()
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const isExpired = (createdAtIso: string, ttlHours: number): boolean => {
|
|
24
|
-
const ms = Date.parse(createdAtIso)
|
|
25
|
-
if (!Number.isFinite(ms)) return false
|
|
26
|
-
return Date.now() - ms > ttlHours * 60 * 60 * 1000
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class R2FileStore implements FileStore {
|
|
30
|
-
constructor(
|
|
31
|
-
private readonly bucket: R2Bucket,
|
|
32
|
-
private readonly policy: StoragePolicy
|
|
33
|
-
) {}
|
|
34
|
-
|
|
35
|
-
async put(input: { readonly filename: string; readonly mimeType: string; readonly bytes: Uint8Array }): Promise<StoredFileMeta> {
|
|
36
|
-
const sizeBytes = input.bytes.byteLength
|
|
37
|
-
if (sizeBytes > this.policy.maxFileBytes) {
|
|
38
|
-
const err = new Error(`file too large: ${sizeBytes} bytes exceeds maxFileBytes ${this.policy.maxFileBytes}`)
|
|
39
|
-
;(err as { status?: number; code?: string; details?: unknown }).status = 413
|
|
40
|
-
;(err as { status?: number; code?: string; details?: unknown }).code = "FILE_TOO_LARGE"
|
|
41
|
-
;(err as { status?: number; code?: string; details?: unknown }).details = { policy: this.policy, sizeBytes }
|
|
42
|
-
throw err
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
await this.cleanupInternal(sizeBytes)
|
|
46
|
-
|
|
47
|
-
const id = crypto.randomUUID()
|
|
48
|
-
const createdAt = new Date().toISOString()
|
|
49
|
-
await this.bucket.put(toKey(id), input.bytes, {
|
|
50
|
-
httpMetadata: {
|
|
51
|
-
contentType: input.mimeType,
|
|
52
|
-
},
|
|
53
|
-
customMetadata: {
|
|
54
|
-
filename: input.filename,
|
|
55
|
-
mimeType: input.mimeType,
|
|
56
|
-
createdAt,
|
|
57
|
-
},
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
return { id, filename: input.filename, mimeType: input.mimeType, sizeBytes, createdAt }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async get(fileId: string): Promise<StoredFileRecord | null> {
|
|
64
|
-
const obj = await this.bucket.get(toKey(fileId))
|
|
65
|
-
if (!obj) return null
|
|
66
|
-
const meta = (obj.customMetadata ?? {}) as MetaFields
|
|
67
|
-
const createdAt = parseCreatedAt(meta.createdAt, obj.uploaded)
|
|
68
|
-
const filename = meta.filename ?? fileId
|
|
69
|
-
const mimeType = meta.mimeType ?? obj.httpMetadata?.contentType ?? "application/octet-stream"
|
|
70
|
-
const bytes = new Uint8Array(await obj.arrayBuffer())
|
|
71
|
-
return {
|
|
72
|
-
id: fileId,
|
|
73
|
-
filename,
|
|
74
|
-
mimeType,
|
|
75
|
-
sizeBytes: bytes.byteLength,
|
|
76
|
-
createdAt,
|
|
77
|
-
bytes,
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async list(): Promise<ReadonlyArray<StoredFileMeta>> {
|
|
82
|
-
return await this.listAllFiles()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async delete(fileId: string): Promise<boolean> {
|
|
86
|
-
await this.bucket.delete(toKey(fileId))
|
|
87
|
-
return true
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async stats(): Promise<unknown> {
|
|
91
|
-
const files = await this.listAllFiles()
|
|
92
|
-
const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0)
|
|
93
|
-
return {
|
|
94
|
-
backend: "r2",
|
|
95
|
-
policy: this.policy,
|
|
96
|
-
stats: {
|
|
97
|
-
fileCount: files.length,
|
|
98
|
-
totalBytes,
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async cleanup(): Promise<unknown> {
|
|
104
|
-
const files = await this.listAllFiles()
|
|
105
|
-
const expired = files.filter((f) => isExpired(f.createdAt, this.policy.ttlHours))
|
|
106
|
-
const active = files.filter((f) => !isExpired(f.createdAt, this.policy.ttlHours))
|
|
107
|
-
if (expired.length > 0) {
|
|
108
|
-
await this.bucket.delete(expired.map((f) => toKey(f.id)))
|
|
109
|
-
}
|
|
110
|
-
const evict = this.pickEvictions(active, 0)
|
|
111
|
-
if (evict.length > 0) {
|
|
112
|
-
await this.bucket.delete(evict.map((f) => toKey(f.id)))
|
|
113
|
-
}
|
|
114
|
-
const evictIds = new Set(evict.map((f) => f.id))
|
|
115
|
-
const after = active.filter((f) => !evictIds.has(f.id))
|
|
116
|
-
const totalBytes = after.reduce((sum, file) => sum + file.sizeBytes, 0)
|
|
117
|
-
return {
|
|
118
|
-
backend: "r2",
|
|
119
|
-
policy: this.policy,
|
|
120
|
-
deletedExpired: expired.length,
|
|
121
|
-
deletedEvicted: evict.length,
|
|
122
|
-
stats: {
|
|
123
|
-
fileCount: after.length,
|
|
124
|
-
totalBytes,
|
|
125
|
-
},
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private async cleanupInternal(incomingBytes: number): Promise<void> {
|
|
130
|
-
const files = await this.listAllFiles()
|
|
131
|
-
const expired = files.filter((f) => isExpired(f.createdAt, this.policy.ttlHours))
|
|
132
|
-
const active = files.filter((f) => !isExpired(f.createdAt, this.policy.ttlHours))
|
|
133
|
-
if (expired.length > 0) {
|
|
134
|
-
await this.bucket.delete(expired.map((f) => toKey(f.id)))
|
|
135
|
-
}
|
|
136
|
-
const evict = this.pickEvictions(active, incomingBytes)
|
|
137
|
-
if (evict.length > 0) {
|
|
138
|
-
await this.bucket.delete(evict.map((f) => toKey(f.id)))
|
|
139
|
-
}
|
|
140
|
-
const evictIds = new Set(evict.map((f) => f.id))
|
|
141
|
-
const remaining = active.filter((f) => !evictIds.has(f.id))
|
|
142
|
-
const finalTotal = remaining.reduce((sum, file) => sum + file.sizeBytes, 0)
|
|
143
|
-
if (finalTotal + incomingBytes > this.policy.maxTotalBytes) {
|
|
144
|
-
const err = new Error(
|
|
145
|
-
`storage quota exceeded: total ${finalTotal} + incoming ${incomingBytes} > maxTotalBytes ${this.policy.maxTotalBytes}`
|
|
146
|
-
)
|
|
147
|
-
;(err as { status?: number; code?: string; details?: unknown }).status = 507
|
|
148
|
-
;(err as { status?: number; code?: string; details?: unknown }).code = "STORAGE_QUOTA_EXCEEDED"
|
|
149
|
-
;(err as { status?: number; code?: string; details?: unknown }).details = { policy: this.policy, totalBytes: finalTotal, incomingBytes }
|
|
150
|
-
throw err
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private pickEvictions(files: ReadonlyArray<StoredFileMeta>, incomingBytes: number): StoredFileMeta[] {
|
|
155
|
-
const totalBytes = files.reduce((sum, f) => sum + f.sizeBytes, 0)
|
|
156
|
-
const projected = totalBytes + incomingBytes
|
|
157
|
-
if (projected <= this.policy.maxTotalBytes) return []
|
|
158
|
-
|
|
159
|
-
const needFree = projected - this.policy.maxTotalBytes
|
|
160
|
-
const candidates = [...files].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
161
|
-
const evict: StoredFileMeta[] = []
|
|
162
|
-
let freed = 0
|
|
163
|
-
for (const file of candidates) {
|
|
164
|
-
evict.push(file)
|
|
165
|
-
freed += file.sizeBytes
|
|
166
|
-
if (freed >= needFree) break
|
|
167
|
-
if (evict.length >= this.policy.cleanupBatchSize) break
|
|
168
|
-
}
|
|
169
|
-
return evict
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async listAllFiles(): Promise<StoredFileMeta[]> {
|
|
173
|
-
const files: StoredFileMeta[] = []
|
|
174
|
-
let cursor: string | undefined
|
|
175
|
-
while (true) {
|
|
176
|
-
const listed = await this.bucket.list({ prefix: PREFIX, limit: 1000, cursor })
|
|
177
|
-
for (const obj of listed.objects) {
|
|
178
|
-
const meta = (obj.customMetadata ?? {}) as MetaFields
|
|
179
|
-
const createdAt = parseCreatedAt(meta.createdAt, obj.uploaded)
|
|
180
|
-
const filename = meta.filename ?? toId(obj.key)
|
|
181
|
-
const mimeType = meta.mimeType ?? obj.httpMetadata?.contentType ?? "application/octet-stream"
|
|
182
|
-
files.push({
|
|
183
|
-
id: toId(obj.key),
|
|
184
|
-
filename,
|
|
185
|
-
mimeType,
|
|
186
|
-
sizeBytes: obj.size,
|
|
187
|
-
createdAt,
|
|
188
|
-
})
|
|
189
|
-
}
|
|
190
|
-
if (listed.truncated !== true || !listed.cursor) break
|
|
191
|
-
cursor = listed.cursor
|
|
192
|
-
}
|
|
193
|
-
return files
|
|
194
|
-
}
|
|
195
|
-
}
|