@echofiles/echo-pdf 0.6.0 → 0.8.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.
package/README.md CHANGED
@@ -68,6 +68,16 @@ echo-pdf semantic ./sample.pdf
68
68
 
69
69
  `echo-pdf semantic` now uses the CLI profile's provider/model/api-key settings. If the selected provider or model is missing, it fails early with a clear setup error instead of quietly dropping back to a weaker path.
70
70
 
71
+ For a local OpenAI-compatible LLM server, point a provider at `http://localhost:...` and leave `apiKeyEnv` empty in `echo-pdf.config.json`. Then configure the CLI profile without a dummy key:
72
+
73
+ ```bash
74
+ echo-pdf provider set --provider ollama --api-key ""
75
+ echo-pdf model set --provider ollama --model llava:13b
76
+ echo-pdf semantic ./sample.pdf --provider ollama
77
+ ```
78
+
79
+ This works for local OpenAI-compatible servers such as Ollama, llama.cpp, vLLM, LM Studio, or LocalAI, as long as the selected model supports vision input.
80
+
71
81
  What these commands map to:
72
82
 
73
83
  - `document` -> `get_document`
@@ -75,6 +85,8 @@ What these commands map to:
75
85
  - `semantic` -> `get_semantic_document_structure`
76
86
  - `page` -> `get_page_content`
77
87
  - `render` -> `get_page_render`
88
+ - `tables` -> `get_page_tables_latex`
89
+ - `formulas` -> `get_page_formulas_latex`
78
90
 
79
91
  By default, `echo-pdf` writes reusable artifacts into a local workspace:
80
92
 
@@ -91,6 +103,10 @@ By default, `echo-pdf` writes reusable artifacts into a local workspace:
91
103
  renders/
92
104
  0001.scale-2.json
93
105
  0001.scale-2.png
106
+ tables/
107
+ 0001.scale-2.provider-openai.model-gpt-4.1-mini.prompt-<hash>.json
108
+ formulas/
109
+ 0001.scale-2.provider-openai.model-gpt-4.1-mini.prompt-<hash>.json
94
110
  ```
95
111
 
96
112
  These artifacts are meant to be inspected, cached, and reused by downstream local tools.
@@ -105,13 +121,21 @@ import {
105
121
  get_semantic_document_structure,
106
122
  get_page_content,
107
123
  get_page_render,
124
+ get_page_tables_latex,
125
+ get_page_formulas_latex,
108
126
  } from "@echofiles/echo-pdf/local"
109
127
 
110
128
  const document = await get_document({ pdfPath: "./sample.pdf" })
111
129
  const structure = await get_document_structure({ pdfPath: "./sample.pdf" })
112
- const semantic = await get_semantic_document_structure({ pdfPath: "./sample.pdf" })
130
+ const semantic = await get_semantic_document_structure({
131
+ pdfPath: "./sample.pdf",
132
+ provider: "openai",
133
+ model: "gpt-4.1-mini",
134
+ })
113
135
  const page1 = await get_page_content({ pdfPath: "./sample.pdf", pageNumber: 1 })
114
136
  const render1 = await get_page_render({ pdfPath: "./sample.pdf", pageNumber: 1, scale: 2 })
137
+ const tables = await get_page_tables_latex({ pdfPath: "./sample.pdf", pageNumber: 1, provider: "openai", model: "gpt-4.1-mini" })
138
+ const formulas = await get_page_formulas_latex({ pdfPath: "./sample.pdf", pageNumber: 1, provider: "openai", model: "gpt-4.1-mini" })
115
139
  ```
116
140
 
117
141
  Notes:
package/bin/echo-pdf.js CHANGED
@@ -9,24 +9,26 @@ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
10
  const PROJECT_CONFIG_FILE = path.resolve(__dirname, "../echo-pdf.config.json")
11
11
  const PROJECT_CONFIG = JSON.parse(fs.readFileSync(PROJECT_CONFIG_FILE, "utf-8"))
12
- const PROVIDER_ENTRIES = Object.entries(PROJECT_CONFIG.providers || {})
13
- const PROVIDER_ALIASES = PROVIDER_ENTRIES.map(([alias]) => alias)
14
- const PROVIDER_ALIAS_BY_TYPE = new Map(PROVIDER_ENTRIES.map(([alias, provider]) => [provider.type, alias]))
15
- const PROVIDER_SET_NAMES = Array.from(new Set(PROVIDER_ENTRIES.flatMap(([alias, provider]) => [alias, provider.type])))
16
12
  const PROJECT_DEFAULT_MODEL = String(PROJECT_CONFIG.agent?.defaultModel || "").trim()
17
13
 
14
+ const getProviderEntries = () => Object.entries(PROJECT_CONFIG.providers || {})
15
+ const getProviderAliases = () => getProviderEntries().map(([alias]) => alias)
16
+ const getProviderAliasByType = () => new Map(getProviderEntries().map(([alias, provider]) => [provider.type, alias]))
17
+ const getProviderSetNames = () => Array.from(new Set(getProviderEntries().flatMap(([alias, provider]) => [alias, provider.type])))
18
+
18
19
  const emptyProviders = () =>
19
- Object.fromEntries(PROVIDER_ALIASES.map((providerAlias) => [providerAlias, { apiKey: "" }]))
20
+ Object.fromEntries(getProviderAliases().map((providerAlias) => [providerAlias, { apiKey: "" }]))
20
21
 
21
22
  const resolveProviderAliasInput = (input) => {
22
23
  if (typeof input !== "string" || input.trim().length === 0) {
23
24
  throw new Error("provider is required")
24
25
  }
25
26
  const raw = input.trim()
26
- if (PROVIDER_ALIASES.includes(raw)) return raw
27
- const fromType = PROVIDER_ALIAS_BY_TYPE.get(raw)
27
+ const providerAliases = getProviderAliases()
28
+ if (providerAliases.includes(raw)) return raw
29
+ const fromType = getProviderAliasByType().get(raw)
28
30
  if (fromType) return fromType
29
- throw new Error(`provider must be one of: ${PROVIDER_SET_NAMES.join(", ")}`)
31
+ throw new Error(`provider must be one of: ${getProviderSetNames().join(", ")}`)
30
32
  }
31
33
 
32
34
  function resolveDefaultProviderAlias() {
@@ -34,7 +36,7 @@ function resolveDefaultProviderAlias() {
34
36
  if (typeof configured === "string" && configured.trim().length > 0) {
35
37
  return resolveProviderAliasInput(configured.trim())
36
38
  }
37
- return PROVIDER_ALIASES[0] || "openai"
39
+ return getProviderAliases()[0] || "openai"
38
40
  }
39
41
 
40
42
  const DEFAULT_PROVIDER_ALIAS = resolveDefaultProviderAlias()
@@ -73,7 +75,7 @@ const getProfile = (config, name) => {
73
75
  }
74
76
  const profile = config.profiles[profileName]
75
77
  if (!profile.providers || typeof profile.providers !== "object") profile.providers = {}
76
- for (const providerAlias of PROVIDER_ALIASES) {
78
+ for (const providerAlias of getProviderAliases()) {
77
79
  if (!profile.providers[providerAlias] || typeof profile.providers[providerAlias] !== "object") {
78
80
  profile.providers[providerAlias] = { apiKey: "" }
79
81
  }
@@ -102,7 +104,7 @@ const parseFlags = (args) => {
102
104
  if (!token?.startsWith("--")) continue
103
105
  const key = token.slice(2)
104
106
  const next = args[i + 1]
105
- if (!next || next.startsWith("--")) {
107
+ if (typeof next !== "string" || next.startsWith("--")) {
106
108
  flags[key] = true
107
109
  } else {
108
110
  flags[key] = next
@@ -147,7 +149,7 @@ const readEnvApiKey = (providerAlias) => {
147
149
  const buildProviderApiKeys = (config, profileName) => {
148
150
  const profile = getProfile(config, profileName)
149
151
  const providerApiKeys = {}
150
- for (const [providerAlias, providerConfig] of PROVIDER_ENTRIES) {
152
+ for (const [providerAlias, providerConfig] of getProviderEntries()) {
151
153
  const apiKey = profile.providers?.[providerAlias]?.apiKey || profile.providers?.[providerConfig.type]?.apiKey || ""
152
154
  providerApiKeys[providerAlias] = apiKey
153
155
  providerApiKeys[providerConfig.type] = apiKey
@@ -171,8 +173,8 @@ const resolveLocalSemanticContext = (flags) => {
171
173
  }
172
174
  const providerApiKeys = buildProviderApiKeys(config, profileName)
173
175
  const configuredApiKey = typeof providerApiKeys[provider] === "string" ? providerApiKeys[provider].trim() : ""
174
- if (!configuredApiKey && !readEnvApiKey(provider)) {
175
- const apiKeyEnv = PROJECT_CONFIG.providers?.[provider]?.apiKeyEnv || "PROVIDER_API_KEY"
176
+ const apiKeyEnv = PROJECT_CONFIG.providers?.[provider]?.apiKeyEnv || ""
177
+ if (apiKeyEnv && !configuredApiKey && !readEnvApiKey(provider)) {
176
178
  throw new Error(
177
179
  [
178
180
  `semantic requires an API key for provider "${provider}".`,
@@ -213,7 +215,7 @@ const loadLocalDocumentApi = async () => {
213
215
  return import(LOCAL_DOCUMENT_DIST_ENTRY.href)
214
216
  }
215
217
 
216
- const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render"]
218
+ const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render", "tables", "formulas"]
217
219
  const REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE = {
218
220
  index: "document",
219
221
  get: "document",
@@ -300,6 +302,40 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
300
302
  return
301
303
  }
302
304
 
305
+ if (primitive === "tables") {
306
+ const semanticContext = resolveLocalSemanticContext(flags)
307
+ const local = await loadLocalDocumentApi()
308
+ print(await local.get_page_tables_latex({
309
+ pdfPath,
310
+ workspaceDir,
311
+ forceRefresh,
312
+ pageNumber,
313
+ renderScale,
314
+ provider: semanticContext.provider,
315
+ model: semanticContext.model,
316
+ providerApiKeys: semanticContext.providerApiKeys,
317
+ prompt: typeof flags.prompt === "string" ? flags.prompt : undefined,
318
+ }))
319
+ return
320
+ }
321
+
322
+ if (primitive === "formulas") {
323
+ const semanticContext = resolveLocalSemanticContext(flags)
324
+ const local = await loadLocalDocumentApi()
325
+ print(await local.get_page_formulas_latex({
326
+ pdfPath,
327
+ workspaceDir,
328
+ forceRefresh,
329
+ pageNumber,
330
+ renderScale,
331
+ provider: semanticContext.provider,
332
+ model: semanticContext.model,
333
+ providerApiKeys: semanticContext.providerApiKeys,
334
+ prompt: typeof flags.prompt === "string" ? flags.prompt : undefined,
335
+ }))
336
+ return
337
+ }
338
+
303
339
  throw new Error(`Unsupported local primitive command: ${primitive}`)
304
340
  }
305
341
 
@@ -311,13 +347,19 @@ const usage = () => {
311
347
  process.stdout.write(` semantic <file.pdf> [--provider alias] [--model model] [--profile name] [--workspace DIR] [--force-refresh]\n`)
312
348
  process.stdout.write(` page <file.pdf> --page <N> [--workspace DIR] [--force-refresh]\n`)
313
349
  process.stdout.write(` render <file.pdf> --page <N> [--scale N] [--workspace DIR] [--force-refresh]\n`)
350
+ process.stdout.write(` tables <file.pdf> --page <N> [--provider alias] [--model model] [--scale N] [--prompt text] [--workspace DIR] [--force-refresh]\n`)
351
+ process.stdout.write(` formulas <file.pdf> --page <N> [--provider alias] [--model model] [--scale N] [--prompt text] [--workspace DIR] [--force-refresh]\n`)
314
352
  process.stdout.write(`\nLocal config commands:\n`)
315
- process.stdout.write(` provider set --provider <${PROVIDER_SET_NAMES.join("|")}> --api-key <KEY> [--profile name]\n`)
316
- process.stdout.write(` provider use --provider <${PROVIDER_ALIASES.join("|")}> [--profile name]\n`)
353
+ process.stdout.write(` provider set --provider <${getProviderSetNames().join("|")}> --api-key <KEY> [--profile name]\n`)
354
+ process.stdout.write(` provider use --provider <${getProviderAliases().join("|")}> [--profile name]\n`)
317
355
  process.stdout.write(` provider list [--profile name]\n`)
318
356
  process.stdout.write(` model set --model <model-id> [--provider alias] [--profile name]\n`)
319
357
  process.stdout.write(` model get [--provider alias] [--profile name]\n`)
320
358
  process.stdout.write(` model list [--profile name]\n`)
359
+ process.stdout.write(`\nLocal LLM example (no auth):\n`)
360
+ process.stdout.write(` echo-pdf provider set --provider ollama --api-key \"\"\n`)
361
+ process.stdout.write(` echo-pdf model set --provider ollama --model llava:13b\n`)
362
+ process.stdout.write(` echo-pdf semantic ./sample.pdf --provider ollama\n`)
321
363
  }
322
364
 
323
365
  const main = async () => {
@@ -0,0 +1,2 @@
1
+ import type { LocalPageFormulasArtifact, LocalPageFormulasRequest } from "./types.js";
2
+ export declare const get_page_formulas_latex: (request: LocalPageFormulasRequest) => Promise<LocalPageFormulasArtifact>;
@@ -0,0 +1,71 @@
1
+ /// <reference path="../node/compat.d.ts" />
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { toDataUrl } from "../file-utils.js";
5
+ import { visionRecognize } from "../provider-client.js";
6
+ import { ensureRenderArtifact, indexDocumentInternal } from "./document.js";
7
+ import { buildStructuredArtifactPath, ensurePageNumber, fileExists, matchesSourceSnapshot, normalizeFormulaItems, pageLabel, parseJsonObject, readJson, resolveAgentSelection, resolveConfig, resolveEnv, resolveRenderScale, writeJson, } from "./shared.js";
8
+ const DEFAULT_FORMULA_PROMPT = "Detect all displayed mathematical formulas from this PDF page image. " +
9
+ "Return JSON only. Schema: " +
10
+ '{ "formulas": [{ "latexMath": "LaTeX math expression", "label": "optional equation label", "evidenceText": "optional" }] }. ' +
11
+ "Use LaTeX math notation. Do not include inline prose math or trivial single-symbol expressions. " +
12
+ "If no displayed formulas are found, return {\"formulas\":[]}.";
13
+ export const get_page_formulas_latex = async (request) => {
14
+ const env = resolveEnv(request.env);
15
+ const config = resolveConfig(request.config, env);
16
+ const { record } = await indexDocumentInternal(request);
17
+ ensurePageNumber(record.pageCount, request.pageNumber);
18
+ const { provider, model } = resolveAgentSelection(config, request);
19
+ const renderScale = resolveRenderScale(config, request.renderScale);
20
+ const prompt = typeof request.prompt === "string" && request.prompt.trim().length > 0
21
+ ? request.prompt.trim()
22
+ : DEFAULT_FORMULA_PROMPT;
23
+ const formulasDir = path.join(record.artifactPaths.documentDir, "formulas");
24
+ const artifactPath = buildStructuredArtifactPath(formulasDir, request.pageNumber, renderScale, provider, model, prompt);
25
+ if (!request.forceRefresh && await fileExists(artifactPath)) {
26
+ const cached = await readJson(artifactPath);
27
+ if (matchesSourceSnapshot(cached, record)) {
28
+ return { ...cached, cacheStatus: "reused" };
29
+ }
30
+ }
31
+ const renderArtifact = await ensureRenderArtifact({
32
+ pdfPath: request.pdfPath,
33
+ workspaceDir: request.workspaceDir,
34
+ forceRefresh: request.forceRefresh,
35
+ config,
36
+ pageNumber: request.pageNumber,
37
+ renderScale: request.renderScale,
38
+ });
39
+ const imageBytes = new Uint8Array(await readFile(renderArtifact.imagePath));
40
+ const imageDataUrl = toDataUrl(imageBytes, renderArtifact.mimeType);
41
+ const response = await visionRecognize({
42
+ config,
43
+ env,
44
+ providerAlias: provider,
45
+ model,
46
+ prompt,
47
+ imageDataUrl,
48
+ runtimeApiKeys: request.providerApiKeys,
49
+ });
50
+ const parsed = parseJsonObject(response);
51
+ const formulas = normalizeFormulaItems(parsed?.formulas);
52
+ const pageArtifactPath = path.join(record.artifactPaths.pagesDir, `${pageLabel(request.pageNumber)}.json`);
53
+ const artifact = {
54
+ documentId: record.documentId,
55
+ pageNumber: request.pageNumber,
56
+ renderScale,
57
+ sourceSizeBytes: record.sizeBytes,
58
+ sourceMtimeMs: record.mtimeMs,
59
+ provider,
60
+ model,
61
+ prompt,
62
+ imagePath: renderArtifact.imagePath,
63
+ pageArtifactPath,
64
+ renderArtifactPath: renderArtifact.artifactPath,
65
+ artifactPath,
66
+ generatedAt: new Date().toISOString(),
67
+ formulas,
68
+ };
69
+ await writeJson(artifactPath, artifact);
70
+ return { ...artifact, cacheStatus: "fresh" };
71
+ };
@@ -1,3 +1,5 @@
1
- export type { LocalDocumentArtifactPaths, LocalDocumentMetadata, LocalDocumentRequest, LocalDocumentStructure, LocalDocumentStructureNode, LocalPageContent, LocalPageContentRequest, LocalPageRenderArtifact, LocalPageRenderRequest, LocalSemanticDocumentRequest, LocalSemanticDocumentStructure, LocalSemanticStructureNode, } from "./types.js";
1
+ export type { LocalDocumentArtifactPaths, LocalDocumentMetadata, LocalDocumentRequest, LocalDocumentStructure, LocalDocumentStructureNode, LocalFormulaArtifactItem, LocalPageContent, LocalPageContentRequest, LocalPageFormulasArtifact, LocalPageFormulasRequest, LocalPageRenderArtifact, LocalPageRenderRequest, LocalPageTablesArtifact, LocalPageTablesRequest, LocalSemanticDocumentRequest, LocalSemanticDocumentStructure, LocalSemanticStructureNode, LocalTableArtifactItem, } from "./types.js";
2
2
  export { get_document, get_document_structure, get_page_content, get_page_render } from "./document.js";
3
+ export { get_page_formulas_latex } from "./formulas.js";
3
4
  export { get_semantic_document_structure } from "./semantic.js";
5
+ export { get_page_tables_latex } from "./tables.js";
@@ -1,2 +1,4 @@
1
1
  export { get_document, get_document_structure, get_page_content, get_page_render } from "./document.js";
2
+ export { get_page_formulas_latex } from "./formulas.js";
2
3
  export { get_semantic_document_structure } from "./semantic.js";
4
+ export { get_page_tables_latex } from "./tables.js";
@@ -0,0 +1,2 @@
1
+ import type { LocalPageTablesArtifact, LocalPageTablesRequest } from "./types.js";
2
+ export declare const get_page_tables_latex: (request: LocalPageTablesRequest) => Promise<LocalPageTablesArtifact>;
@@ -0,0 +1,71 @@
1
+ /// <reference path="../node/compat.d.ts" />
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { toDataUrl } from "../file-utils.js";
5
+ import { visionRecognize } from "../provider-client.js";
6
+ import { ensureRenderArtifact, indexDocumentInternal } from "./document.js";
7
+ import { buildStructuredArtifactPath, ensurePageNumber, fileExists, matchesSourceSnapshot, normalizeTableItems, pageLabel, parseJsonObject, readJson, resolveAgentSelection, resolveConfig, resolveEnv, resolveRenderScale, writeJson, } from "./shared.js";
8
+ const DEFAULT_TABLE_PROMPT = "Detect all tabular structures from this PDF page image. " +
9
+ "Return JSON only. Schema: " +
10
+ '{ "tables": [{ "latexTabular": "\\\\begin{tabular}...\\\\end{tabular}", "caption": "optional", "evidenceText": "optional" }] }. ' +
11
+ "Each table must be a complete LaTeX tabular environment. " +
12
+ "If no tables are found, return {\"tables\":[]}.";
13
+ export const get_page_tables_latex = async (request) => {
14
+ const env = resolveEnv(request.env);
15
+ const config = resolveConfig(request.config, env);
16
+ const { record } = await indexDocumentInternal(request);
17
+ ensurePageNumber(record.pageCount, request.pageNumber);
18
+ const { provider, model } = resolveAgentSelection(config, request);
19
+ const renderScale = resolveRenderScale(config, request.renderScale);
20
+ const prompt = typeof request.prompt === "string" && request.prompt.trim().length > 0
21
+ ? request.prompt.trim()
22
+ : (config.agent.tablePrompt || DEFAULT_TABLE_PROMPT);
23
+ const tablesDir = path.join(record.artifactPaths.documentDir, "tables");
24
+ const artifactPath = buildStructuredArtifactPath(tablesDir, request.pageNumber, renderScale, provider, model, prompt);
25
+ if (!request.forceRefresh && await fileExists(artifactPath)) {
26
+ const cached = await readJson(artifactPath);
27
+ if (matchesSourceSnapshot(cached, record)) {
28
+ return { ...cached, cacheStatus: "reused" };
29
+ }
30
+ }
31
+ const renderArtifact = await ensureRenderArtifact({
32
+ pdfPath: request.pdfPath,
33
+ workspaceDir: request.workspaceDir,
34
+ forceRefresh: request.forceRefresh,
35
+ config,
36
+ pageNumber: request.pageNumber,
37
+ renderScale: request.renderScale,
38
+ });
39
+ const imageBytes = new Uint8Array(await readFile(renderArtifact.imagePath));
40
+ const imageDataUrl = toDataUrl(imageBytes, renderArtifact.mimeType);
41
+ const response = await visionRecognize({
42
+ config,
43
+ env,
44
+ providerAlias: provider,
45
+ model,
46
+ prompt,
47
+ imageDataUrl,
48
+ runtimeApiKeys: request.providerApiKeys,
49
+ });
50
+ const parsed = parseJsonObject(response);
51
+ const tables = normalizeTableItems(parsed?.tables);
52
+ const pageArtifactPath = path.join(record.artifactPaths.pagesDir, `${pageLabel(request.pageNumber)}.json`);
53
+ const artifact = {
54
+ documentId: record.documentId,
55
+ pageNumber: request.pageNumber,
56
+ renderScale,
57
+ sourceSizeBytes: record.sizeBytes,
58
+ sourceMtimeMs: record.mtimeMs,
59
+ provider,
60
+ model,
61
+ prompt,
62
+ imagePath: renderArtifact.imagePath,
63
+ pageArtifactPath,
64
+ renderArtifactPath: renderArtifact.artifactPath,
65
+ artifactPath,
66
+ generatedAt: new Date().toISOString(),
67
+ tables,
68
+ };
69
+ await writeJson(artifactPath, artifact);
70
+ return { ...artifact, cacheStatus: "fresh" };
71
+ };
@@ -1,7 +1,7 @@
1
1
  import type { ProviderType } from "./types.js";
2
2
  export interface EchoPdfProviderConfig {
3
3
  readonly type: ProviderType;
4
- readonly apiKeyEnv: string;
4
+ readonly apiKeyEnv?: string;
5
5
  readonly baseUrl?: string;
6
6
  readonly headers?: Record<string, string>;
7
7
  readonly timeoutMs?: number;
@@ -21,6 +21,7 @@ export interface EchoPdfConfig {
21
21
  readonly defaultProvider: string;
22
22
  readonly defaultModel: string;
23
23
  readonly tablePrompt: string;
24
+ readonly formulaPrompt?: string;
24
25
  };
25
26
  readonly providers: Record<string, EchoPdfProviderConfig>;
26
27
  }
@@ -30,7 +30,7 @@ const toAuthHeader = (config, providerAlias, provider, env, runtimeApiKeys) => {
30
30
  provider,
31
31
  runtimeApiKeys,
32
32
  });
33
- return { Authorization: `Bearer ${token}` };
33
+ return token ? { Authorization: `Bearer ${token}` } : {};
34
34
  };
35
35
  const withTimeout = async (url, init, timeoutMs) => {
36
36
  const ctrl = new AbortController();
@@ -16,6 +16,9 @@ export const runtimeProviderKeyCandidates = (_config, providerAlias, provider) =
16
16
  return Array.from(new Set([...aliases, ...types]));
17
17
  };
18
18
  export const resolveProviderApiKey = (input) => {
19
+ const envKey = typeof input.provider.apiKeyEnv === "string" ? input.provider.apiKeyEnv.trim() : "";
20
+ if (!envKey)
21
+ return "";
19
22
  const candidates = runtimeProviderKeyCandidates(input.config, input.providerAlias, input.provider);
20
23
  for (const candidate of candidates) {
21
24
  const value = input.runtimeApiKeys?.[candidate];
@@ -23,5 +26,5 @@ export const resolveProviderApiKey = (input) => {
23
26
  return value.trim();
24
27
  }
25
28
  }
26
- return readRequiredEnv(input.env, input.provider.apiKeyEnv);
29
+ return readRequiredEnv(input.env, envKey);
27
30
  };
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ProviderType = "openai" | "openrouter" | "vercel-ai-gateway";
1
+ export type ProviderType = string;
2
2
  export interface Env {
3
3
  readonly ECHO_PDF_CONFIG_JSON?: string;
4
4
  readonly [key: string]: string | undefined;
@@ -8,7 +8,8 @@
8
8
  "agent": {
9
9
  "defaultProvider": "openai",
10
10
  "defaultModel": "",
11
- "tablePrompt": "Detect all tabular structures from this PDF page image. Output only valid LaTeX tabular environments, no explanations, no markdown fences."
11
+ "tablePrompt": "Detect all tabular structures from this PDF page image. Output only valid LaTeX tabular environments, no explanations, no markdown fences.",
12
+ "formulaPrompt": ""
12
13
  },
13
14
  "providers": {
14
15
  "openai": {
@@ -37,6 +38,15 @@
37
38
  "chatCompletionsPath": "/chat/completions",
38
39
  "modelsPath": "/models"
39
40
  }
41
+ },
42
+ "ollama": {
43
+ "type": "openai-compatible",
44
+ "apiKeyEnv": "",
45
+ "baseUrl": "http://127.0.0.1:11434/v1",
46
+ "endpoints": {
47
+ "chatCompletionsPath": "/chat/completions",
48
+ "modelsPath": "/models"
49
+ }
40
50
  }
41
51
  }
42
52
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@echofiles/echo-pdf",
3
3
  "description": "Local-first PDF document component core with CLI, workspace artifacts, and reusable page primitives.",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "homepage": "https://pdf.echofile.ai/",
7
7
  "repository": {