@echofiles/echo-pdf 0.11.0 → 0.11.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.
@@ -1,4 +1,28 @@
1
1
  import { resolveProviderApiKey } from "./provider-keys.js";
2
+ class ProviderRequestError extends Error {
3
+ code;
4
+ detail;
5
+ constructor(code, detail) {
6
+ const parts = [
7
+ `${detail.operation} failed`,
8
+ `code=${code}`,
9
+ `url=${detail.url}`,
10
+ `attempt=${detail.attempt}/${detail.maxAttempts}`,
11
+ ];
12
+ if (typeof detail.status === "number")
13
+ parts.push(`http=${detail.status}`);
14
+ if (detail.contentType)
15
+ parts.push(`contentType=${detail.contentType}`);
16
+ if (detail.causeMessage)
17
+ parts.push(`cause=${detail.causeMessage}`);
18
+ if (detail.responsePreview)
19
+ parts.push(`preview=${JSON.stringify(detail.responsePreview)}`);
20
+ super(parts.join(" "));
21
+ this.code = code;
22
+ this.detail = detail;
23
+ this.name = "ProviderRequestError";
24
+ }
25
+ }
2
26
  const defaultBaseUrl = (provider) => {
3
27
  if (provider.baseUrl)
4
28
  return provider.baseUrl;
@@ -32,33 +56,197 @@ const toAuthHeader = (config, providerAlias, provider, env, runtimeApiKeys) => {
32
56
  });
33
57
  return token ? { Authorization: `Bearer ${token}` } : {};
34
58
  };
35
- const withTimeout = async (url, init, timeoutMs) => {
59
+ const isPrivateIpv4 = (hostname) => /^10\./.test(hostname) ||
60
+ /^127\./.test(hostname) ||
61
+ /^192\.168\./.test(hostname) ||
62
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
63
+ const isLocalOpenAiCompatible = (provider, url) => {
64
+ try {
65
+ const parsed = new URL(url);
66
+ const hostname = parsed.hostname.toLowerCase();
67
+ return (provider.type === "openai-compatible" &&
68
+ (hostname === "localhost" || hostname === "::1" || isPrivateIpv4(hostname)));
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ };
74
+ const maxAttemptsFor = (provider, url, method, operation) => {
75
+ if (method !== "POST")
76
+ return 1;
77
+ if (operation === "Model list request")
78
+ return 1;
79
+ return isLocalOpenAiCompatible(provider, url) ? 2 : 1;
80
+ };
81
+ const truncate = (value, length = 800) => value.slice(0, length);
82
+ const withTimeout = async (input) => {
36
83
  const ctrl = new AbortController();
37
- const timer = setTimeout(() => ctrl.abort("timeout"), timeoutMs);
84
+ const timer = setTimeout(() => ctrl.abort("timeout"), input.timeoutMs);
38
85
  try {
39
- return await fetch(url, { ...init, signal: ctrl.signal });
86
+ return await fetch(input.url, { ...input.init, signal: ctrl.signal });
40
87
  }
41
88
  catch (error) {
42
89
  if (error instanceof Error && error.name === "AbortError") {
43
- throw new Error(`Request timeout after ${timeoutMs}ms for ${url}`);
90
+ throw new ProviderRequestError("PROVIDER_REQUEST_TIMEOUT", {
91
+ operation: input.operation,
92
+ providerAlias: input.providerAlias,
93
+ url: input.url,
94
+ attempt: input.attempt,
95
+ maxAttempts: input.maxAttempts,
96
+ causeMessage: `timeout after ${input.timeoutMs}ms`,
97
+ });
44
98
  }
45
- throw error;
99
+ throw new ProviderRequestError("PROVIDER_REQUEST_SEND_FAILED", {
100
+ operation: input.operation,
101
+ providerAlias: input.providerAlias,
102
+ url: input.url,
103
+ attempt: input.attempt,
104
+ maxAttempts: input.maxAttempts,
105
+ causeMessage: error instanceof Error ? error.message : String(error),
106
+ });
46
107
  }
47
108
  finally {
48
109
  clearTimeout(timer);
49
110
  }
50
111
  };
51
- const responseDetail = async (response) => {
52
- const contentType = response.headers.get("content-type") ?? "";
112
+ const readResponseText = async (input) => {
113
+ const reader = input.response.body?.getReader();
114
+ if (!reader) {
115
+ try {
116
+ return await input.response.text();
117
+ }
118
+ catch (error) {
119
+ throw new ProviderRequestError("PROVIDER_RESPONSE_BODY_READ_FAILED", {
120
+ operation: input.operation,
121
+ providerAlias: input.providerAlias,
122
+ url: input.url,
123
+ status: input.response.status,
124
+ contentType: input.response.headers.get("content-type") ?? "",
125
+ attempt: input.attempt,
126
+ maxAttempts: input.maxAttempts,
127
+ causeMessage: error instanceof Error ? error.message : String(error),
128
+ });
129
+ }
130
+ }
131
+ const chunks = [];
132
+ let total = 0;
53
133
  try {
54
- if (contentType.includes("application/json")) {
55
- return JSON.stringify(await response.json()).slice(0, 800);
134
+ while (true) {
135
+ const { done, value } = await reader.read();
136
+ if (done)
137
+ break;
138
+ if (!value || value.length === 0)
139
+ continue;
140
+ chunks.push(value);
141
+ total += value.length;
56
142
  }
57
- return (await response.text()).slice(0, 800);
58
143
  }
59
- catch {
60
- return "<unable to parse response payload>";
144
+ catch (error) {
145
+ const combined = new Uint8Array(total);
146
+ let offset = 0;
147
+ for (const chunk of chunks) {
148
+ combined.set(chunk, offset);
149
+ offset += chunk.length;
150
+ }
151
+ throw new ProviderRequestError("PROVIDER_RESPONSE_BODY_READ_FAILED", {
152
+ operation: input.operation,
153
+ providerAlias: input.providerAlias,
154
+ url: input.url,
155
+ status: input.response.status,
156
+ contentType: input.response.headers.get("content-type") ?? "",
157
+ attempt: input.attempt,
158
+ maxAttempts: input.maxAttempts,
159
+ causeMessage: error instanceof Error ? error.message : String(error),
160
+ responsePreview: truncate(new TextDecoder().decode(combined)),
161
+ });
162
+ }
163
+ const combined = new Uint8Array(total);
164
+ let offset = 0;
165
+ for (const chunk of chunks) {
166
+ combined.set(chunk, offset);
167
+ offset += chunk.length;
61
168
  }
169
+ return new TextDecoder().decode(combined);
170
+ };
171
+ const parseJsonResponse = (input) => {
172
+ try {
173
+ return JSON.parse(input.rawText);
174
+ }
175
+ catch (error) {
176
+ throw new ProviderRequestError("PROVIDER_RESPONSE_JSON_PARSE_FAILED", {
177
+ operation: input.operation,
178
+ providerAlias: input.providerAlias,
179
+ url: input.url,
180
+ status: input.response.status,
181
+ contentType: input.response.headers.get("content-type") ?? "",
182
+ attempt: input.attempt,
183
+ maxAttempts: input.maxAttempts,
184
+ causeMessage: error instanceof Error ? error.message : String(error),
185
+ responsePreview: truncate(input.rawText),
186
+ });
187
+ }
188
+ };
189
+ const requestJson = async (input) => {
190
+ const maxAttempts = maxAttemptsFor(input.provider, input.url, input.method, input.operation);
191
+ let lastError = null;
192
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
193
+ try {
194
+ const response = await withTimeout({
195
+ operation: input.operation,
196
+ providerAlias: input.providerAlias,
197
+ url: input.url,
198
+ init: {
199
+ method: input.method,
200
+ headers: input.headers,
201
+ ...(typeof input.body === "string" ? { body: input.body } : {}),
202
+ },
203
+ timeoutMs: input.provider.timeoutMs ?? 30000,
204
+ attempt,
205
+ maxAttempts,
206
+ });
207
+ const rawText = await readResponseText({
208
+ response,
209
+ operation: input.operation,
210
+ providerAlias: input.providerAlias,
211
+ url: input.url,
212
+ attempt,
213
+ maxAttempts,
214
+ });
215
+ if (!response.ok) {
216
+ throw new ProviderRequestError("PROVIDER_HTTP_ERROR", {
217
+ operation: input.operation,
218
+ providerAlias: input.providerAlias,
219
+ url: input.url,
220
+ status: response.status,
221
+ contentType: response.headers.get("content-type") ?? "",
222
+ attempt,
223
+ maxAttempts,
224
+ responsePreview: truncate(rawText),
225
+ });
226
+ }
227
+ return parseJsonResponse({
228
+ rawText,
229
+ response,
230
+ operation: input.operation,
231
+ providerAlias: input.providerAlias,
232
+ url: input.url,
233
+ attempt,
234
+ maxAttempts,
235
+ });
236
+ }
237
+ catch (error) {
238
+ lastError = error instanceof Error ? error : new Error(String(error));
239
+ if (attempt < maxAttempts &&
240
+ lastError instanceof ProviderRequestError &&
241
+ (lastError.code === "PROVIDER_REQUEST_SEND_FAILED" ||
242
+ lastError.code === "PROVIDER_RESPONSE_BODY_READ_FAILED" ||
243
+ lastError.code === "PROVIDER_RESPONSE_JSON_PARSE_FAILED")) {
244
+ continue;
245
+ }
246
+ throw lastError;
247
+ }
248
+ }
249
+ throw lastError ?? new Error(`${input.operation} failed`);
62
250
  };
63
251
  const getProvider = (config, alias) => {
64
252
  const provider = config.providers[alias];
@@ -70,18 +258,20 @@ const getProvider = (config, alias) => {
70
258
  export const listProviderModels = async (config, env, alias, runtimeApiKeys) => {
71
259
  const provider = getProvider(config, alias);
72
260
  const url = resolveEndpoint(provider, "modelsPath");
73
- const response = await withTimeout(url, {
261
+ const payload = await requestJson({
262
+ operation: "Model list request",
263
+ config,
264
+ env,
265
+ providerAlias: alias,
266
+ provider,
267
+ url,
74
268
  method: "GET",
75
269
  headers: {
76
270
  Accept: "application/json",
77
271
  ...toAuthHeader(config, alias, provider, env, runtimeApiKeys),
78
272
  ...(provider.headers ?? {}),
79
273
  },
80
- }, provider.timeoutMs ?? 30000);
81
- if (!response.ok) {
82
- throw new Error(`Model list request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`);
83
- }
84
- const payload = await response.json();
274
+ });
85
275
  const data = payload.data;
86
276
  if (!Array.isArray(data))
87
277
  return [];
@@ -93,7 +283,13 @@ export const listProviderModels = async (config, env, alias, runtimeApiKeys) =>
93
283
  export const visionRecognize = async (input) => {
94
284
  const provider = getProvider(input.config, input.providerAlias);
95
285
  const url = resolveEndpoint(provider, "chatCompletionsPath");
96
- const response = await withTimeout(url, {
286
+ const payload = await requestJson({
287
+ operation: "Vision request",
288
+ config: input.config,
289
+ env: input.env,
290
+ providerAlias: input.providerAlias,
291
+ provider,
292
+ url,
97
293
  method: "POST",
98
294
  headers: {
99
295
  "Content-Type": "application/json",
@@ -112,11 +308,7 @@ export const visionRecognize = async (input) => {
112
308
  },
113
309
  ],
114
310
  }),
115
- }, provider.timeoutMs ?? 30000);
116
- if (!response.ok) {
117
- throw new Error(`Vision request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`);
118
- }
119
- const payload = await response.json();
311
+ });
120
312
  const message = payload.choices?.[0]?.message;
121
313
  if (!message)
122
314
  return "";
@@ -135,7 +327,13 @@ export const visionRecognize = async (input) => {
135
327
  export const generateText = async (input) => {
136
328
  const provider = getProvider(input.config, input.providerAlias);
137
329
  const url = resolveEndpoint(provider, "chatCompletionsPath");
138
- const response = await withTimeout(url, {
330
+ const payload = await requestJson({
331
+ operation: "Text generation request",
332
+ config: input.config,
333
+ env: input.env,
334
+ providerAlias: input.providerAlias,
335
+ provider,
336
+ url,
139
337
  method: "POST",
140
338
  headers: {
141
339
  "Content-Type": "application/json",
@@ -151,11 +349,7 @@ export const generateText = async (input) => {
151
349
  },
152
350
  ],
153
351
  }),
154
- }, provider.timeoutMs ?? 30000);
155
- if (!response.ok) {
156
- throw new Error(`Text generation request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`);
157
- }
158
- const payload = await response.json();
352
+ });
159
353
  const message = payload.choices?.[0]?.message;
160
354
  if (!message)
161
355
  return "";
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.11.0",
4
+ "version": "0.11.2",
5
5
  "type": "module",
6
6
  "homepage": "https://pdf.echofile.ai/",
7
7
  "repository": {
@@ -57,7 +57,7 @@
57
57
  "test:unit": "npm run check:runtime && vitest run tests/unit",
58
58
  "test:acceptance": "npm run check:runtime && npm run build && vitest run tests/acceptance",
59
59
  "test:import-smoke": "npm run check:runtime && npm run build && vitest run tests/integration/npm-pack-import.integration.test.ts tests/integration/ts-nodenext-consumer.integration.test.ts",
60
- "test:integration": "npm run check:runtime && npm run build && vitest run tests/integration/local-document-cli.integration.test.ts tests/integration/local-document.integration.test.ts tests/integration/local-semantic-structure.integration.test.ts tests/integration/npm-pack-import.integration.test.ts tests/integration/ts-nodenext-consumer.integration.test.ts",
60
+ "test:integration": "npm run check:runtime && npm run build && vitest run tests/integration/local-document-cli.integration.test.ts tests/integration/local-document.integration.test.ts tests/integration/local-provider-stability.integration.test.ts tests/integration/local-semantic-structure.integration.test.ts tests/integration/npm-pack-import.integration.test.ts tests/integration/ts-nodenext-consumer.integration.test.ts",
61
61
  "test": "npm run test:unit && npm run test:acceptance && npm run test:integration",
62
62
  "smoke": "bash ./scripts/smoke.sh",
63
63
  "prepublishOnly": "npm run build && npm run typecheck && npm run test"