@echofiles/echo-pdf 0.10.1 → 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.
- package/dist/provider-client.js +224 -30
- package/package.json +2 -2
package/dist/provider-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
const
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
-
}
|
|
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.
|
|
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"
|