@doclo/providers-llm 0.1.5
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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.d.ts +1421 -0
- package/dist/index.js +2743 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2743 @@
|
|
|
1
|
+
// src/schema-translator.ts
|
|
2
|
+
import { zodToJsonSchema } from "@alcyone-labs/zod-to-json-schema";
|
|
3
|
+
var SchemaTranslator = class {
|
|
4
|
+
/**
|
|
5
|
+
* Unified → OpenAI/Grok (standard JSON Schema)
|
|
6
|
+
* OpenAI strict mode doesn't support nullable: true
|
|
7
|
+
* Must convert to anyOf: [{ type: "string" }, { type: "null" }]
|
|
8
|
+
*/
|
|
9
|
+
toOpenAISchema(schema) {
|
|
10
|
+
const jsonSchema = this.convertZodIfNeeded(schema);
|
|
11
|
+
return this.convertNullableToAnyOf(jsonSchema);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Detect if schema is a Zod schema and convert to JSON Schema
|
|
15
|
+
* Public method to allow embedding schemas in prompts
|
|
16
|
+
*/
|
|
17
|
+
convertZodIfNeeded(schema) {
|
|
18
|
+
if (schema && typeof schema === "object") {
|
|
19
|
+
const flexibleSchema = schema;
|
|
20
|
+
if (flexibleSchema["~standard"]?.vendor === "zod") {
|
|
21
|
+
const jsonSchema = zodToJsonSchema(schema);
|
|
22
|
+
delete jsonSchema.$schema;
|
|
23
|
+
delete jsonSchema.$defs;
|
|
24
|
+
delete jsonSchema.definitions;
|
|
25
|
+
return jsonSchema;
|
|
26
|
+
}
|
|
27
|
+
if (flexibleSchema._def) {
|
|
28
|
+
const jsonSchema = zodToJsonSchema(schema);
|
|
29
|
+
delete jsonSchema.$schema;
|
|
30
|
+
delete jsonSchema.$defs;
|
|
31
|
+
delete jsonSchema.definitions;
|
|
32
|
+
return jsonSchema;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return schema;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Convert nullable fields to anyOf format for OpenAI strict mode
|
|
39
|
+
* nullable: true is not supported, must use anyOf with null type
|
|
40
|
+
*/
|
|
41
|
+
convertNullableToAnyOf(schema) {
|
|
42
|
+
if (typeof schema !== "object" || schema === null) {
|
|
43
|
+
return schema;
|
|
44
|
+
}
|
|
45
|
+
const result = { ...schema };
|
|
46
|
+
if (result.nullable === true) {
|
|
47
|
+
delete result.nullable;
|
|
48
|
+
const baseType = result.type;
|
|
49
|
+
if (baseType) {
|
|
50
|
+
return {
|
|
51
|
+
anyOf: [
|
|
52
|
+
{ type: baseType },
|
|
53
|
+
{ type: "null" }
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (result.properties) {
|
|
59
|
+
result.properties = Object.fromEntries(
|
|
60
|
+
Object.entries(result.properties).map(([key, value]) => {
|
|
61
|
+
if (value && typeof value === "object" && value.nullable === true) {
|
|
62
|
+
const { nullable, type, ...rest } = value;
|
|
63
|
+
return [key, {
|
|
64
|
+
anyOf: [
|
|
65
|
+
{ type, ...rest },
|
|
66
|
+
{ type: "null" }
|
|
67
|
+
]
|
|
68
|
+
}];
|
|
69
|
+
}
|
|
70
|
+
return [key, this.convertNullableToAnyOf(value)];
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (result.items && !Array.isArray(result.items)) {
|
|
75
|
+
result.items = this.convertNullableToAnyOf(result.items);
|
|
76
|
+
}
|
|
77
|
+
const keywords = ["anyOf", "oneOf", "allOf"];
|
|
78
|
+
keywords.forEach((keyword) => {
|
|
79
|
+
const schemaArray = result[keyword];
|
|
80
|
+
if (schemaArray && Array.isArray(schemaArray)) {
|
|
81
|
+
result[keyword] = schemaArray.map((s) => this.convertNullableToAnyOf(s));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Unified → Claude (Tool Input Schema format)
|
|
88
|
+
* Claude requires tool calling with input_schema
|
|
89
|
+
* Claude supports nullable: true directly
|
|
90
|
+
*/
|
|
91
|
+
toClaudeToolSchema(schema) {
|
|
92
|
+
const jsonSchema = this.convertZodIfNeeded(schema);
|
|
93
|
+
return {
|
|
94
|
+
name: "extract_data",
|
|
95
|
+
description: "Extract structured data according to the schema",
|
|
96
|
+
input_schema: jsonSchema
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Unified → Claude for OpenRouter
|
|
101
|
+
* When using Claude via OpenRouter, use anyOf format like OpenAI
|
|
102
|
+
*/
|
|
103
|
+
toClaudeOpenRouterSchema(schema) {
|
|
104
|
+
const jsonSchema = this.convertZodIfNeeded(schema);
|
|
105
|
+
return this.convertNullableToAnyOf(jsonSchema);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Unified → Gemini (OpenAPI 3.0 subset with propertyOrdering)
|
|
109
|
+
* Gemini uses a subset of OpenAPI 3.0 schema
|
|
110
|
+
*/
|
|
111
|
+
toGeminiSchema(schema) {
|
|
112
|
+
const jsonSchema = this.convertZodIfNeeded(schema);
|
|
113
|
+
const geminiSchema = {
|
|
114
|
+
type: jsonSchema.type
|
|
115
|
+
};
|
|
116
|
+
if (jsonSchema.properties) {
|
|
117
|
+
geminiSchema.properties = {};
|
|
118
|
+
const propertyNames = Object.keys(jsonSchema.properties);
|
|
119
|
+
geminiSchema.propertyOrdering = propertyNames;
|
|
120
|
+
for (const [key, value] of Object.entries(jsonSchema.properties)) {
|
|
121
|
+
geminiSchema.properties[key] = this.convertPropertyToGemini(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (jsonSchema.required && Array.isArray(jsonSchema.required)) {
|
|
125
|
+
geminiSchema.required = jsonSchema.required;
|
|
126
|
+
}
|
|
127
|
+
if (jsonSchema.additionalProperties !== void 0) {
|
|
128
|
+
geminiSchema.additionalProperties = jsonSchema.additionalProperties;
|
|
129
|
+
}
|
|
130
|
+
return geminiSchema;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Convert individual property to Gemini format
|
|
134
|
+
*/
|
|
135
|
+
convertPropertyToGemini(property) {
|
|
136
|
+
const geminiProp = {
|
|
137
|
+
type: property.type
|
|
138
|
+
};
|
|
139
|
+
if (property.description) {
|
|
140
|
+
geminiProp.description = property.description;
|
|
141
|
+
}
|
|
142
|
+
if (property.nullable !== void 0) {
|
|
143
|
+
geminiProp.nullable = property.nullable;
|
|
144
|
+
}
|
|
145
|
+
if (property.enum) {
|
|
146
|
+
geminiProp.enum = property.enum;
|
|
147
|
+
}
|
|
148
|
+
if (property.items) {
|
|
149
|
+
if (Array.isArray(property.items)) {
|
|
150
|
+
geminiProp.items = property.items.length > 0 ? this.convertPropertyToGemini(property.items[0]) : void 0;
|
|
151
|
+
} else {
|
|
152
|
+
geminiProp.items = this.convertPropertyToGemini(property.items);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (property.properties) {
|
|
156
|
+
geminiProp.properties = {};
|
|
157
|
+
for (const [key, value] of Object.entries(property.properties)) {
|
|
158
|
+
geminiProp.properties[key] = this.convertPropertyToGemini(value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (property.required) {
|
|
162
|
+
geminiProp.required = property.required;
|
|
163
|
+
}
|
|
164
|
+
return geminiProp;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// src/schema-prompt-formatter.ts
|
|
169
|
+
function formatSchemaForPrompt(schema, indent = 0) {
|
|
170
|
+
if (!schema || typeof schema !== "object") {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
const indentStr = " ".repeat(indent);
|
|
174
|
+
let result = "";
|
|
175
|
+
if (schema.type === "object" && schema.properties) {
|
|
176
|
+
const properties = schema.properties;
|
|
177
|
+
const required = schema.required || [];
|
|
178
|
+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
|
179
|
+
const isRequired = required.includes(fieldName);
|
|
180
|
+
const requiredMarker = isRequired ? " (REQUIRED)" : " (optional)";
|
|
181
|
+
result += `${indentStr}- \`${fieldName}\`${requiredMarker}`;
|
|
182
|
+
const type = getTypeDescription(fieldSchema);
|
|
183
|
+
if (type) {
|
|
184
|
+
result += `: ${type}`;
|
|
185
|
+
}
|
|
186
|
+
if (fieldSchema.description) {
|
|
187
|
+
result += `
|
|
188
|
+
${indentStr} ${fieldSchema.description}`;
|
|
189
|
+
}
|
|
190
|
+
if (fieldSchema.enum) {
|
|
191
|
+
result += `
|
|
192
|
+
${indentStr} Allowed values: ${fieldSchema.enum.map((v) => JSON.stringify(v)).join(", ")}`;
|
|
193
|
+
}
|
|
194
|
+
result += "\n";
|
|
195
|
+
if (fieldSchema.type === "object" && fieldSchema.properties) {
|
|
196
|
+
result += formatSchemaForPrompt(fieldSchema, indent + 1);
|
|
197
|
+
}
|
|
198
|
+
if (fieldSchema.type === "array" && fieldSchema.items) {
|
|
199
|
+
result += `${indentStr} Array items:
|
|
200
|
+
`;
|
|
201
|
+
const itemSchema = Array.isArray(fieldSchema.items) ? fieldSchema.items[0] : fieldSchema.items;
|
|
202
|
+
if (itemSchema && itemSchema.type === "object" && itemSchema.properties) {
|
|
203
|
+
result += formatSchemaForPrompt(itemSchema, indent + 2);
|
|
204
|
+
} else if (itemSchema) {
|
|
205
|
+
const itemType = getTypeDescription(itemSchema);
|
|
206
|
+
result += `${indentStr} ${itemType}
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
function getTypeDescription(schema) {
|
|
215
|
+
if (!schema) return "any";
|
|
216
|
+
if (schema.type) {
|
|
217
|
+
const typeStr = Array.isArray(schema.type) ? schema.type.join(" | ") : schema.type;
|
|
218
|
+
if (typeStr === "array" || Array.isArray(schema.type) && schema.type.includes("array")) {
|
|
219
|
+
if (schema.items && !Array.isArray(schema.items) && schema.items.type) {
|
|
220
|
+
const itemType = Array.isArray(schema.items.type) ? schema.items.type.join(" | ") : schema.items.type;
|
|
221
|
+
return `array of ${itemType}`;
|
|
222
|
+
}
|
|
223
|
+
return "array";
|
|
224
|
+
}
|
|
225
|
+
if ((typeStr === "string" || Array.isArray(schema.type) && schema.type.includes("string")) && schema.format) {
|
|
226
|
+
const formatHints = {
|
|
227
|
+
"date": "YYYY-MM-DD",
|
|
228
|
+
"time": "HH:MM or HH:MM:SS",
|
|
229
|
+
"date-time": "YYYY-MM-DDTHH:MM:SS (ISO 8601)"
|
|
230
|
+
};
|
|
231
|
+
const hint = formatHints[schema.format];
|
|
232
|
+
if (hint) {
|
|
233
|
+
return `string (format: ${schema.format}, use ${hint})`;
|
|
234
|
+
}
|
|
235
|
+
return `string (format: ${schema.format})`;
|
|
236
|
+
}
|
|
237
|
+
return typeStr;
|
|
238
|
+
}
|
|
239
|
+
if (schema.anyOf) {
|
|
240
|
+
return schema.anyOf.map((s) => getTypeDescription(s)).join(" OR ");
|
|
241
|
+
}
|
|
242
|
+
if (schema.oneOf) {
|
|
243
|
+
return schema.oneOf.map((s) => getTypeDescription(s)).join(" OR ");
|
|
244
|
+
}
|
|
245
|
+
return "any";
|
|
246
|
+
}
|
|
247
|
+
function buildSchemaPromptSection(schema) {
|
|
248
|
+
const schemaFields = formatSchemaForPrompt(schema);
|
|
249
|
+
return `
|
|
250
|
+
==================================================
|
|
251
|
+
CRITICAL: OUTPUT STRUCTURE REQUIREMENTS
|
|
252
|
+
==================================================
|
|
253
|
+
|
|
254
|
+
YOU MUST RETURN JSON MATCHING THIS EXACT STRUCTURE:
|
|
255
|
+
|
|
256
|
+
${schemaFields}
|
|
257
|
+
|
|
258
|
+
CRITICAL FIELD NAME REQUIREMENTS:
|
|
259
|
+
\u2713 Use EXACTLY the field names shown above (character-for-character match)
|
|
260
|
+
\u2713 Preserve the exact casing (e.g., "fullName", not "full_name" or "FullName")
|
|
261
|
+
\u2713 Do NOT abbreviate field names (e.g., "dob" instead of "dateOfBirth")
|
|
262
|
+
\u2713 Do NOT invent alternative names (e.g., "directorName" instead of "fullName")
|
|
263
|
+
\u2713 Do NOT use snake_case if the schema uses camelCase
|
|
264
|
+
\u2713 Do NOT flatten nested structures or rename nested fields
|
|
265
|
+
\u2713 The schema above is the SINGLE SOURCE OF TRUTH for field naming
|
|
266
|
+
|
|
267
|
+
MISSING DATA:
|
|
268
|
+
- If a required field has no data in the document, use null
|
|
269
|
+
- If an optional field has no data, you may omit it or use null
|
|
270
|
+
- Do NOT invent data that isn't in the document
|
|
271
|
+
|
|
272
|
+
==================================================
|
|
273
|
+
`.trim();
|
|
274
|
+
}
|
|
275
|
+
function combineSchemaAndUserPrompt(schema, userPrompt) {
|
|
276
|
+
const schemaSection = buildSchemaPromptSection(schema);
|
|
277
|
+
if (!userPrompt || userPrompt.trim() === "") {
|
|
278
|
+
return schemaSection + "\n\nTASK: Extract structured data from the provided document.";
|
|
279
|
+
}
|
|
280
|
+
return schemaSection + "\n\n" + userPrompt;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/provider-registry.ts
|
|
284
|
+
function normalizeProviderType(type) {
|
|
285
|
+
if (type === "x-ai") return "xai";
|
|
286
|
+
return type;
|
|
287
|
+
}
|
|
288
|
+
var ProviderRegistry = class {
|
|
289
|
+
factories = /* @__PURE__ */ new Map();
|
|
290
|
+
/**
|
|
291
|
+
* Register a provider factory
|
|
292
|
+
* Called by each provider package when it's imported
|
|
293
|
+
*/
|
|
294
|
+
register(type, factory) {
|
|
295
|
+
const normalizedType = normalizeProviderType(type);
|
|
296
|
+
this.factories.set(normalizedType, factory);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Check if a provider is registered
|
|
300
|
+
*/
|
|
301
|
+
has(type) {
|
|
302
|
+
const normalizedType = normalizeProviderType(type);
|
|
303
|
+
return this.factories.has(normalizedType);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Get a provider factory
|
|
307
|
+
*/
|
|
308
|
+
get(type) {
|
|
309
|
+
const normalizedType = normalizeProviderType(type);
|
|
310
|
+
return this.factories.get(normalizedType);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Create a provider instance
|
|
314
|
+
* @throws Error if provider is not registered
|
|
315
|
+
*/
|
|
316
|
+
create(config) {
|
|
317
|
+
const normalizedType = normalizeProviderType(config.provider);
|
|
318
|
+
const factory = this.factories.get(normalizedType);
|
|
319
|
+
if (!factory) {
|
|
320
|
+
const registered = Array.from(this.factories.keys()).join(", ") || "none";
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Provider '${config.provider}' is not registered. Registered providers: ${registered}. Make sure to import the provider package (e.g., import '@doclo/providers-${normalizedType}').`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return factory({ ...config, provider: normalizedType });
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Get all registered provider types
|
|
329
|
+
*/
|
|
330
|
+
getRegisteredTypes() {
|
|
331
|
+
return Array.from(this.factories.keys());
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Clear all registrations (for testing)
|
|
335
|
+
*/
|
|
336
|
+
clear() {
|
|
337
|
+
this.factories.clear();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
var providerRegistry = new ProviderRegistry();
|
|
341
|
+
function registerProvider(type, factory) {
|
|
342
|
+
providerRegistry.register(type, factory);
|
|
343
|
+
}
|
|
344
|
+
function createProviderFromRegistry(config) {
|
|
345
|
+
return providerRegistry.create(config);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/providers/openai.ts
|
|
349
|
+
import { fetchWithTimeout, DEFAULT_LIMITS, safeJsonParse } from "@doclo/core/security";
|
|
350
|
+
function extractProviderFromModel(model, defaultProvider) {
|
|
351
|
+
const slashIndex = model.indexOf("/");
|
|
352
|
+
return slashIndex > 0 ? model.substring(0, slashIndex) : defaultProvider;
|
|
353
|
+
}
|
|
354
|
+
var OpenAIProvider = class {
|
|
355
|
+
name;
|
|
356
|
+
capabilities = {
|
|
357
|
+
supportsStructuredOutput: true,
|
|
358
|
+
supportsStreaming: true,
|
|
359
|
+
supportsImages: true,
|
|
360
|
+
supportsPDFs: true,
|
|
361
|
+
maxPDFPages: 100,
|
|
362
|
+
maxPDFSize: 32,
|
|
363
|
+
maxContextTokens: 128e3
|
|
364
|
+
};
|
|
365
|
+
config;
|
|
366
|
+
translator;
|
|
367
|
+
limits;
|
|
368
|
+
constructor(config) {
|
|
369
|
+
this.config = config;
|
|
370
|
+
const baseProvider = extractProviderFromModel(config.model, "openai");
|
|
371
|
+
this.name = `${baseProvider}:${config.model}`;
|
|
372
|
+
this.translator = new SchemaTranslator();
|
|
373
|
+
this.limits = {
|
|
374
|
+
...DEFAULT_LIMITS,
|
|
375
|
+
...config.limits || {}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async completeJson(params) {
|
|
379
|
+
const startTime = Date.now();
|
|
380
|
+
const mode = params.mode || (params.schema ? "strict" : "relaxed");
|
|
381
|
+
if (mode === "strict" && !params.schema) {
|
|
382
|
+
throw new Error('schema is required when mode is "strict"');
|
|
383
|
+
}
|
|
384
|
+
const shouldEmbedSchema = params.embedSchemaInPrompt !== false && params.schema;
|
|
385
|
+
let enhancedInput = params.input;
|
|
386
|
+
if (shouldEmbedSchema) {
|
|
387
|
+
const jsonSchema = this.translator.convertZodIfNeeded(params.schema);
|
|
388
|
+
const enhancedText = combineSchemaAndUserPrompt(
|
|
389
|
+
jsonSchema,
|
|
390
|
+
params.input.text || ""
|
|
391
|
+
);
|
|
392
|
+
enhancedInput = {
|
|
393
|
+
...params.input,
|
|
394
|
+
text: enhancedText
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const messages = this.buildMessages(enhancedInput);
|
|
398
|
+
const requestBody = {
|
|
399
|
+
model: this.config.model,
|
|
400
|
+
messages,
|
|
401
|
+
max_tokens: params.max_tokens || 4096,
|
|
402
|
+
// Set reasonable default to avoid massive token requests
|
|
403
|
+
// Enable usage tracking for OpenRouter cost info
|
|
404
|
+
usage: {
|
|
405
|
+
include: true
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
if (mode === "relaxed") {
|
|
409
|
+
requestBody.response_format = {
|
|
410
|
+
type: "json_object"
|
|
411
|
+
};
|
|
412
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
413
|
+
console.log("[OpenAIProvider] Using relaxed JSON mode (json_object)");
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
const schema = this.translator.toOpenAISchema(params.schema);
|
|
417
|
+
const fixSchemaForAzure = (obj) => {
|
|
418
|
+
if (obj && typeof obj === "object") {
|
|
419
|
+
if (obj.type === "object" && obj.properties) {
|
|
420
|
+
const allProps = Object.keys(obj.properties);
|
|
421
|
+
obj.required = allProps;
|
|
422
|
+
obj.additionalProperties = false;
|
|
423
|
+
for (const key in obj.properties) {
|
|
424
|
+
obj.properties[key] = fixSchemaForAzure(obj.properties[key]);
|
|
425
|
+
}
|
|
426
|
+
} else if (obj.type === "array" && obj.items) {
|
|
427
|
+
obj.items = fixSchemaForAzure(obj.items);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return obj;
|
|
431
|
+
};
|
|
432
|
+
fixSchemaForAzure(schema);
|
|
433
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
434
|
+
console.log("[OpenAIProvider] Using strict JSON mode (json_schema)");
|
|
435
|
+
console.log("[OpenAIProvider] Fixed schema:", JSON.stringify(schema, null, 2));
|
|
436
|
+
}
|
|
437
|
+
requestBody.response_format = {
|
|
438
|
+
type: "json_schema",
|
|
439
|
+
json_schema: {
|
|
440
|
+
name: "extraction",
|
|
441
|
+
strict: true,
|
|
442
|
+
schema
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (params.reasoning) {
|
|
447
|
+
requestBody.reasoning = this.buildReasoningConfig(params.reasoning);
|
|
448
|
+
}
|
|
449
|
+
if (this.config.via === "openrouter" && mode === "strict") {
|
|
450
|
+
requestBody.provider = {
|
|
451
|
+
require_parameters: true
|
|
452
|
+
// Only route to models supporting json_schema
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const endpoint = this.config.via === "openrouter" ? "https://openrouter.ai/api/v1" : this.config.baseUrl || "https://api.openai.com/v1";
|
|
456
|
+
const headers = {
|
|
457
|
+
"Content-Type": "application/json",
|
|
458
|
+
"Authorization": `Bearer ${this.config.apiKey}`
|
|
459
|
+
};
|
|
460
|
+
if (this.config.via === "openrouter") {
|
|
461
|
+
headers["HTTP-Referer"] = "https://github.com/docloai/sdk";
|
|
462
|
+
headers["X-Title"] = "Doclo SDK";
|
|
463
|
+
}
|
|
464
|
+
const response = await fetchWithTimeout(`${endpoint}/chat/completions`, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers,
|
|
467
|
+
body: JSON.stringify(requestBody)
|
|
468
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
const error = await response.text();
|
|
471
|
+
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
|
472
|
+
}
|
|
473
|
+
const data = await response.json();
|
|
474
|
+
const latencyMs = Date.now() - startTime;
|
|
475
|
+
const content = data.choices?.[0]?.message?.content ?? "{}";
|
|
476
|
+
const parsed = safeJsonParse(content);
|
|
477
|
+
const message = data.choices?.[0]?.message;
|
|
478
|
+
const reasoning = message?.reasoning;
|
|
479
|
+
const reasoning_details = message?.reasoning_details;
|
|
480
|
+
let costUSD;
|
|
481
|
+
if (this.config.via === "openrouter") {
|
|
482
|
+
costUSD = data.usage?.total_cost ?? data.usage?.cost;
|
|
483
|
+
} else {
|
|
484
|
+
costUSD = this.calculateCost(data.usage);
|
|
485
|
+
}
|
|
486
|
+
const baseProvider = extractProviderFromModel(this.config.model, "openai");
|
|
487
|
+
return {
|
|
488
|
+
json: parsed,
|
|
489
|
+
rawText: content,
|
|
490
|
+
metrics: {
|
|
491
|
+
costUSD,
|
|
492
|
+
inputTokens: data.usage?.prompt_tokens,
|
|
493
|
+
outputTokens: data.usage?.completion_tokens,
|
|
494
|
+
latencyMs,
|
|
495
|
+
attemptNumber: 1,
|
|
496
|
+
provider: baseProvider,
|
|
497
|
+
// Base provider (e.g., "openai" from "openai/gpt-4...")
|
|
498
|
+
model: this.config.model
|
|
499
|
+
},
|
|
500
|
+
reasoning,
|
|
501
|
+
reasoning_details
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
buildReasoningConfig(reasoning) {
|
|
505
|
+
const config = {};
|
|
506
|
+
if (reasoning.effort) {
|
|
507
|
+
config.effort = reasoning.effort;
|
|
508
|
+
} else if (reasoning.enabled) {
|
|
509
|
+
config.effort = "medium";
|
|
510
|
+
}
|
|
511
|
+
if (reasoning.exclude !== void 0) {
|
|
512
|
+
config.exclude = reasoning.exclude;
|
|
513
|
+
}
|
|
514
|
+
return Object.keys(config).length > 0 ? config : void 0;
|
|
515
|
+
}
|
|
516
|
+
buildMessages(input) {
|
|
517
|
+
const content = [];
|
|
518
|
+
if (input.text) {
|
|
519
|
+
content.push({ type: "text", text: input.text });
|
|
520
|
+
}
|
|
521
|
+
if (input.images && input.images.length > 0) {
|
|
522
|
+
for (const image of input.images) {
|
|
523
|
+
if (image.url) {
|
|
524
|
+
content.push({
|
|
525
|
+
type: "image_url",
|
|
526
|
+
image_url: { url: image.url }
|
|
527
|
+
});
|
|
528
|
+
} else if (image.base64) {
|
|
529
|
+
content.push({
|
|
530
|
+
type: "image_url",
|
|
531
|
+
image_url: {
|
|
532
|
+
url: `data:${image.mimeType};base64,${this.extractBase64(image.base64)}`
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (input.pdfs && input.pdfs.length > 0) {
|
|
539
|
+
for (const pdf of input.pdfs) {
|
|
540
|
+
let fileData;
|
|
541
|
+
if (pdf.url) {
|
|
542
|
+
fileData = pdf.url;
|
|
543
|
+
} else if (pdf.base64) {
|
|
544
|
+
fileData = `data:application/pdf;base64,${this.extractBase64(pdf.base64)}`;
|
|
545
|
+
} else {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
content.push({
|
|
549
|
+
type: "file",
|
|
550
|
+
file: {
|
|
551
|
+
filename: "document.pdf",
|
|
552
|
+
file_data: fileData
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return [{ role: "user", content }];
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Extract base64 data from a data URL or return as-is if already raw base64
|
|
561
|
+
*/
|
|
562
|
+
extractBase64(input) {
|
|
563
|
+
if (input.startsWith("data:")) {
|
|
564
|
+
const base64Part = input.split(",")[1];
|
|
565
|
+
if (!base64Part) {
|
|
566
|
+
throw new Error(`Invalid data URL format: ${input.substring(0, 50)}`);
|
|
567
|
+
}
|
|
568
|
+
return base64Part;
|
|
569
|
+
}
|
|
570
|
+
return input;
|
|
571
|
+
}
|
|
572
|
+
calculateCost(usage) {
|
|
573
|
+
if (!usage) return void 0;
|
|
574
|
+
const inputCostPer1k = 5e-3;
|
|
575
|
+
const outputCostPer1k = 0.015;
|
|
576
|
+
const inputCost = usage.prompt_tokens / 1e3 * inputCostPer1k;
|
|
577
|
+
const outputCost = usage.completion_tokens / 1e3 * outputCostPer1k;
|
|
578
|
+
return inputCost + outputCost;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// src/providers/anthropic.ts
|
|
583
|
+
import { fetchWithTimeout as fetchWithTimeout2, DEFAULT_LIMITS as DEFAULT_LIMITS2, validateUrl, safeJsonParse as safeJsonParse2 } from "@doclo/core/security";
|
|
584
|
+
import { detectMimeTypeFromBase64 } from "@doclo/core";
|
|
585
|
+
function extractProviderFromModel2(model, defaultProvider) {
|
|
586
|
+
const slashIndex = model.indexOf("/");
|
|
587
|
+
return slashIndex > 0 ? model.substring(0, slashIndex) : defaultProvider;
|
|
588
|
+
}
|
|
589
|
+
var AnthropicProvider = class {
|
|
590
|
+
name;
|
|
591
|
+
capabilities = {
|
|
592
|
+
supportsStructuredOutput: true,
|
|
593
|
+
// via tool calling
|
|
594
|
+
supportsStreaming: true,
|
|
595
|
+
supportsImages: true,
|
|
596
|
+
supportsPDFs: true,
|
|
597
|
+
maxPDFPages: 100,
|
|
598
|
+
maxPDFSize: void 0,
|
|
599
|
+
// ~400k tokens with overhead
|
|
600
|
+
maxContextTokens: 2e5
|
|
601
|
+
};
|
|
602
|
+
config;
|
|
603
|
+
translator;
|
|
604
|
+
limits;
|
|
605
|
+
constructor(config) {
|
|
606
|
+
this.config = config;
|
|
607
|
+
const baseProvider = extractProviderFromModel2(config.model, "anthropic");
|
|
608
|
+
this.name = `${baseProvider}:${config.model}`;
|
|
609
|
+
this.translator = new SchemaTranslator();
|
|
610
|
+
this.limits = {
|
|
611
|
+
...DEFAULT_LIMITS2,
|
|
612
|
+
...config.limits || {}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async completeJson(params) {
|
|
616
|
+
const startTime = Date.now();
|
|
617
|
+
const mode = params.mode || (params.schema ? "strict" : "relaxed");
|
|
618
|
+
if (mode === "strict" && !params.schema) {
|
|
619
|
+
throw new Error('schema is required when mode is "strict"');
|
|
620
|
+
}
|
|
621
|
+
const shouldEmbedSchema = params.embedSchemaInPrompt !== false && params.schema;
|
|
622
|
+
let enhancedInput = params.input;
|
|
623
|
+
if (shouldEmbedSchema) {
|
|
624
|
+
const jsonSchema = this.translator.convertZodIfNeeded(params.schema);
|
|
625
|
+
const enhancedText = combineSchemaAndUserPrompt(
|
|
626
|
+
jsonSchema,
|
|
627
|
+
params.input.text || ""
|
|
628
|
+
);
|
|
629
|
+
enhancedInput = {
|
|
630
|
+
...params.input,
|
|
631
|
+
text: enhancedText
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const messages = await this.buildMessages(enhancedInput);
|
|
635
|
+
const useNewStructuredOutputs = this.supportsNewStructuredOutputs();
|
|
636
|
+
const requestBody = {
|
|
637
|
+
model: this.config.model,
|
|
638
|
+
max_tokens: params.max_tokens || 4096,
|
|
639
|
+
messages
|
|
640
|
+
};
|
|
641
|
+
if (mode === "relaxed") {
|
|
642
|
+
requestBody.messages.push({
|
|
643
|
+
role: "assistant",
|
|
644
|
+
content: "{"
|
|
645
|
+
});
|
|
646
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
647
|
+
console.log("[AnthropicProvider] Using relaxed JSON mode (prompt + prefilling)");
|
|
648
|
+
}
|
|
649
|
+
} else if (useNewStructuredOutputs) {
|
|
650
|
+
const jsonSchema = this.translator.convertZodIfNeeded(params.schema);
|
|
651
|
+
const fixedSchema = this.fixSchemaForStrictMode(jsonSchema);
|
|
652
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
653
|
+
console.log("[AnthropicProvider] Original schema:", JSON.stringify(jsonSchema, null, 2));
|
|
654
|
+
console.log("[AnthropicProvider] Fixed schema:", JSON.stringify(fixedSchema, null, 2));
|
|
655
|
+
}
|
|
656
|
+
requestBody.output_format = {
|
|
657
|
+
type: "json_schema",
|
|
658
|
+
schema: fixedSchema
|
|
659
|
+
};
|
|
660
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
661
|
+
console.log("[AnthropicProvider] Using NEW structured outputs API (strict mode)");
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
const tool = this.translator.toClaudeToolSchema(params.schema);
|
|
665
|
+
requestBody.tools = [tool];
|
|
666
|
+
requestBody.tool_choice = { type: "tool", name: "extract_data" };
|
|
667
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
668
|
+
console.log("[AnthropicProvider] Using legacy tool calling approach (strict mode)");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (this.config.via !== "openrouter" && params.reasoning) {
|
|
672
|
+
const thinkingConfig = this.buildNativeThinkingConfig(params.reasoning, params.max_tokens);
|
|
673
|
+
if (thinkingConfig) {
|
|
674
|
+
requestBody.thinking = thinkingConfig;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
let response;
|
|
678
|
+
let parsed;
|
|
679
|
+
let inputTokens;
|
|
680
|
+
let outputTokens;
|
|
681
|
+
let costUSD;
|
|
682
|
+
if (this.config.via === "openrouter") {
|
|
683
|
+
const useNewStructuredOutputs2 = this.supportsNewStructuredOutputs();
|
|
684
|
+
const openRouterRequest = this.translateToOpenRouterFormat(messages, params.schema, mode, params.max_tokens, params.reasoning);
|
|
685
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
686
|
+
console.log("[AnthropicProvider] OpenRouter request body (messages):");
|
|
687
|
+
console.log(JSON.stringify(openRouterRequest.messages, null, 2));
|
|
688
|
+
console.log("[AnthropicProvider] Using new structured outputs:", useNewStructuredOutputs2);
|
|
689
|
+
}
|
|
690
|
+
response = await fetchWithTimeout2("https://openrouter.ai/api/v1/chat/completions", {
|
|
691
|
+
method: "POST",
|
|
692
|
+
headers: {
|
|
693
|
+
"Content-Type": "application/json",
|
|
694
|
+
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
695
|
+
"HTTP-Referer": "https://github.com/docloai/sdk",
|
|
696
|
+
"X-Title": "Doclo SDK"
|
|
697
|
+
},
|
|
698
|
+
body: JSON.stringify(openRouterRequest)
|
|
699
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
700
|
+
if (!response.ok) {
|
|
701
|
+
const error = await response.text();
|
|
702
|
+
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
|
703
|
+
}
|
|
704
|
+
const data = await response.json();
|
|
705
|
+
const message = data.choices?.[0]?.message;
|
|
706
|
+
let content = message?.content ?? (useNewStructuredOutputs2 ? "{}" : "}");
|
|
707
|
+
if (!useNewStructuredOutputs2) {
|
|
708
|
+
content = "{" + content;
|
|
709
|
+
}
|
|
710
|
+
const reasoning = message?.reasoning;
|
|
711
|
+
const reasoning_details = message?.reasoning_details;
|
|
712
|
+
content = content.replace(/^```json\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
|
|
713
|
+
content = content.replace(/\*\*/g, "").replace(/\*/g, "");
|
|
714
|
+
const firstBrace = content.indexOf("{");
|
|
715
|
+
if (firstBrace !== -1) {
|
|
716
|
+
let braceCount = 0;
|
|
717
|
+
let jsonEnd = -1;
|
|
718
|
+
for (let i = firstBrace; i < content.length; i++) {
|
|
719
|
+
if (content[i] === "{") braceCount++;
|
|
720
|
+
if (content[i] === "}") braceCount--;
|
|
721
|
+
if (braceCount === 0) {
|
|
722
|
+
jsonEnd = i + 1;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (jsonEnd !== -1) {
|
|
727
|
+
content = content.substring(firstBrace, jsonEnd);
|
|
728
|
+
}
|
|
729
|
+
} else if (!content.startsWith("[")) {
|
|
730
|
+
throw new Error(`Claude did not return JSON. Response: ${content.substring(0, 200)}`);
|
|
731
|
+
}
|
|
732
|
+
content = content.trim();
|
|
733
|
+
parsed = safeJsonParse2(content);
|
|
734
|
+
if (mode === "relaxed" && this.looksLikeUnwrappedProperties(parsed)) {
|
|
735
|
+
parsed = this.wrapAsSchema(parsed);
|
|
736
|
+
}
|
|
737
|
+
inputTokens = data.usage?.prompt_tokens;
|
|
738
|
+
outputTokens = data.usage?.completion_tokens;
|
|
739
|
+
costUSD = data.usage?.total_cost ?? data.usage?.cost;
|
|
740
|
+
const cacheCreationInputTokens = data.usage?.cache_creation_input_tokens;
|
|
741
|
+
const cacheReadInputTokens = data.usage?.cache_read_input_tokens;
|
|
742
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
743
|
+
console.log("[AnthropicProvider] OpenRouter usage response:", JSON.stringify(data.usage, null, 2));
|
|
744
|
+
console.log("[AnthropicProvider] Extracted costUSD:", costUSD);
|
|
745
|
+
console.log("[AnthropicProvider] Cache creation tokens:", cacheCreationInputTokens);
|
|
746
|
+
console.log("[AnthropicProvider] Cache read tokens:", cacheReadInputTokens);
|
|
747
|
+
}
|
|
748
|
+
const latencyMs = Date.now() - startTime;
|
|
749
|
+
const baseProvider = extractProviderFromModel2(this.config.model, "anthropic");
|
|
750
|
+
return {
|
|
751
|
+
json: parsed,
|
|
752
|
+
rawText: JSON.stringify(parsed),
|
|
753
|
+
metrics: {
|
|
754
|
+
costUSD,
|
|
755
|
+
inputTokens,
|
|
756
|
+
outputTokens,
|
|
757
|
+
latencyMs,
|
|
758
|
+
attemptNumber: 1,
|
|
759
|
+
provider: baseProvider,
|
|
760
|
+
// Base provider (e.g., "anthropic" from "anthropic/claude-...")
|
|
761
|
+
model: this.config.model,
|
|
762
|
+
cacheCreationInputTokens,
|
|
763
|
+
cacheReadInputTokens
|
|
764
|
+
},
|
|
765
|
+
reasoning,
|
|
766
|
+
reasoning_details
|
|
767
|
+
};
|
|
768
|
+
} else {
|
|
769
|
+
const endpoint = this.config.baseUrl || "https://api.anthropic.com/v1";
|
|
770
|
+
const headers = {
|
|
771
|
+
"Content-Type": "application/json",
|
|
772
|
+
"x-api-key": this.config.apiKey,
|
|
773
|
+
"anthropic-version": "2023-06-01"
|
|
774
|
+
};
|
|
775
|
+
if (useNewStructuredOutputs) {
|
|
776
|
+
headers["anthropic-beta"] = "structured-outputs-2025-11-13";
|
|
777
|
+
}
|
|
778
|
+
response = await fetchWithTimeout2(`${endpoint}/messages`, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers,
|
|
781
|
+
body: JSON.stringify(requestBody)
|
|
782
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
783
|
+
if (!response.ok) {
|
|
784
|
+
const error = await response.text();
|
|
785
|
+
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
|
786
|
+
}
|
|
787
|
+
const data = await response.json();
|
|
788
|
+
if (mode === "relaxed") {
|
|
789
|
+
const textBlock = data.content?.find((block) => block.type === "text");
|
|
790
|
+
if (!textBlock || !textBlock.text) {
|
|
791
|
+
throw new Error("Claude did not return structured output (relaxed mode)");
|
|
792
|
+
}
|
|
793
|
+
let content = "{" + textBlock.text;
|
|
794
|
+
const firstBrace = content.indexOf("{");
|
|
795
|
+
if (firstBrace !== -1) {
|
|
796
|
+
let braceCount = 0;
|
|
797
|
+
let jsonEnd = -1;
|
|
798
|
+
for (let i = firstBrace; i < content.length; i++) {
|
|
799
|
+
if (content[i] === "{") braceCount++;
|
|
800
|
+
if (content[i] === "}") braceCount--;
|
|
801
|
+
if (braceCount === 0) {
|
|
802
|
+
jsonEnd = i + 1;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (jsonEnd !== -1) {
|
|
807
|
+
content = content.substring(firstBrace, jsonEnd);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
parsed = safeJsonParse2(content);
|
|
811
|
+
} else if (useNewStructuredOutputs) {
|
|
812
|
+
const textBlock = data.content?.find((block) => block.type === "text");
|
|
813
|
+
if (!textBlock || !textBlock.text) {
|
|
814
|
+
throw new Error("Claude did not return structured output via new API");
|
|
815
|
+
}
|
|
816
|
+
parsed = safeJsonParse2(textBlock.text);
|
|
817
|
+
} else {
|
|
818
|
+
const toolUseBlock = data.content?.find((block) => block.type === "tool_use");
|
|
819
|
+
if (!toolUseBlock || !toolUseBlock.input) {
|
|
820
|
+
throw new Error("Claude did not return structured output via tool calling");
|
|
821
|
+
}
|
|
822
|
+
parsed = toolUseBlock.input;
|
|
823
|
+
}
|
|
824
|
+
inputTokens = data.usage?.input_tokens;
|
|
825
|
+
outputTokens = data.usage?.output_tokens;
|
|
826
|
+
costUSD = this.calculateCost(data.usage);
|
|
827
|
+
const thinkingBlock = data.content?.find((block) => block.type === "thinking");
|
|
828
|
+
const reasoning = thinkingBlock?.thinking;
|
|
829
|
+
const latencyMs = Date.now() - startTime;
|
|
830
|
+
const baseProvider = extractProviderFromModel2(this.config.model, "anthropic");
|
|
831
|
+
return {
|
|
832
|
+
json: parsed,
|
|
833
|
+
rawText: JSON.stringify(parsed),
|
|
834
|
+
metrics: {
|
|
835
|
+
costUSD,
|
|
836
|
+
inputTokens,
|
|
837
|
+
outputTokens,
|
|
838
|
+
latencyMs,
|
|
839
|
+
attemptNumber: 1,
|
|
840
|
+
provider: baseProvider,
|
|
841
|
+
// Base provider (e.g., "anthropic" from "anthropic/claude-...")
|
|
842
|
+
model: this.config.model
|
|
843
|
+
},
|
|
844
|
+
reasoning,
|
|
845
|
+
reasoning_details: reasoning ? [{
|
|
846
|
+
type: "reasoning.text",
|
|
847
|
+
text: reasoning,
|
|
848
|
+
signature: null,
|
|
849
|
+
id: "thinking-1",
|
|
850
|
+
format: "anthropic-claude-v1"
|
|
851
|
+
}] : void 0
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
buildNativeThinkingConfig(reasoning, max_tokens) {
|
|
856
|
+
if (!reasoning.effort && !reasoning.enabled) {
|
|
857
|
+
return void 0;
|
|
858
|
+
}
|
|
859
|
+
const effort = reasoning.effort || "medium";
|
|
860
|
+
const requestMaxTokens = max_tokens || 4096;
|
|
861
|
+
const effortRatios = { low: 0.2, medium: 0.5, high: 0.8 };
|
|
862
|
+
const ratio = effortRatios[effort];
|
|
863
|
+
const budget_tokens = Math.max(1024, Math.min(32e3, Math.floor(requestMaxTokens * ratio)));
|
|
864
|
+
return {
|
|
865
|
+
type: "enabled",
|
|
866
|
+
budget_tokens
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
translateToOpenRouterFormat(messages, schema, mode, max_tokens, reasoning) {
|
|
870
|
+
const useNewStructuredOutputs = this.supportsNewStructuredOutputs();
|
|
871
|
+
const systemMessage = {
|
|
872
|
+
role: "system",
|
|
873
|
+
content: mode === "strict" ? "You are a data extraction assistant. You must respond ONLY with valid JSON that matches the provided schema. Do not include any markdown formatting, explanations, or additional text." : "You are a data extraction assistant. You must respond ONLY with valid JSON. Do not include any markdown formatting, explanations, or additional text."
|
|
874
|
+
};
|
|
875
|
+
const messageArray = [systemMessage, ...messages];
|
|
876
|
+
const requestBody = {
|
|
877
|
+
model: this.config.model,
|
|
878
|
+
messages: messageArray,
|
|
879
|
+
// Enable usage tracking for OpenRouter cost info
|
|
880
|
+
usage: {
|
|
881
|
+
include: true
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
if (mode === "relaxed") {
|
|
885
|
+
requestBody.response_format = {
|
|
886
|
+
type: "json_object"
|
|
887
|
+
};
|
|
888
|
+
} else {
|
|
889
|
+
const openRouterSchema = this.translator.toClaudeOpenRouterSchema(schema);
|
|
890
|
+
const fixedSchema = this.fixSchemaForStrictMode(openRouterSchema);
|
|
891
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
892
|
+
console.log("[AnthropicProvider] Original schema:", JSON.stringify(openRouterSchema, null, 2));
|
|
893
|
+
console.log("[AnthropicProvider] Fixed schema:", JSON.stringify(fixedSchema, null, 2));
|
|
894
|
+
}
|
|
895
|
+
if (!useNewStructuredOutputs) {
|
|
896
|
+
messageArray.push({
|
|
897
|
+
role: "assistant",
|
|
898
|
+
content: "{"
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
requestBody.response_format = {
|
|
902
|
+
type: "json_schema",
|
|
903
|
+
json_schema: {
|
|
904
|
+
name: "extraction",
|
|
905
|
+
strict: true,
|
|
906
|
+
schema: fixedSchema
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
if (reasoning) {
|
|
911
|
+
requestBody.reasoning = this.buildReasoningConfig(reasoning, max_tokens);
|
|
912
|
+
}
|
|
913
|
+
return requestBody;
|
|
914
|
+
}
|
|
915
|
+
buildReasoningConfig(reasoning, max_tokens) {
|
|
916
|
+
const config = {};
|
|
917
|
+
if (reasoning.effort || reasoning.enabled) {
|
|
918
|
+
const effort = reasoning.effort || "medium";
|
|
919
|
+
const requestMaxTokens = max_tokens || 4096;
|
|
920
|
+
const effortRatios = { low: 0.2, medium: 0.5, high: 0.8 };
|
|
921
|
+
const ratio = effortRatios[effort];
|
|
922
|
+
const reasoningBudget = Math.max(1024, Math.min(32e3, Math.floor(requestMaxTokens * ratio)));
|
|
923
|
+
config.max_tokens = reasoningBudget;
|
|
924
|
+
}
|
|
925
|
+
if (reasoning.exclude !== void 0) {
|
|
926
|
+
config.exclude = reasoning.exclude;
|
|
927
|
+
}
|
|
928
|
+
return Object.keys(config).length > 0 ? config : void 0;
|
|
929
|
+
}
|
|
930
|
+
supportsNewStructuredOutputs() {
|
|
931
|
+
const model = this.config.model.toLowerCase();
|
|
932
|
+
const sonnetMatch = model.match(/sonnet[_-](\d+)[._-](\d+)/);
|
|
933
|
+
const opusMatch = model.match(/opus[_-](\d+)[._-](\d+)/);
|
|
934
|
+
if (sonnetMatch) {
|
|
935
|
+
const major = parseInt(sonnetMatch[1], 10);
|
|
936
|
+
const minor = parseInt(sonnetMatch[2], 10);
|
|
937
|
+
const version = major + minor / 10;
|
|
938
|
+
return version >= 4.5;
|
|
939
|
+
}
|
|
940
|
+
if (opusMatch) {
|
|
941
|
+
const major = parseInt(opusMatch[1], 10);
|
|
942
|
+
const minor = parseInt(opusMatch[2], 10);
|
|
943
|
+
const version = major + minor / 10;
|
|
944
|
+
return version >= 4.1;
|
|
945
|
+
}
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
fixSchemaForStrictMode(schema) {
|
|
949
|
+
const clonedSchema = JSON.parse(JSON.stringify(schema));
|
|
950
|
+
if (!clonedSchema.type) {
|
|
951
|
+
clonedSchema.type = "object";
|
|
952
|
+
}
|
|
953
|
+
const fixRecursive = (obj) => {
|
|
954
|
+
if (!obj || typeof obj !== "object") {
|
|
955
|
+
return obj;
|
|
956
|
+
}
|
|
957
|
+
if (obj.type === "object") {
|
|
958
|
+
obj.additionalProperties = false;
|
|
959
|
+
if (obj.properties) {
|
|
960
|
+
const allProps = Object.keys(obj.properties);
|
|
961
|
+
obj.required = allProps;
|
|
962
|
+
for (const key in obj.properties) {
|
|
963
|
+
obj.properties[key] = fixRecursive(obj.properties[key]);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (obj.type === "array" && obj.items) {
|
|
968
|
+
obj.items = fixRecursive(obj.items);
|
|
969
|
+
}
|
|
970
|
+
["anyOf", "oneOf", "allOf"].forEach((keyword) => {
|
|
971
|
+
if (obj[keyword] && Array.isArray(obj[keyword])) {
|
|
972
|
+
obj[keyword] = obj[keyword].map((s) => fixRecursive(s));
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
return obj;
|
|
976
|
+
};
|
|
977
|
+
return fixRecursive(clonedSchema);
|
|
978
|
+
}
|
|
979
|
+
async buildMessages(input) {
|
|
980
|
+
const content = [];
|
|
981
|
+
const hasMedia = input.images && input.images.length > 0 || input.pdfs && input.pdfs.length > 0;
|
|
982
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
983
|
+
console.log("[AnthropicProvider.buildMessages] Input state:");
|
|
984
|
+
console.log(" hasMedia:", hasMedia);
|
|
985
|
+
console.log(" input.images:", input.images?.length || 0);
|
|
986
|
+
console.log(" input.pdfs:", input.pdfs?.length || 0);
|
|
987
|
+
console.log(" input.text:", input.text ? `"${input.text.substring(0, 50)}..."` : "undefined");
|
|
988
|
+
console.log(" via:", this.config.via);
|
|
989
|
+
}
|
|
990
|
+
if (this.config.via === "openrouter") {
|
|
991
|
+
if (input.images && input.images.length > 0) {
|
|
992
|
+
for (const image of input.images) {
|
|
993
|
+
if (image.url) {
|
|
994
|
+
content.push({
|
|
995
|
+
type: "image_url",
|
|
996
|
+
image_url: { url: image.url }
|
|
997
|
+
});
|
|
998
|
+
} else if (image.base64) {
|
|
999
|
+
const actualMimeType = detectMimeTypeFromBase64(image.base64);
|
|
1000
|
+
if (image.mimeType && image.mimeType !== actualMimeType) {
|
|
1001
|
+
console.warn(
|
|
1002
|
+
`[AnthropicProvider] MIME type mismatch detected: declared "${image.mimeType}", actual "${actualMimeType}". Using detected type "${actualMimeType}" to prevent API errors.`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
content.push({
|
|
1006
|
+
type: "image_url",
|
|
1007
|
+
image_url: {
|
|
1008
|
+
url: `data:${actualMimeType};base64,${this.extractBase64(image.base64)}`
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (input.pdfs && input.pdfs.length > 0) {
|
|
1015
|
+
for (const pdf of input.pdfs) {
|
|
1016
|
+
let fileData;
|
|
1017
|
+
if (pdf.url) {
|
|
1018
|
+
fileData = pdf.url;
|
|
1019
|
+
} else if (pdf.base64) {
|
|
1020
|
+
const actualMimeType = detectMimeTypeFromBase64(pdf.base64);
|
|
1021
|
+
if (actualMimeType !== "application/pdf") {
|
|
1022
|
+
console.warn(
|
|
1023
|
+
`[AnthropicProvider] PDF MIME type mismatch: expected "application/pdf", detected "${actualMimeType}". Using detected type.`
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
fileData = `data:${actualMimeType};base64,${this.extractBase64(pdf.base64)}`;
|
|
1027
|
+
} else {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
content.push({
|
|
1031
|
+
type: "file",
|
|
1032
|
+
file: {
|
|
1033
|
+
filename: "document.pdf",
|
|
1034
|
+
file_data: fileData
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (hasMedia) {
|
|
1040
|
+
const textContent = input.text || "Extract the requested information from the document.";
|
|
1041
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1042
|
+
console.log("[AnthropicProvider.buildMessages] Adding text block with cache_control");
|
|
1043
|
+
console.log(" textContent:", textContent);
|
|
1044
|
+
}
|
|
1045
|
+
content.push({
|
|
1046
|
+
type: "text",
|
|
1047
|
+
text: textContent,
|
|
1048
|
+
cache_control: { type: "ephemeral" }
|
|
1049
|
+
});
|
|
1050
|
+
} else if (input.text) {
|
|
1051
|
+
content.push({
|
|
1052
|
+
type: "text",
|
|
1053
|
+
text: input.text
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
if (input.text) {
|
|
1058
|
+
content.push({ type: "text", text: input.text });
|
|
1059
|
+
}
|
|
1060
|
+
if (input.images && input.images.length > 0) {
|
|
1061
|
+
for (const image of input.images) {
|
|
1062
|
+
if (image.url) {
|
|
1063
|
+
const base64 = await this.urlToBase64(image.url);
|
|
1064
|
+
content.push({
|
|
1065
|
+
type: "image",
|
|
1066
|
+
source: {
|
|
1067
|
+
type: "base64",
|
|
1068
|
+
media_type: image.mimeType,
|
|
1069
|
+
data: base64
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
} else if (image.base64) {
|
|
1073
|
+
content.push({
|
|
1074
|
+
type: "image",
|
|
1075
|
+
source: {
|
|
1076
|
+
type: "base64",
|
|
1077
|
+
media_type: image.mimeType,
|
|
1078
|
+
data: this.extractBase64(image.base64)
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (input.pdfs && input.pdfs.length > 0) {
|
|
1085
|
+
for (const pdf of input.pdfs) {
|
|
1086
|
+
if (pdf.fileId) {
|
|
1087
|
+
content.push({
|
|
1088
|
+
type: "document",
|
|
1089
|
+
source: {
|
|
1090
|
+
type: "file",
|
|
1091
|
+
file_id: pdf.fileId
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
} else if (pdf.base64) {
|
|
1095
|
+
content.push({
|
|
1096
|
+
type: "document",
|
|
1097
|
+
source: {
|
|
1098
|
+
type: "base64",
|
|
1099
|
+
media_type: "application/pdf",
|
|
1100
|
+
data: this.extractBase64(pdf.base64)
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
} else if (pdf.url) {
|
|
1104
|
+
const base64 = await this.urlToBase64(pdf.url);
|
|
1105
|
+
content.push({
|
|
1106
|
+
type: "document",
|
|
1107
|
+
source: {
|
|
1108
|
+
type: "base64",
|
|
1109
|
+
media_type: "application/pdf",
|
|
1110
|
+
data: base64
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1118
|
+
console.log("[AnthropicProvider.buildMessages] Final content array length:", content.length);
|
|
1119
|
+
console.log("[AnthropicProvider.buildMessages] Final content array:", JSON.stringify(content, null, 2));
|
|
1120
|
+
}
|
|
1121
|
+
return [{ role: "user", content }];
|
|
1122
|
+
}
|
|
1123
|
+
async urlToBase64(url) {
|
|
1124
|
+
validateUrl(url);
|
|
1125
|
+
const response = await fetchWithTimeout2(url, {}, this.limits.REQUEST_TIMEOUT);
|
|
1126
|
+
if (!response.ok) {
|
|
1127
|
+
throw new Error(`Failed to fetch URL: ${url}`);
|
|
1128
|
+
}
|
|
1129
|
+
const buffer = await response.arrayBuffer();
|
|
1130
|
+
return Buffer.from(buffer).toString("base64");
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Extract base64 data from a data URL or return as-is if already raw base64
|
|
1134
|
+
*/
|
|
1135
|
+
extractBase64(input) {
|
|
1136
|
+
if (input.startsWith("data:")) {
|
|
1137
|
+
const base64Part = input.split(",")[1];
|
|
1138
|
+
if (!base64Part) {
|
|
1139
|
+
throw new Error(`Invalid data URL format: ${input.substring(0, 50)}`);
|
|
1140
|
+
}
|
|
1141
|
+
return base64Part;
|
|
1142
|
+
}
|
|
1143
|
+
return input;
|
|
1144
|
+
}
|
|
1145
|
+
calculateCost(usage) {
|
|
1146
|
+
if (!usage) return void 0;
|
|
1147
|
+
const inputCostPer1k = 3e-3;
|
|
1148
|
+
const outputCostPer1k = 0.015;
|
|
1149
|
+
const inputCost = usage.input_tokens / 1e3 * inputCostPer1k;
|
|
1150
|
+
const outputCost = usage.output_tokens / 1e3 * outputCostPer1k;
|
|
1151
|
+
return inputCost + outputCost;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Detect if a parsed JSON object looks like unwrapped JSON Schema properties
|
|
1155
|
+
* (e.g., missing the root "type": "object" and "properties": {...} wrapper)
|
|
1156
|
+
*/
|
|
1157
|
+
looksLikeUnwrappedProperties(obj) {
|
|
1158
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
if (obj.type === "object" && obj.properties) {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
const keys = Object.keys(obj);
|
|
1165
|
+
if (keys.length === 0) {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
let schemaPropertyCount = 0;
|
|
1169
|
+
for (const key of keys) {
|
|
1170
|
+
const value = obj[key];
|
|
1171
|
+
if (value && typeof value === "object" && "type" in value) {
|
|
1172
|
+
schemaPropertyCount++;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return schemaPropertyCount / keys.length > 0.5;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Wrap unwrapped properties into a proper JSON Schema structure
|
|
1179
|
+
*/
|
|
1180
|
+
wrapAsSchema(unwrapped) {
|
|
1181
|
+
const required = [];
|
|
1182
|
+
for (const [key, value] of Object.entries(unwrapped)) {
|
|
1183
|
+
if (value && typeof value === "object") {
|
|
1184
|
+
const propDef = value;
|
|
1185
|
+
if (!propDef.type || (propDef.type === "string" || propDef.type === "number" || propDef.type === "boolean" || propDef.type === "integer")) {
|
|
1186
|
+
required.push(key);
|
|
1187
|
+
} else if (propDef.type === "object" || propDef.type === "array") {
|
|
1188
|
+
required.push(key);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
type: "object",
|
|
1194
|
+
properties: unwrapped,
|
|
1195
|
+
required
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
// src/providers/google.ts
|
|
1201
|
+
import { fetchWithTimeout as fetchWithTimeout3, validateUrl as validateUrl2, DEFAULT_LIMITS as DEFAULT_LIMITS3, safeJsonParse as safeJsonParse3 } from "@doclo/core/security";
|
|
1202
|
+
function extractProviderFromModel3(model, defaultProvider) {
|
|
1203
|
+
const slashIndex = model.indexOf("/");
|
|
1204
|
+
return slashIndex > 0 ? model.substring(0, slashIndex) : defaultProvider;
|
|
1205
|
+
}
|
|
1206
|
+
var GoogleProvider = class {
|
|
1207
|
+
name;
|
|
1208
|
+
capabilities = {
|
|
1209
|
+
supportsStructuredOutput: true,
|
|
1210
|
+
supportsStreaming: true,
|
|
1211
|
+
supportsImages: true,
|
|
1212
|
+
supportsPDFs: true,
|
|
1213
|
+
maxPDFPages: 1e3,
|
|
1214
|
+
// 1 page = 1 image
|
|
1215
|
+
maxPDFSize: 50,
|
|
1216
|
+
maxContextTokens: 1e6
|
|
1217
|
+
// 1M tokens
|
|
1218
|
+
};
|
|
1219
|
+
config;
|
|
1220
|
+
translator;
|
|
1221
|
+
limits;
|
|
1222
|
+
constructor(config) {
|
|
1223
|
+
this.config = config;
|
|
1224
|
+
const baseProvider = extractProviderFromModel3(config.model, "google");
|
|
1225
|
+
this.name = `${baseProvider}:${config.model}`;
|
|
1226
|
+
this.translator = new SchemaTranslator();
|
|
1227
|
+
this.limits = {
|
|
1228
|
+
...DEFAULT_LIMITS3,
|
|
1229
|
+
...config.limits || {}
|
|
1230
|
+
};
|
|
1231
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1232
|
+
console.log("[GoogleProvider] Config:", JSON.stringify({
|
|
1233
|
+
provider: config.provider,
|
|
1234
|
+
model: config.model,
|
|
1235
|
+
via: config.via,
|
|
1236
|
+
hasApiKey: !!config.apiKey
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
async completeJson(params) {
|
|
1241
|
+
const startTime = Date.now();
|
|
1242
|
+
const mode = params.mode || (params.schema ? "strict" : "relaxed");
|
|
1243
|
+
if (mode === "strict" && !params.schema) {
|
|
1244
|
+
throw new Error('schema is required when mode is "strict"');
|
|
1245
|
+
}
|
|
1246
|
+
const shouldEmbedSchema = params.embedSchemaInPrompt !== false && params.schema;
|
|
1247
|
+
let enhancedInput = params.input;
|
|
1248
|
+
if (shouldEmbedSchema) {
|
|
1249
|
+
const jsonSchema = this.translator.convertZodIfNeeded(params.schema);
|
|
1250
|
+
const enhancedText = combineSchemaAndUserPrompt(
|
|
1251
|
+
jsonSchema,
|
|
1252
|
+
params.input.text || ""
|
|
1253
|
+
);
|
|
1254
|
+
enhancedInput = {
|
|
1255
|
+
...params.input,
|
|
1256
|
+
text: enhancedText
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
const contents = await this.buildContents(enhancedInput);
|
|
1260
|
+
const requestBody = {
|
|
1261
|
+
contents,
|
|
1262
|
+
generationConfig: {
|
|
1263
|
+
// Google's native responseSchema has strict validation issues with complex schemas.
|
|
1264
|
+
// Use JSON mode without responseSchema - schema is already in the prompt via combineSchemaAndUserPrompt.
|
|
1265
|
+
// See: https://ubaidullahmomer.medium.com/why-google-geminis-response-schema-isn-t-ready-for-complex-json-46f35c3aaaea
|
|
1266
|
+
responseMimeType: "application/json"
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1270
|
+
console.log(`[GoogleProvider] Using ${mode} JSON mode (schema in prompt, no responseSchema)`);
|
|
1271
|
+
}
|
|
1272
|
+
if (this.config.via !== "openrouter" && params.reasoning) {
|
|
1273
|
+
const thinkingConfig = this.buildNativeThinkingConfig(params.reasoning, params.max_tokens);
|
|
1274
|
+
if (thinkingConfig) {
|
|
1275
|
+
requestBody.generationConfig.thinking_config = thinkingConfig;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
let response;
|
|
1279
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1280
|
+
console.log("[GoogleProvider] Using via:", this.config.via, "Checking:", this.config.via === "openrouter");
|
|
1281
|
+
}
|
|
1282
|
+
if (this.config.via === "openrouter") {
|
|
1283
|
+
const openRouterRequest = this.translateToOpenRouterFormat(contents, mode, params.max_tokens, params.reasoning);
|
|
1284
|
+
response = await fetchWithTimeout3("https://openrouter.ai/api/v1/chat/completions", {
|
|
1285
|
+
method: "POST",
|
|
1286
|
+
headers: {
|
|
1287
|
+
"Content-Type": "application/json",
|
|
1288
|
+
"Authorization": `Bearer ${this.config.apiKey}`,
|
|
1289
|
+
"HTTP-Referer": "https://github.com/docloai/sdk",
|
|
1290
|
+
"X-Title": "Doclo SDK"
|
|
1291
|
+
},
|
|
1292
|
+
body: JSON.stringify(openRouterRequest)
|
|
1293
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
1294
|
+
} else {
|
|
1295
|
+
const endpoint = this.config.baseUrl || `https://generativelanguage.googleapis.com/v1beta/models/${this.config.model}:generateContent`;
|
|
1296
|
+
validateUrl2(endpoint);
|
|
1297
|
+
response = await fetchWithTimeout3(endpoint, {
|
|
1298
|
+
method: "POST",
|
|
1299
|
+
headers: {
|
|
1300
|
+
"Content-Type": "application/json",
|
|
1301
|
+
"x-goog-api-key": this.config.apiKey
|
|
1302
|
+
// Use header instead of query param
|
|
1303
|
+
},
|
|
1304
|
+
body: JSON.stringify(requestBody)
|
|
1305
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
1306
|
+
}
|
|
1307
|
+
if (!response.ok) {
|
|
1308
|
+
const error = await response.text();
|
|
1309
|
+
throw new Error(`Google API error (${response.status}): ${error}`);
|
|
1310
|
+
}
|
|
1311
|
+
const data = await response.json();
|
|
1312
|
+
const latencyMs = Date.now() - startTime;
|
|
1313
|
+
let content;
|
|
1314
|
+
let inputTokens;
|
|
1315
|
+
let outputTokens;
|
|
1316
|
+
let costUSD;
|
|
1317
|
+
if (this.config.via === "openrouter") {
|
|
1318
|
+
const message = data.choices?.[0]?.message;
|
|
1319
|
+
content = message?.content?.trim() || "{}";
|
|
1320
|
+
inputTokens = data.usage?.prompt_tokens;
|
|
1321
|
+
outputTokens = data.usage?.completion_tokens;
|
|
1322
|
+
costUSD = data.usage?.total_cost ?? data.usage?.cost;
|
|
1323
|
+
const reasoning = message?.reasoning;
|
|
1324
|
+
const reasoning_details = message?.reasoning_details;
|
|
1325
|
+
content = content.replace(/^```json\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
|
|
1326
|
+
const parsed = safeJsonParse3(content);
|
|
1327
|
+
const baseProvider = extractProviderFromModel3(this.config.model, "google");
|
|
1328
|
+
return {
|
|
1329
|
+
json: parsed,
|
|
1330
|
+
rawText: content,
|
|
1331
|
+
metrics: {
|
|
1332
|
+
costUSD,
|
|
1333
|
+
inputTokens,
|
|
1334
|
+
outputTokens,
|
|
1335
|
+
latencyMs,
|
|
1336
|
+
attemptNumber: 1,
|
|
1337
|
+
provider: baseProvider,
|
|
1338
|
+
// Base provider (e.g., "google" from "google/gemini-...")
|
|
1339
|
+
model: this.config.model
|
|
1340
|
+
},
|
|
1341
|
+
reasoning,
|
|
1342
|
+
reasoning_details
|
|
1343
|
+
};
|
|
1344
|
+
} else {
|
|
1345
|
+
const candidate = data.candidates?.[0];
|
|
1346
|
+
content = candidate?.content?.parts?.[0]?.text?.trim() || "{}";
|
|
1347
|
+
inputTokens = data.usageMetadata?.promptTokenCount;
|
|
1348
|
+
outputTokens = data.usageMetadata?.candidatesTokenCount;
|
|
1349
|
+
costUSD = this.calculateCost(data.usageMetadata);
|
|
1350
|
+
const thinkingPart = candidate?.content?.parts?.find((part) => part.thought === true);
|
|
1351
|
+
const reasoning = thinkingPart?.text;
|
|
1352
|
+
const parsed = safeJsonParse3(content);
|
|
1353
|
+
const baseProvider = extractProviderFromModel3(this.config.model, "google");
|
|
1354
|
+
return {
|
|
1355
|
+
json: parsed,
|
|
1356
|
+
rawText: content,
|
|
1357
|
+
metrics: {
|
|
1358
|
+
costUSD,
|
|
1359
|
+
inputTokens,
|
|
1360
|
+
outputTokens,
|
|
1361
|
+
latencyMs,
|
|
1362
|
+
attemptNumber: 1,
|
|
1363
|
+
provider: baseProvider,
|
|
1364
|
+
// Base provider (e.g., "google" from "google/gemini-...")
|
|
1365
|
+
model: this.config.model
|
|
1366
|
+
},
|
|
1367
|
+
reasoning,
|
|
1368
|
+
reasoning_details: reasoning ? [{
|
|
1369
|
+
type: "reasoning.text",
|
|
1370
|
+
text: reasoning,
|
|
1371
|
+
signature: null,
|
|
1372
|
+
id: "thinking-1",
|
|
1373
|
+
format: "google-gemini-v1"
|
|
1374
|
+
}] : void 0
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
buildNativeThinkingConfig(reasoning, max_tokens) {
|
|
1379
|
+
if (!reasoning.effort && !reasoning.enabled) {
|
|
1380
|
+
return void 0;
|
|
1381
|
+
}
|
|
1382
|
+
const effort = reasoning.effort || "medium";
|
|
1383
|
+
const requestMaxTokens = max_tokens || 8192;
|
|
1384
|
+
const effortRatios = { low: 0.2, medium: 0.5, high: 0.8 };
|
|
1385
|
+
const ratio = effortRatios[effort];
|
|
1386
|
+
const thinking_budget = Math.min(24576, Math.floor(requestMaxTokens * ratio));
|
|
1387
|
+
return {
|
|
1388
|
+
thinking_budget
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
translateToOpenRouterFormat(contents, mode, max_tokens, reasoning) {
|
|
1392
|
+
const messages = [];
|
|
1393
|
+
for (const content of contents) {
|
|
1394
|
+
if (content.role === "user") {
|
|
1395
|
+
const messageContent = [];
|
|
1396
|
+
for (const part of content.parts) {
|
|
1397
|
+
if (!part) continue;
|
|
1398
|
+
if (part.text) {
|
|
1399
|
+
messageContent.push({ type: "text", text: part.text });
|
|
1400
|
+
} else if (part.inlineData) {
|
|
1401
|
+
if (part.inlineData.mimeType === "application/pdf") {
|
|
1402
|
+
messageContent.push({
|
|
1403
|
+
type: "file",
|
|
1404
|
+
file: {
|
|
1405
|
+
filename: "document.pdf",
|
|
1406
|
+
file_data: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
} else {
|
|
1410
|
+
messageContent.push({
|
|
1411
|
+
type: "image_url",
|
|
1412
|
+
image_url: {
|
|
1413
|
+
url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
messages.push({
|
|
1420
|
+
role: "user",
|
|
1421
|
+
content: messageContent.length === 1 && messageContent[0].type === "text" ? messageContent[0].text : messageContent
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const requestBody = {
|
|
1426
|
+
model: this.config.model,
|
|
1427
|
+
messages,
|
|
1428
|
+
// Enable usage tracking for OpenRouter cost info
|
|
1429
|
+
usage: {
|
|
1430
|
+
include: true
|
|
1431
|
+
},
|
|
1432
|
+
// Both relaxed and strict modes use json_object for Google via OpenRouter
|
|
1433
|
+
// Schema is already in the prompt via combineSchemaAndUserPrompt
|
|
1434
|
+
response_format: {
|
|
1435
|
+
type: "json_object"
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
if (reasoning) {
|
|
1439
|
+
requestBody.reasoning = this.buildReasoningConfig(reasoning, max_tokens);
|
|
1440
|
+
}
|
|
1441
|
+
return requestBody;
|
|
1442
|
+
}
|
|
1443
|
+
buildReasoningConfig(reasoning, max_tokens) {
|
|
1444
|
+
const config = {};
|
|
1445
|
+
if (reasoning.effort || reasoning.enabled) {
|
|
1446
|
+
const effort = reasoning.effort || "medium";
|
|
1447
|
+
const requestMaxTokens = max_tokens || 8192;
|
|
1448
|
+
const effortRatios = { low: 0.2, medium: 0.5, high: 0.8 };
|
|
1449
|
+
const ratio = effortRatios[effort];
|
|
1450
|
+
const reasoningBudget = Math.floor(requestMaxTokens * ratio);
|
|
1451
|
+
config.max_tokens = reasoningBudget;
|
|
1452
|
+
}
|
|
1453
|
+
if (reasoning.exclude !== void 0) {
|
|
1454
|
+
config.exclude = reasoning.exclude;
|
|
1455
|
+
}
|
|
1456
|
+
return Object.keys(config).length > 0 ? config : void 0;
|
|
1457
|
+
}
|
|
1458
|
+
async buildContents(input) {
|
|
1459
|
+
const parts = [];
|
|
1460
|
+
if (input.text) {
|
|
1461
|
+
parts.push({ text: input.text });
|
|
1462
|
+
}
|
|
1463
|
+
if (input.images && input.images.length > 0) {
|
|
1464
|
+
for (const image of input.images) {
|
|
1465
|
+
if (image.url) {
|
|
1466
|
+
const base64 = await this.urlToBase64(image.url);
|
|
1467
|
+
parts.push({
|
|
1468
|
+
inlineData: {
|
|
1469
|
+
mimeType: image.mimeType,
|
|
1470
|
+
data: base64
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
} else if (image.base64) {
|
|
1474
|
+
parts.push({
|
|
1475
|
+
inlineData: {
|
|
1476
|
+
mimeType: image.mimeType,
|
|
1477
|
+
data: this.extractBase64(image.base64)
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (input.pdfs && input.pdfs.length > 0) {
|
|
1484
|
+
for (const pdf of input.pdfs) {
|
|
1485
|
+
if (pdf.fileId) {
|
|
1486
|
+
parts.push({
|
|
1487
|
+
fileData: {
|
|
1488
|
+
fileUri: `https://generativelanguage.googleapis.com/v1beta/files/${pdf.fileId}`,
|
|
1489
|
+
mimeType: "application/pdf"
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
} else if (pdf.base64) {
|
|
1493
|
+
parts.push({
|
|
1494
|
+
inlineData: {
|
|
1495
|
+
mimeType: "application/pdf",
|
|
1496
|
+
data: this.extractBase64(pdf.base64)
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
} else if (pdf.url) {
|
|
1500
|
+
const base64 = await this.urlToBase64(pdf.url);
|
|
1501
|
+
parts.push({
|
|
1502
|
+
inlineData: {
|
|
1503
|
+
mimeType: "application/pdf",
|
|
1504
|
+
data: base64
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return [{ role: "user", parts }];
|
|
1511
|
+
}
|
|
1512
|
+
async urlToBase64(url) {
|
|
1513
|
+
validateUrl2(url);
|
|
1514
|
+
const response = await fetchWithTimeout3(url, {}, this.limits.REQUEST_TIMEOUT);
|
|
1515
|
+
if (!response.ok) {
|
|
1516
|
+
throw new Error(`Failed to fetch URL: ${url}`);
|
|
1517
|
+
}
|
|
1518
|
+
const buffer = await response.arrayBuffer();
|
|
1519
|
+
return Buffer.from(buffer).toString("base64");
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Extract base64 data from a data URL or return as-is if already raw base64
|
|
1523
|
+
*/
|
|
1524
|
+
extractBase64(input) {
|
|
1525
|
+
if (input.startsWith("data:")) {
|
|
1526
|
+
const base64Part = input.split(",")[1];
|
|
1527
|
+
if (!base64Part) {
|
|
1528
|
+
throw new Error(`Invalid data URL format: ${input.substring(0, 50)}`);
|
|
1529
|
+
}
|
|
1530
|
+
return base64Part;
|
|
1531
|
+
}
|
|
1532
|
+
return input;
|
|
1533
|
+
}
|
|
1534
|
+
calculateCost(usage) {
|
|
1535
|
+
if (!usage) return void 0;
|
|
1536
|
+
const inputCostPer1k = 25e-5;
|
|
1537
|
+
const outputCostPer1k = 1e-3;
|
|
1538
|
+
const inputCost = usage.promptTokenCount / 1e3 * inputCostPer1k;
|
|
1539
|
+
const outputCost = usage.candidatesTokenCount / 1e3 * outputCostPer1k;
|
|
1540
|
+
return inputCost + outputCost;
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
// src/providers/xai.ts
|
|
1545
|
+
import { fetchWithTimeout as fetchWithTimeout4, DEFAULT_LIMITS as DEFAULT_LIMITS4, safeJsonParse as safeJsonParse4 } from "@doclo/core/security";
|
|
1546
|
+
function extractProviderFromModel4(model, defaultProvider) {
|
|
1547
|
+
const slashIndex = model.indexOf("/");
|
|
1548
|
+
return slashIndex > 0 ? model.substring(0, slashIndex) : defaultProvider;
|
|
1549
|
+
}
|
|
1550
|
+
var XAIProvider = class {
|
|
1551
|
+
name;
|
|
1552
|
+
capabilities = {
|
|
1553
|
+
supportsStructuredOutput: true,
|
|
1554
|
+
supportsStreaming: false,
|
|
1555
|
+
// Not with structured outputs
|
|
1556
|
+
supportsImages: true,
|
|
1557
|
+
supportsPDFs: true,
|
|
1558
|
+
// via page-by-page images
|
|
1559
|
+
maxPDFPages: void 0,
|
|
1560
|
+
maxPDFSize: void 0,
|
|
1561
|
+
maxContextTokens: 131072
|
|
1562
|
+
};
|
|
1563
|
+
config;
|
|
1564
|
+
translator;
|
|
1565
|
+
limits;
|
|
1566
|
+
constructor(config) {
|
|
1567
|
+
this.config = config;
|
|
1568
|
+
const baseProvider = extractProviderFromModel4(config.model, "xai");
|
|
1569
|
+
this.name = `${baseProvider}:${config.model}`;
|
|
1570
|
+
this.translator = new SchemaTranslator();
|
|
1571
|
+
this.limits = {
|
|
1572
|
+
...DEFAULT_LIMITS4,
|
|
1573
|
+
...config.limits || {}
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
async completeJson(params) {
|
|
1577
|
+
const startTime = Date.now();
|
|
1578
|
+
const mode = params.mode || (params.schema ? "strict" : "relaxed");
|
|
1579
|
+
if (mode === "strict" && !params.schema) {
|
|
1580
|
+
throw new Error('schema is required when mode is "strict"');
|
|
1581
|
+
}
|
|
1582
|
+
const shouldEmbedSchema = params.embedSchemaInPrompt !== false && params.schema;
|
|
1583
|
+
let enhancedInput = params.input;
|
|
1584
|
+
if (shouldEmbedSchema) {
|
|
1585
|
+
const jsonSchema = this.translator.convertZodIfNeeded(params.schema);
|
|
1586
|
+
const enhancedText = combineSchemaAndUserPrompt(
|
|
1587
|
+
jsonSchema,
|
|
1588
|
+
params.input.text || ""
|
|
1589
|
+
);
|
|
1590
|
+
enhancedInput = {
|
|
1591
|
+
...params.input,
|
|
1592
|
+
text: enhancedText
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
const messages = await this.buildMessages(enhancedInput);
|
|
1596
|
+
const requestBody = {
|
|
1597
|
+
model: this.config.model,
|
|
1598
|
+
messages,
|
|
1599
|
+
max_tokens: params.max_tokens || 4096,
|
|
1600
|
+
stream: false,
|
|
1601
|
+
// Structured output doesn't support streaming
|
|
1602
|
+
// Enable usage tracking for OpenRouter cost info
|
|
1603
|
+
usage: {
|
|
1604
|
+
include: true
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
if (mode === "relaxed") {
|
|
1608
|
+
requestBody.response_format = {
|
|
1609
|
+
type: "json_object"
|
|
1610
|
+
};
|
|
1611
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1612
|
+
console.log("[XAIProvider] Using relaxed JSON mode (json_object)");
|
|
1613
|
+
}
|
|
1614
|
+
} else {
|
|
1615
|
+
const schema = this.translator.toOpenAISchema(params.schema);
|
|
1616
|
+
const fixSchemaRecursive = (obj) => {
|
|
1617
|
+
if (obj && typeof obj === "object") {
|
|
1618
|
+
if (obj.type === "object" && obj.properties) {
|
|
1619
|
+
const allProps = Object.keys(obj.properties);
|
|
1620
|
+
obj.required = allProps;
|
|
1621
|
+
obj.additionalProperties = false;
|
|
1622
|
+
for (const key in obj.properties) {
|
|
1623
|
+
obj.properties[key] = fixSchemaRecursive(obj.properties[key]);
|
|
1624
|
+
}
|
|
1625
|
+
} else if (obj.type === "array" && obj.items) {
|
|
1626
|
+
obj.items = fixSchemaRecursive(obj.items);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return obj;
|
|
1630
|
+
};
|
|
1631
|
+
fixSchemaRecursive(schema);
|
|
1632
|
+
if (process.env.DEBUG_PROVIDERS) {
|
|
1633
|
+
console.log("[XAIProvider] Using strict JSON mode (json_schema)");
|
|
1634
|
+
}
|
|
1635
|
+
requestBody.response_format = {
|
|
1636
|
+
type: "json_schema",
|
|
1637
|
+
json_schema: {
|
|
1638
|
+
name: "extraction",
|
|
1639
|
+
schema
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
if (params.reasoning) {
|
|
1644
|
+
requestBody.reasoning = this.buildReasoningConfig(params.reasoning);
|
|
1645
|
+
}
|
|
1646
|
+
if (this.config.via === "openrouter" && mode === "strict") {
|
|
1647
|
+
requestBody.provider = {
|
|
1648
|
+
require_parameters: true
|
|
1649
|
+
// Only route to models supporting json_schema
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
const endpoint = this.config.via === "openrouter" ? "https://openrouter.ai/api/v1" : this.config.baseUrl || "https://api.x.ai/v1";
|
|
1653
|
+
const headers = {
|
|
1654
|
+
"Content-Type": "application/json",
|
|
1655
|
+
"Authorization": `Bearer ${this.config.apiKey}`
|
|
1656
|
+
};
|
|
1657
|
+
if (this.config.via === "openrouter") {
|
|
1658
|
+
headers["HTTP-Referer"] = "https://github.com/docloai/sdk";
|
|
1659
|
+
headers["X-Title"] = "Doclo SDK";
|
|
1660
|
+
}
|
|
1661
|
+
const response = await fetchWithTimeout4(`${endpoint}/chat/completions`, {
|
|
1662
|
+
method: "POST",
|
|
1663
|
+
headers,
|
|
1664
|
+
body: JSON.stringify(requestBody)
|
|
1665
|
+
}, this.limits.REQUEST_TIMEOUT);
|
|
1666
|
+
if (!response.ok) {
|
|
1667
|
+
const error = await response.text();
|
|
1668
|
+
throw new Error(`xAI API error (${response.status}): ${error}`);
|
|
1669
|
+
}
|
|
1670
|
+
const data = await response.json();
|
|
1671
|
+
const latencyMs = Date.now() - startTime;
|
|
1672
|
+
const message = data.choices?.[0]?.message;
|
|
1673
|
+
const content = message?.content ?? "{}";
|
|
1674
|
+
const parsed = safeJsonParse4(content);
|
|
1675
|
+
const reasoning = message?.reasoning;
|
|
1676
|
+
const reasoning_details = message?.reasoning_details;
|
|
1677
|
+
let costUSD;
|
|
1678
|
+
if (this.config.via === "openrouter") {
|
|
1679
|
+
costUSD = data.usage?.total_cost ?? data.usage?.cost;
|
|
1680
|
+
} else {
|
|
1681
|
+
costUSD = this.calculateCost(data.usage);
|
|
1682
|
+
}
|
|
1683
|
+
const baseProvider = extractProviderFromModel4(this.config.model, "xai");
|
|
1684
|
+
return {
|
|
1685
|
+
json: parsed,
|
|
1686
|
+
rawText: content,
|
|
1687
|
+
metrics: {
|
|
1688
|
+
costUSD,
|
|
1689
|
+
inputTokens: data.usage?.prompt_tokens,
|
|
1690
|
+
outputTokens: data.usage?.completion_tokens,
|
|
1691
|
+
latencyMs,
|
|
1692
|
+
attemptNumber: 1,
|
|
1693
|
+
provider: baseProvider,
|
|
1694
|
+
// Base provider (e.g., "x-ai" from "x-ai/grok-...")
|
|
1695
|
+
model: this.config.model
|
|
1696
|
+
},
|
|
1697
|
+
reasoning,
|
|
1698
|
+
reasoning_details
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
buildReasoningConfig(reasoning) {
|
|
1702
|
+
const config = {};
|
|
1703
|
+
if (reasoning.effort) {
|
|
1704
|
+
config.effort = reasoning.effort;
|
|
1705
|
+
} else if (reasoning.enabled) {
|
|
1706
|
+
config.effort = "medium";
|
|
1707
|
+
}
|
|
1708
|
+
if (reasoning.exclude !== void 0) {
|
|
1709
|
+
config.exclude = reasoning.exclude;
|
|
1710
|
+
}
|
|
1711
|
+
return Object.keys(config).length > 0 ? config : void 0;
|
|
1712
|
+
}
|
|
1713
|
+
async buildMessages(input) {
|
|
1714
|
+
const content = [];
|
|
1715
|
+
if (input.text) {
|
|
1716
|
+
content.push({ type: "text", text: input.text });
|
|
1717
|
+
}
|
|
1718
|
+
if (input.images && input.images.length > 0) {
|
|
1719
|
+
for (const image of input.images) {
|
|
1720
|
+
if (image.url) {
|
|
1721
|
+
content.push({
|
|
1722
|
+
type: "image_url",
|
|
1723
|
+
image_url: { url: image.url }
|
|
1724
|
+
});
|
|
1725
|
+
} else if (image.base64) {
|
|
1726
|
+
content.push({
|
|
1727
|
+
type: "image_url",
|
|
1728
|
+
image_url: {
|
|
1729
|
+
url: `data:${image.mimeType};base64,${this.extractBase64(image.base64)}`
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (input.pdfs && input.pdfs.length > 0) {
|
|
1736
|
+
for (const pdf of input.pdfs) {
|
|
1737
|
+
let fileData;
|
|
1738
|
+
if (pdf.url) {
|
|
1739
|
+
fileData = pdf.url;
|
|
1740
|
+
} else if (pdf.base64) {
|
|
1741
|
+
fileData = `data:application/pdf;base64,${this.extractBase64(pdf.base64)}`;
|
|
1742
|
+
} else {
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
content.push({
|
|
1746
|
+
type: "file",
|
|
1747
|
+
file: {
|
|
1748
|
+
filename: "document.pdf",
|
|
1749
|
+
file_data: fileData
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return [{ role: "user", content }];
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Extract base64 data from a data URL or return as-is if already raw base64
|
|
1758
|
+
*/
|
|
1759
|
+
extractBase64(input) {
|
|
1760
|
+
if (input.startsWith("data:")) {
|
|
1761
|
+
const base64Part = input.split(",")[1];
|
|
1762
|
+
if (!base64Part) {
|
|
1763
|
+
throw new Error(`Invalid data URL format: ${input.substring(0, 50)}`);
|
|
1764
|
+
}
|
|
1765
|
+
return base64Part;
|
|
1766
|
+
}
|
|
1767
|
+
return input;
|
|
1768
|
+
}
|
|
1769
|
+
calculateCost(usage) {
|
|
1770
|
+
if (!usage) return void 0;
|
|
1771
|
+
const inputCostPer1k = 5e-3;
|
|
1772
|
+
const outputCostPer1k = 0.015;
|
|
1773
|
+
const inputCost = usage.prompt_tokens / 1e3 * inputCostPer1k;
|
|
1774
|
+
const outputCost = usage.completion_tokens / 1e3 * outputCostPer1k;
|
|
1775
|
+
return inputCost + outputCost;
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
// src/fallback-manager.ts
|
|
1780
|
+
import {
|
|
1781
|
+
executeHook,
|
|
1782
|
+
createLogger
|
|
1783
|
+
} from "@doclo/core/observability";
|
|
1784
|
+
var FallbackManager = class {
|
|
1785
|
+
config;
|
|
1786
|
+
circuitBreakers;
|
|
1787
|
+
constructor(config) {
|
|
1788
|
+
this.config = config;
|
|
1789
|
+
this.circuitBreakers = /* @__PURE__ */ new Map();
|
|
1790
|
+
}
|
|
1791
|
+
async executeWithFallback(input, schema, max_tokens, reasoning, mode, observability) {
|
|
1792
|
+
const errors = [];
|
|
1793
|
+
const logger = createLogger({
|
|
1794
|
+
observability: observability?.config,
|
|
1795
|
+
flowId: observability?.flowId,
|
|
1796
|
+
executionId: observability?.executionId,
|
|
1797
|
+
stepId: observability?.stepId,
|
|
1798
|
+
traceContext: observability?.traceContext,
|
|
1799
|
+
metadata: observability?.metadata
|
|
1800
|
+
});
|
|
1801
|
+
for (const [providerIndex, providerConfig] of this.config.providers.entries()) {
|
|
1802
|
+
const providerKey = `${providerConfig.provider}:${providerConfig.model}`;
|
|
1803
|
+
if (this.isCircuitOpen(providerKey)) {
|
|
1804
|
+
logger.warn(`Circuit breaker open for ${providerKey}, skipping`);
|
|
1805
|
+
if (observability?.config && observability.traceContext && observability.executionId) {
|
|
1806
|
+
const cbState = this.circuitBreakers.get(providerKey);
|
|
1807
|
+
const circuitBreakerContext = {
|
|
1808
|
+
flowId: observability.flowId ?? "flow",
|
|
1809
|
+
executionId: observability.executionId,
|
|
1810
|
+
timestamp: Date.now(),
|
|
1811
|
+
provider: providerConfig.provider,
|
|
1812
|
+
model: providerConfig.model,
|
|
1813
|
+
failureCount: cbState?.consecutiveFailures ?? 0,
|
|
1814
|
+
threshold: this.config.circuitBreakerThreshold ?? 5,
|
|
1815
|
+
cooldownMs: 6e4,
|
|
1816
|
+
// Default cooldown
|
|
1817
|
+
metadata: observability.metadata,
|
|
1818
|
+
traceContext: observability.traceContext
|
|
1819
|
+
};
|
|
1820
|
+
await executeHook(observability.config.onCircuitBreakerTriggered, {
|
|
1821
|
+
hookName: "onCircuitBreakerTriggered",
|
|
1822
|
+
config: observability.config,
|
|
1823
|
+
context: circuitBreakerContext
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const provider = this.createProvider(providerConfig);
|
|
1829
|
+
const maxRetriesForProvider = providerIndex === 0 ? this.config.primaryMaxRetries ?? this.config.maxRetries : this.config.maxRetries;
|
|
1830
|
+
let lastError = null;
|
|
1831
|
+
for (let attempt = 1; attempt <= maxRetriesForProvider; attempt++) {
|
|
1832
|
+
try {
|
|
1833
|
+
logger.info(`Attempting ${providerKey} (attempt ${attempt}/${maxRetriesForProvider})`);
|
|
1834
|
+
const requestStartTime = Date.now();
|
|
1835
|
+
if (observability?.config && observability.traceContext && observability.executionId) {
|
|
1836
|
+
const providerRequestContext = {
|
|
1837
|
+
flowId: observability.flowId ?? "flow",
|
|
1838
|
+
executionId: observability.executionId,
|
|
1839
|
+
stepId: observability.stepId,
|
|
1840
|
+
timestamp: requestStartTime,
|
|
1841
|
+
provider: providerConfig.provider,
|
|
1842
|
+
model: providerConfig.model,
|
|
1843
|
+
input,
|
|
1844
|
+
schema,
|
|
1845
|
+
attemptNumber: attempt,
|
|
1846
|
+
maxAttempts: maxRetriesForProvider,
|
|
1847
|
+
metadata: observability.metadata,
|
|
1848
|
+
traceContext: observability.traceContext
|
|
1849
|
+
};
|
|
1850
|
+
await executeHook(observability.config.onProviderRequest, {
|
|
1851
|
+
hookName: "onProviderRequest",
|
|
1852
|
+
config: observability.config,
|
|
1853
|
+
context: providerRequestContext
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
const response = await provider.completeJson({ input, schema, max_tokens, reasoning, mode });
|
|
1857
|
+
if (this.validateResponse(response)) {
|
|
1858
|
+
this.recordSuccess(providerKey);
|
|
1859
|
+
if (observability?.config && observability.traceContext && observability.executionId) {
|
|
1860
|
+
const providerResponseContext = {
|
|
1861
|
+
flowId: observability.flowId ?? "flow",
|
|
1862
|
+
executionId: observability.executionId,
|
|
1863
|
+
stepId: observability.stepId,
|
|
1864
|
+
timestamp: Date.now(),
|
|
1865
|
+
startTime: requestStartTime,
|
|
1866
|
+
duration: Date.now() - requestStartTime,
|
|
1867
|
+
provider: providerConfig.provider,
|
|
1868
|
+
model: providerConfig.model,
|
|
1869
|
+
modelUsed: response.metrics.model,
|
|
1870
|
+
output: response.json,
|
|
1871
|
+
usage: {
|
|
1872
|
+
inputTokens: response.metrics.inputTokens ?? 0,
|
|
1873
|
+
outputTokens: response.metrics.outputTokens ?? 0,
|
|
1874
|
+
totalTokens: (response.metrics.inputTokens ?? 0) + (response.metrics.outputTokens ?? 0),
|
|
1875
|
+
cacheCreationInputTokens: response.metrics.cacheCreationInputTokens,
|
|
1876
|
+
cacheReadInputTokens: response.metrics.cacheReadInputTokens
|
|
1877
|
+
},
|
|
1878
|
+
cost: response.metrics.costUSD ?? 0,
|
|
1879
|
+
finishReason: response.metrics.finishReason,
|
|
1880
|
+
attemptNumber: attempt,
|
|
1881
|
+
metadata: observability.metadata,
|
|
1882
|
+
traceContext: observability.traceContext
|
|
1883
|
+
};
|
|
1884
|
+
await executeHook(observability.config.onProviderResponse, {
|
|
1885
|
+
hookName: "onProviderResponse",
|
|
1886
|
+
config: observability.config,
|
|
1887
|
+
context: providerResponseContext
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
...response,
|
|
1892
|
+
metrics: {
|
|
1893
|
+
...response.metrics,
|
|
1894
|
+
attemptNumber: attempt
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
} else {
|
|
1898
|
+
throw new Error("Response validation failed: incomplete or invalid data");
|
|
1899
|
+
}
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
lastError = error;
|
|
1902
|
+
logger.error(`${providerKey} attempt ${attempt} failed`, lastError, { providerKey, attempt });
|
|
1903
|
+
if (!this.isRetryable(lastError) || attempt === maxRetriesForProvider) {
|
|
1904
|
+
break;
|
|
1905
|
+
}
|
|
1906
|
+
const retryDelay = this.calculateDelay(attempt);
|
|
1907
|
+
if (observability?.config && observability.traceContext && observability.executionId) {
|
|
1908
|
+
const providerRetryContext = {
|
|
1909
|
+
flowId: observability.flowId ?? "flow",
|
|
1910
|
+
executionId: observability.executionId,
|
|
1911
|
+
stepId: observability.stepId,
|
|
1912
|
+
timestamp: Date.now(),
|
|
1913
|
+
provider: providerConfig.provider,
|
|
1914
|
+
model: providerConfig.model,
|
|
1915
|
+
attemptNumber: attempt,
|
|
1916
|
+
maxAttempts: maxRetriesForProvider,
|
|
1917
|
+
error: lastError,
|
|
1918
|
+
nextRetryDelay: retryDelay,
|
|
1919
|
+
metadata: observability.metadata,
|
|
1920
|
+
traceContext: observability.traceContext
|
|
1921
|
+
};
|
|
1922
|
+
await executeHook(observability.config.onProviderRetry, {
|
|
1923
|
+
hookName: "onProviderRetry",
|
|
1924
|
+
config: observability.config,
|
|
1925
|
+
context: providerRetryContext
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
await this.sleep(retryDelay);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if (lastError) {
|
|
1932
|
+
errors.push({ provider: providerKey, error: lastError });
|
|
1933
|
+
this.recordFailure(providerKey);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
throw new Error(
|
|
1937
|
+
`All providers failed:
|
|
1938
|
+
${errors.map((e) => ` ${e.provider}: ${e.error.message}`).join("\n")}`
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
createProvider(config) {
|
|
1942
|
+
return createProviderFromRegistry(config);
|
|
1943
|
+
}
|
|
1944
|
+
validateResponse(response) {
|
|
1945
|
+
if (!response.json || typeof response.json !== "object") {
|
|
1946
|
+
return false;
|
|
1947
|
+
}
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
1950
|
+
isRetryable(error) {
|
|
1951
|
+
const message = error.message.toLowerCase();
|
|
1952
|
+
const retryablePatterns = [
|
|
1953
|
+
"408",
|
|
1954
|
+
"429",
|
|
1955
|
+
"500",
|
|
1956
|
+
"502",
|
|
1957
|
+
"503",
|
|
1958
|
+
"504",
|
|
1959
|
+
"timeout",
|
|
1960
|
+
"rate limit",
|
|
1961
|
+
"overloaded"
|
|
1962
|
+
];
|
|
1963
|
+
return retryablePatterns.some(
|
|
1964
|
+
(pattern) => message.includes(pattern)
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
calculateDelay(attempt) {
|
|
1968
|
+
if (!this.config.useExponentialBackoff) {
|
|
1969
|
+
return this.config.retryDelay;
|
|
1970
|
+
}
|
|
1971
|
+
const exponentialDelay = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
1972
|
+
const jitter = Math.random() * 1e3;
|
|
1973
|
+
return Math.min(exponentialDelay + jitter, 3e4);
|
|
1974
|
+
}
|
|
1975
|
+
sleep(ms) {
|
|
1976
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1977
|
+
}
|
|
1978
|
+
// Circuit breaker logic
|
|
1979
|
+
isCircuitOpen(providerKey) {
|
|
1980
|
+
const state = this.circuitBreakers.get(providerKey);
|
|
1981
|
+
if (!state || !state.isOpen) return false;
|
|
1982
|
+
if (state.lastFailureTime && Date.now() - state.lastFailureTime > 3e4) {
|
|
1983
|
+
this.circuitBreakers.set(providerKey, {
|
|
1984
|
+
consecutiveFailures: 0,
|
|
1985
|
+
isOpen: false
|
|
1986
|
+
});
|
|
1987
|
+
return false;
|
|
1988
|
+
}
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
recordSuccess(providerKey) {
|
|
1992
|
+
this.circuitBreakers.set(providerKey, {
|
|
1993
|
+
consecutiveFailures: 0,
|
|
1994
|
+
isOpen: false
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
recordFailure(providerKey) {
|
|
1998
|
+
const state = this.circuitBreakers.get(providerKey) || {
|
|
1999
|
+
consecutiveFailures: 0,
|
|
2000
|
+
isOpen: false
|
|
2001
|
+
};
|
|
2002
|
+
state.consecutiveFailures++;
|
|
2003
|
+
state.lastFailureTime = Date.now();
|
|
2004
|
+
const threshold = this.config.circuitBreakerThreshold || 3;
|
|
2005
|
+
if (state.consecutiveFailures >= threshold) {
|
|
2006
|
+
state.isOpen = true;
|
|
2007
|
+
console.warn(`Circuit breaker opened for ${providerKey} after ${state.consecutiveFailures} failures`);
|
|
2008
|
+
}
|
|
2009
|
+
this.circuitBreakers.set(providerKey, state);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
// src/adapter.ts
|
|
2014
|
+
function adaptToCoreLLMProvider(provider) {
|
|
2015
|
+
return {
|
|
2016
|
+
name: provider.name,
|
|
2017
|
+
capabilities: {
|
|
2018
|
+
supportsImages: true,
|
|
2019
|
+
supportsPDFs: provider.capabilities.supportsPDFs,
|
|
2020
|
+
maxPDFPages: provider.capabilities.maxPDFPages
|
|
2021
|
+
},
|
|
2022
|
+
async completeJson(input) {
|
|
2023
|
+
let multimodalInput;
|
|
2024
|
+
if (typeof input.prompt === "string") {
|
|
2025
|
+
multimodalInput = { text: input.prompt };
|
|
2026
|
+
} else {
|
|
2027
|
+
multimodalInput = input.prompt;
|
|
2028
|
+
}
|
|
2029
|
+
const response = await provider.completeJson({
|
|
2030
|
+
input: multimodalInput,
|
|
2031
|
+
schema: input.schema,
|
|
2032
|
+
max_tokens: input.max_tokens,
|
|
2033
|
+
reasoning: input.reasoning
|
|
2034
|
+
});
|
|
2035
|
+
return {
|
|
2036
|
+
json: response.json,
|
|
2037
|
+
rawText: response.rawText,
|
|
2038
|
+
costUSD: response.metrics.costUSD,
|
|
2039
|
+
inputTokens: response.metrics.inputTokens,
|
|
2040
|
+
outputTokens: response.metrics.outputTokens,
|
|
2041
|
+
cacheCreationInputTokens: response.metrics.cacheCreationInputTokens,
|
|
2042
|
+
cacheReadInputTokens: response.metrics.cacheReadInputTokens
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// src/metadata.ts
|
|
2049
|
+
var SUPPORTED_IMAGE_TYPES = {
|
|
2050
|
+
COMMON: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
|
2051
|
+
EXTENDED: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff", "image/heif"]
|
|
2052
|
+
};
|
|
2053
|
+
var PROVIDER_METADATA = {
|
|
2054
|
+
openai: {
|
|
2055
|
+
id: "openai",
|
|
2056
|
+
name: "OpenAI",
|
|
2057
|
+
vendor: "openai",
|
|
2058
|
+
models: ["gpt-5.1", "gpt-4.1", "gpt-4.1-mini", "o3", "o3-mini", "o4-mini"],
|
|
2059
|
+
detailedModels: [
|
|
2060
|
+
{
|
|
2061
|
+
id: "gpt-4.1",
|
|
2062
|
+
name: "GPT-4.1",
|
|
2063
|
+
openRouterId: "openai/gpt-4.1",
|
|
2064
|
+
capabilities: { supportsReasoning: false },
|
|
2065
|
+
limits: { maxContextTokens: 128e3, maxOutputTokens: 16384 },
|
|
2066
|
+
pricing: { inputPer1k: 2e-3, outputPer1k: 8e-3 }
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
id: "gpt-4.1-mini",
|
|
2070
|
+
name: "GPT-4.1 Mini",
|
|
2071
|
+
openRouterId: "openai/gpt-4.1-mini",
|
|
2072
|
+
capabilities: { supportsReasoning: false },
|
|
2073
|
+
limits: { maxContextTokens: 128e3, maxOutputTokens: 16384 },
|
|
2074
|
+
pricing: { inputPer1k: 4e-4, outputPer1k: 16e-4 }
|
|
2075
|
+
},
|
|
2076
|
+
{
|
|
2077
|
+
id: "o3",
|
|
2078
|
+
name: "o3",
|
|
2079
|
+
openRouterId: "openai/o3",
|
|
2080
|
+
capabilities: { supportsReasoning: true },
|
|
2081
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 1e5 },
|
|
2082
|
+
pricing: { inputPer1k: 0.01, outputPer1k: 0.04 }
|
|
2083
|
+
},
|
|
2084
|
+
{
|
|
2085
|
+
id: "o3-mini",
|
|
2086
|
+
name: "o3-mini",
|
|
2087
|
+
openRouterId: "openai/o3-mini",
|
|
2088
|
+
capabilities: { supportsReasoning: true },
|
|
2089
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 1e5 },
|
|
2090
|
+
pricing: { inputPer1k: 11e-4, outputPer1k: 44e-4 }
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
id: "o4-mini",
|
|
2094
|
+
name: "o4-mini",
|
|
2095
|
+
openRouterId: "openai/o4-mini",
|
|
2096
|
+
capabilities: { supportsReasoning: true },
|
|
2097
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 1e5 },
|
|
2098
|
+
pricing: { inputPer1k: 11e-4, outputPer1k: 44e-4 }
|
|
2099
|
+
},
|
|
2100
|
+
{
|
|
2101
|
+
id: "gpt-5.1",
|
|
2102
|
+
name: "GPT-5.1",
|
|
2103
|
+
openRouterId: "openai/gpt-5.1",
|
|
2104
|
+
capabilities: { supportsReasoning: true },
|
|
2105
|
+
limits: { maxContextTokens: 256e3, maxOutputTokens: 32768 },
|
|
2106
|
+
pricing: { inputPer1k: 5e-3, outputPer1k: 0.015 }
|
|
2107
|
+
}
|
|
2108
|
+
],
|
|
2109
|
+
accessMethods: {
|
|
2110
|
+
native: {
|
|
2111
|
+
available: true,
|
|
2112
|
+
endpoint: "https://api.openai.com/v1",
|
|
2113
|
+
requiresApiKey: true
|
|
2114
|
+
},
|
|
2115
|
+
openrouter: {
|
|
2116
|
+
available: true,
|
|
2117
|
+
modelPrefix: "openai/"
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
capabilities: {
|
|
2121
|
+
supportsImages: true,
|
|
2122
|
+
supportsPDFs: true,
|
|
2123
|
+
supportsReasoning: true,
|
|
2124
|
+
supportsStreaming: true,
|
|
2125
|
+
supportsStructuredOutput: true
|
|
2126
|
+
},
|
|
2127
|
+
// LLM with vision - can work with raw documents OR parsed text
|
|
2128
|
+
inputRequirements: {
|
|
2129
|
+
inputType: "any",
|
|
2130
|
+
acceptedMethods: ["url", "base64"]
|
|
2131
|
+
},
|
|
2132
|
+
compatibleNodes: {
|
|
2133
|
+
parse: true,
|
|
2134
|
+
// ✅ VLMProvider - can convert images/PDFs to text
|
|
2135
|
+
extract: true,
|
|
2136
|
+
// ✅ VLMProvider - can extract structured data
|
|
2137
|
+
categorize: true,
|
|
2138
|
+
// ✅ VLMProvider - can classify documents
|
|
2139
|
+
qualify: true,
|
|
2140
|
+
// ✅ VLMProvider - can assess quality
|
|
2141
|
+
split: true
|
|
2142
|
+
// ✅ VLMProvider - can detect document boundaries
|
|
2143
|
+
},
|
|
2144
|
+
inputFormats: {
|
|
2145
|
+
images: {
|
|
2146
|
+
mimeTypes: SUPPORTED_IMAGE_TYPES.COMMON,
|
|
2147
|
+
methods: ["url", "base64"],
|
|
2148
|
+
maxSize: 20,
|
|
2149
|
+
// 20 MB per image
|
|
2150
|
+
maxDimensions: void 0,
|
|
2151
|
+
// Images resized server-side
|
|
2152
|
+
notes: "Inline via image_url with data URL or HTTP URL. Large images auto-resized."
|
|
2153
|
+
},
|
|
2154
|
+
pdfs: {
|
|
2155
|
+
supported: true,
|
|
2156
|
+
methods: ["base64", "fileId"],
|
|
2157
|
+
maxSize: 50,
|
|
2158
|
+
// 50 MB per file, 50 MB total per request
|
|
2159
|
+
maxPages: 100,
|
|
2160
|
+
notes: "Inline via type: file with base64, or via Files API. Extracts text + images of each page. File URLs NOT supported for chat completions."
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
2163
|
+
outputFormat: {
|
|
2164
|
+
supportsJSON: true,
|
|
2165
|
+
supportsReasoning: true,
|
|
2166
|
+
tokenTracking: true,
|
|
2167
|
+
costTracking: true
|
|
2168
|
+
},
|
|
2169
|
+
pricing: {
|
|
2170
|
+
model: "per-token",
|
|
2171
|
+
inputPer1k: 5e-3,
|
|
2172
|
+
outputPer1k: 0.015,
|
|
2173
|
+
currency: "USD",
|
|
2174
|
+
notes: "Cost calculated from tokens. OpenRouter may include cost in response. GPT-4.1 baseline."
|
|
2175
|
+
},
|
|
2176
|
+
limits: {
|
|
2177
|
+
maxContextTokens: 128e3,
|
|
2178
|
+
maxOutputTokens: 16384,
|
|
2179
|
+
requestsPerMinute: void 0
|
|
2180
|
+
},
|
|
2181
|
+
nativeAPI: {
|
|
2182
|
+
imageFormat: 'type: "image_url", image_url: { url: "data:image/jpeg;base64,..." }',
|
|
2183
|
+
pdfFormat: 'type: "file", file: { file_id: "..." } OR file: { filename: "...", file_data: "data:application/pdf;base64,..." }',
|
|
2184
|
+
reasoningConfig: 'reasoning: { effort: "low"|"medium"|"high", exclude?: boolean }'
|
|
2185
|
+
},
|
|
2186
|
+
openRouterAPI: {
|
|
2187
|
+
imageFormat: "Same as native (OpenAI-compatible)",
|
|
2188
|
+
pdfFormat: "Same as native (OpenAI-compatible)",
|
|
2189
|
+
reasoningConfig: "Same as native (OpenAI-compatible)",
|
|
2190
|
+
differences: [
|
|
2191
|
+
"File URLs not supported (base64 only)",
|
|
2192
|
+
"Cost may be available via usage.total_cost or generation endpoint"
|
|
2193
|
+
]
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
anthropic: {
|
|
2197
|
+
id: "anthropic",
|
|
2198
|
+
name: "Anthropic (Claude)",
|
|
2199
|
+
vendor: "anthropic",
|
|
2200
|
+
models: ["claude-opus-4.5", "claude-sonnet-4.5", "claude-haiku-4.5", "claude-opus-4", "claude-sonnet-4"],
|
|
2201
|
+
detailedModels: [
|
|
2202
|
+
{
|
|
2203
|
+
id: "claude-opus-4.5",
|
|
2204
|
+
name: "Claude Opus 4.5",
|
|
2205
|
+
openRouterId: "anthropic/claude-opus-4.5",
|
|
2206
|
+
capabilities: { supportsReasoning: true },
|
|
2207
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 32e3 },
|
|
2208
|
+
pricing: { inputPer1k: 0.015, outputPer1k: 0.075 }
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
id: "claude-sonnet-4.5",
|
|
2212
|
+
name: "Claude Sonnet 4.5",
|
|
2213
|
+
openRouterId: "anthropic/claude-sonnet-4.5",
|
|
2214
|
+
// Reasoning available via toggle (extended thinking)
|
|
2215
|
+
capabilities: { supportsReasoning: true },
|
|
2216
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 16e3 },
|
|
2217
|
+
pricing: { inputPer1k: 3e-3, outputPer1k: 0.015 }
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
id: "claude-haiku-4.5",
|
|
2221
|
+
name: "Claude Haiku 4.5",
|
|
2222
|
+
openRouterId: "anthropic/claude-haiku-4.5",
|
|
2223
|
+
// Reasoning available via toggle (extended thinking)
|
|
2224
|
+
capabilities: { supportsReasoning: true },
|
|
2225
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 8192 },
|
|
2226
|
+
pricing: { inputPer1k: 8e-4, outputPer1k: 4e-3 }
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
id: "claude-opus-4",
|
|
2230
|
+
name: "Claude Opus 4",
|
|
2231
|
+
openRouterId: "anthropic/claude-opus-4",
|
|
2232
|
+
capabilities: { supportsReasoning: true },
|
|
2233
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 32e3 },
|
|
2234
|
+
pricing: { inputPer1k: 0.015, outputPer1k: 0.075 }
|
|
2235
|
+
},
|
|
2236
|
+
{
|
|
2237
|
+
id: "claude-sonnet-4",
|
|
2238
|
+
name: "Claude Sonnet 4",
|
|
2239
|
+
openRouterId: "anthropic/claude-sonnet-4",
|
|
2240
|
+
// Reasoning available via toggle (extended thinking)
|
|
2241
|
+
capabilities: { supportsReasoning: true },
|
|
2242
|
+
limits: { maxContextTokens: 2e5, maxOutputTokens: 16e3 },
|
|
2243
|
+
pricing: { inputPer1k: 3e-3, outputPer1k: 0.015 }
|
|
2244
|
+
}
|
|
2245
|
+
],
|
|
2246
|
+
accessMethods: {
|
|
2247
|
+
native: {
|
|
2248
|
+
available: true,
|
|
2249
|
+
endpoint: "https://api.anthropic.com/v1",
|
|
2250
|
+
requiresApiKey: true
|
|
2251
|
+
},
|
|
2252
|
+
openrouter: {
|
|
2253
|
+
available: true,
|
|
2254
|
+
modelPrefix: "anthropic/"
|
|
2255
|
+
}
|
|
2256
|
+
},
|
|
2257
|
+
capabilities: {
|
|
2258
|
+
supportsImages: true,
|
|
2259
|
+
supportsPDFs: true,
|
|
2260
|
+
supportsReasoning: true,
|
|
2261
|
+
supportsStreaming: true,
|
|
2262
|
+
supportsStructuredOutput: true
|
|
2263
|
+
},
|
|
2264
|
+
// LLM with vision - can work with raw documents OR parsed text
|
|
2265
|
+
inputRequirements: {
|
|
2266
|
+
inputType: "any",
|
|
2267
|
+
acceptedMethods: ["base64"]
|
|
2268
|
+
// URLs must be downloaded and converted
|
|
2269
|
+
},
|
|
2270
|
+
compatibleNodes: {
|
|
2271
|
+
parse: true,
|
|
2272
|
+
extract: true,
|
|
2273
|
+
categorize: true,
|
|
2274
|
+
qualify: true,
|
|
2275
|
+
split: true
|
|
2276
|
+
},
|
|
2277
|
+
inputFormats: {
|
|
2278
|
+
images: {
|
|
2279
|
+
mimeTypes: SUPPORTED_IMAGE_TYPES.COMMON,
|
|
2280
|
+
methods: ["base64"],
|
|
2281
|
+
// URLs must be downloaded and converted
|
|
2282
|
+
maxSize: 5,
|
|
2283
|
+
// 5 MB per image (API), 10 MB (claude.ai)
|
|
2284
|
+
maxDimensions: { width: 8e3, height: 8e3 },
|
|
2285
|
+
// Standard limit, 2000x2000 for 20+ images
|
|
2286
|
+
notes: "Native API requires base64. Max 100 images/request. Optimal at 1568px max dimension."
|
|
2287
|
+
},
|
|
2288
|
+
pdfs: {
|
|
2289
|
+
supported: true,
|
|
2290
|
+
methods: ["base64", "fileId"],
|
|
2291
|
+
maxSize: 32,
|
|
2292
|
+
// 32 MB per file
|
|
2293
|
+
maxPages: 100,
|
|
2294
|
+
// 100 pages for full visual analysis
|
|
2295
|
+
notes: "Inline via type: document with base64, or via Files API (beta). PDFs over 100 pages: text-only processing."
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
outputFormat: {
|
|
2299
|
+
supportsJSON: true,
|
|
2300
|
+
supportsReasoning: true,
|
|
2301
|
+
tokenTracking: true,
|
|
2302
|
+
costTracking: true
|
|
2303
|
+
},
|
|
2304
|
+
pricing: {
|
|
2305
|
+
model: "per-token",
|
|
2306
|
+
inputPer1k: 3e-3,
|
|
2307
|
+
outputPer1k: 0.015,
|
|
2308
|
+
currency: "USD",
|
|
2309
|
+
notes: "Cost calculated from tokens. OpenRouter may include cost in response. Claude 3.5 Sonnet baseline."
|
|
2310
|
+
},
|
|
2311
|
+
limits: {
|
|
2312
|
+
maxContextTokens: 2e5,
|
|
2313
|
+
maxOutputTokens: 8192,
|
|
2314
|
+
requestsPerMinute: void 0
|
|
2315
|
+
},
|
|
2316
|
+
nativeAPI: {
|
|
2317
|
+
imageFormat: 'type: "image", source: { type: "base64", media_type: "image/jpeg", data: "..." }',
|
|
2318
|
+
pdfFormat: 'type: "document", source: { type: "base64"|"file", media_type: "application/pdf", data: "..." | file_id: "..." }',
|
|
2319
|
+
reasoningConfig: 'thinking: { type: "enabled", budget_tokens: 1024-32000 }'
|
|
2320
|
+
},
|
|
2321
|
+
openRouterAPI: {
|
|
2322
|
+
imageFormat: 'type: "image_url", image_url: { url: "data:image/jpeg;base64,..." }',
|
|
2323
|
+
pdfFormat: 'type: "file", file: { filename: "...", file_data: "data:application/pdf;base64,..." }',
|
|
2324
|
+
reasoningConfig: "reasoning: { max_tokens: number, exclude?: boolean }",
|
|
2325
|
+
differences: [
|
|
2326
|
+
"Uses OpenAI-compatible format (image_url, file types)",
|
|
2327
|
+
"Reasoning uses max_tokens instead of budget_tokens",
|
|
2328
|
+
'Response prefill trick ({ role: "assistant", content: "{" }) for strict JSON',
|
|
2329
|
+
"Tool calling instead of native structured output"
|
|
2330
|
+
]
|
|
2331
|
+
}
|
|
2332
|
+
},
|
|
2333
|
+
google: {
|
|
2334
|
+
id: "google",
|
|
2335
|
+
name: "Google (Gemini)",
|
|
2336
|
+
vendor: "google",
|
|
2337
|
+
models: ["gemini-3-pro", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"],
|
|
2338
|
+
detailedModels: [
|
|
2339
|
+
{
|
|
2340
|
+
id: "gemini-3-pro",
|
|
2341
|
+
name: "Gemini 3 Pro",
|
|
2342
|
+
openRouterId: "google/gemini-3-pro",
|
|
2343
|
+
capabilities: { supportsReasoning: true },
|
|
2344
|
+
limits: { maxContextTokens: 1e6, maxOutputTokens: 65536 },
|
|
2345
|
+
pricing: { inputPer1k: 125e-5, outputPer1k: 5e-3 }
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
id: "gemini-2.5-pro",
|
|
2349
|
+
name: "Gemini 2.5 Pro",
|
|
2350
|
+
openRouterId: "google/gemini-2.5-pro-preview-06-05",
|
|
2351
|
+
capabilities: { supportsReasoning: true },
|
|
2352
|
+
limits: { maxContextTokens: 1e6, maxOutputTokens: 65536 },
|
|
2353
|
+
pricing: { inputPer1k: 125e-5, outputPer1k: 5e-3 }
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
id: "gemini-2.5-flash",
|
|
2357
|
+
name: "Gemini 2.5 Flash",
|
|
2358
|
+
openRouterId: "google/gemini-2.5-flash-preview-09-2025",
|
|
2359
|
+
capabilities: { supportsReasoning: false },
|
|
2360
|
+
limits: { maxContextTokens: 1e6, maxOutputTokens: 8192 },
|
|
2361
|
+
pricing: { inputPer1k: 15e-5, outputPer1k: 6e-4 }
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
id: "gemini-2.5-flash-lite",
|
|
2365
|
+
name: "Gemini 2.5 Flash Lite",
|
|
2366
|
+
openRouterId: "google/gemini-2.5-flash-lite",
|
|
2367
|
+
capabilities: { supportsReasoning: false },
|
|
2368
|
+
limits: { maxContextTokens: 1e6, maxOutputTokens: 8192 },
|
|
2369
|
+
pricing: { inputPer1k: 75e-6, outputPer1k: 3e-4 }
|
|
2370
|
+
}
|
|
2371
|
+
],
|
|
2372
|
+
accessMethods: {
|
|
2373
|
+
native: {
|
|
2374
|
+
available: true,
|
|
2375
|
+
endpoint: "https://generativelanguage.googleapis.com/v1beta",
|
|
2376
|
+
requiresApiKey: true
|
|
2377
|
+
},
|
|
2378
|
+
openrouter: {
|
|
2379
|
+
available: true,
|
|
2380
|
+
modelPrefix: "google/"
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
capabilities: {
|
|
2384
|
+
supportsImages: true,
|
|
2385
|
+
supportsPDFs: true,
|
|
2386
|
+
supportsReasoning: true,
|
|
2387
|
+
supportsStreaming: true,
|
|
2388
|
+
supportsStructuredOutput: true
|
|
2389
|
+
},
|
|
2390
|
+
// LLM with vision - can work with raw documents OR parsed text
|
|
2391
|
+
inputRequirements: {
|
|
2392
|
+
inputType: "any",
|
|
2393
|
+
acceptedMethods: ["base64"]
|
|
2394
|
+
// URLs are downloaded and converted
|
|
2395
|
+
},
|
|
2396
|
+
compatibleNodes: {
|
|
2397
|
+
parse: true,
|
|
2398
|
+
extract: true,
|
|
2399
|
+
categorize: true,
|
|
2400
|
+
qualify: true,
|
|
2401
|
+
split: true
|
|
2402
|
+
},
|
|
2403
|
+
inputFormats: {
|
|
2404
|
+
images: {
|
|
2405
|
+
mimeTypes: [...SUPPORTED_IMAGE_TYPES.COMMON, "image/bmp", "image/tiff", "image/heif"],
|
|
2406
|
+
methods: ["base64"],
|
|
2407
|
+
// URLs are downloaded and converted
|
|
2408
|
+
maxSize: 20,
|
|
2409
|
+
// 20 MB inline data limit (File API: 2GB for Gemini 2.0)
|
|
2410
|
+
maxDimensions: { width: 3072, height: 3072 },
|
|
2411
|
+
// Images scaled to fit, padded to preserve ratio
|
|
2412
|
+
notes: "Inline via inlineData. Max 3000 images/request. File API supports larger files (stored 48 hours)."
|
|
2413
|
+
},
|
|
2414
|
+
pdfs: {
|
|
2415
|
+
supported: true,
|
|
2416
|
+
methods: ["base64", "fileId"],
|
|
2417
|
+
maxSize: 50,
|
|
2418
|
+
// 50 MB limit (inline & File API)
|
|
2419
|
+
maxPages: 1e3,
|
|
2420
|
+
// 1000 pages max, each page = 258 tokens
|
|
2421
|
+
notes: "Inline via inlineData OR File API. Pages scaled to 3072x3072 max. Native text not charged."
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
outputFormat: {
|
|
2425
|
+
supportsJSON: true,
|
|
2426
|
+
supportsReasoning: true,
|
|
2427
|
+
tokenTracking: true,
|
|
2428
|
+
costTracking: true
|
|
2429
|
+
},
|
|
2430
|
+
pricing: {
|
|
2431
|
+
model: "per-token",
|
|
2432
|
+
inputPer1k: 25e-5,
|
|
2433
|
+
outputPer1k: 1e-3,
|
|
2434
|
+
currency: "USD",
|
|
2435
|
+
notes: "Cost calculated from tokens. OpenRouter may include cost in response. Gemini 2.5 Flash baseline."
|
|
2436
|
+
},
|
|
2437
|
+
limits: {
|
|
2438
|
+
maxContextTokens: 1e6,
|
|
2439
|
+
// 1M tokens
|
|
2440
|
+
maxOutputTokens: 8192,
|
|
2441
|
+
requestsPerMinute: void 0
|
|
2442
|
+
},
|
|
2443
|
+
nativeAPI: {
|
|
2444
|
+
imageFormat: 'inlineData: { mimeType: "image/jpeg", data: "..." }',
|
|
2445
|
+
pdfFormat: 'inlineData: { mimeType: "application/pdf", data: "..." } OR fileData: { fileUri: "...", mimeType: "application/pdf" }',
|
|
2446
|
+
reasoningConfig: "generationConfig.thinking_config: { thinking_budget: number } (max 24576)"
|
|
2447
|
+
},
|
|
2448
|
+
openRouterAPI: {
|
|
2449
|
+
imageFormat: 'type: "image_url", image_url: { url: "data:image/jpeg;base64,..." }',
|
|
2450
|
+
pdfFormat: 'type: "file", file: { filename: "...", file_data: "data:application/pdf;base64,..." }',
|
|
2451
|
+
reasoningConfig: "reasoning: { max_tokens: number, exclude?: boolean }",
|
|
2452
|
+
differences: [
|
|
2453
|
+
"Uses OpenAI-compatible format instead of parts/inlineData",
|
|
2454
|
+
"Reasoning uses max_tokens instead of thinking_budget",
|
|
2455
|
+
"Different content structure (messages vs contents.parts)"
|
|
2456
|
+
]
|
|
2457
|
+
}
|
|
2458
|
+
},
|
|
2459
|
+
xai: {
|
|
2460
|
+
id: "xai",
|
|
2461
|
+
name: "xAI (Grok)",
|
|
2462
|
+
vendor: "xai",
|
|
2463
|
+
models: ["grok-4.1", "grok-4.1-fast", "grok-4", "grok-4-fast"],
|
|
2464
|
+
detailedModels: [
|
|
2465
|
+
{
|
|
2466
|
+
id: "grok-4.1",
|
|
2467
|
+
name: "Grok 4.1",
|
|
2468
|
+
openRouterId: "x-ai/grok-4.1",
|
|
2469
|
+
capabilities: { supportsReasoning: true },
|
|
2470
|
+
limits: { maxContextTokens: 256e3, maxOutputTokens: 32768 },
|
|
2471
|
+
pricing: { inputPer1k: 3e-3, outputPer1k: 0.015 }
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
id: "grok-4.1-fast",
|
|
2475
|
+
name: "Grok 4.1 Fast",
|
|
2476
|
+
openRouterId: "x-ai/grok-4.1-fast",
|
|
2477
|
+
capabilities: { supportsReasoning: false },
|
|
2478
|
+
limits: { maxContextTokens: 2e6, maxOutputTokens: 32768 },
|
|
2479
|
+
pricing: { inputPer1k: 5e-3, outputPer1k: 0.025 }
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
id: "grok-4",
|
|
2483
|
+
name: "Grok 4",
|
|
2484
|
+
openRouterId: "x-ai/grok-4",
|
|
2485
|
+
capabilities: { supportsReasoning: true },
|
|
2486
|
+
limits: { maxContextTokens: 256e3, maxOutputTokens: 32768 },
|
|
2487
|
+
pricing: { inputPer1k: 3e-3, outputPer1k: 0.015 }
|
|
2488
|
+
},
|
|
2489
|
+
{
|
|
2490
|
+
id: "grok-4-fast",
|
|
2491
|
+
name: "Grok 4 Fast",
|
|
2492
|
+
openRouterId: "x-ai/grok-4-fast",
|
|
2493
|
+
capabilities: { supportsReasoning: false },
|
|
2494
|
+
limits: { maxContextTokens: 2e6, maxOutputTokens: 32768 },
|
|
2495
|
+
pricing: { inputPer1k: 5e-3, outputPer1k: 0.025 }
|
|
2496
|
+
}
|
|
2497
|
+
],
|
|
2498
|
+
accessMethods: {
|
|
2499
|
+
native: {
|
|
2500
|
+
available: true,
|
|
2501
|
+
endpoint: "https://api.x.ai/v1",
|
|
2502
|
+
requiresApiKey: true
|
|
2503
|
+
},
|
|
2504
|
+
openrouter: {
|
|
2505
|
+
available: true,
|
|
2506
|
+
modelPrefix: "xai/"
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2509
|
+
capabilities: {
|
|
2510
|
+
supportsImages: true,
|
|
2511
|
+
supportsPDFs: true,
|
|
2512
|
+
supportsReasoning: true,
|
|
2513
|
+
supportsStreaming: false,
|
|
2514
|
+
// Not with structured outputs
|
|
2515
|
+
supportsStructuredOutput: true
|
|
2516
|
+
},
|
|
2517
|
+
// LLM with vision - can work with raw documents OR parsed text
|
|
2518
|
+
inputRequirements: {
|
|
2519
|
+
inputType: "any",
|
|
2520
|
+
acceptedMethods: ["url", "base64"]
|
|
2521
|
+
},
|
|
2522
|
+
compatibleNodes: {
|
|
2523
|
+
parse: true,
|
|
2524
|
+
extract: true,
|
|
2525
|
+
categorize: true,
|
|
2526
|
+
qualify: true,
|
|
2527
|
+
split: true
|
|
2528
|
+
},
|
|
2529
|
+
inputFormats: {
|
|
2530
|
+
images: {
|
|
2531
|
+
mimeTypes: ["image/jpeg", "image/png"],
|
|
2532
|
+
// Only jpg/jpeg and png supported
|
|
2533
|
+
methods: ["url", "base64"],
|
|
2534
|
+
maxSize: 30,
|
|
2535
|
+
// 30 MB per file (API), 25 MB (chat)
|
|
2536
|
+
maxDimensions: void 0,
|
|
2537
|
+
notes: "OpenAI-compatible format. Max 10 images via API. Only JPEG/PNG supported."
|
|
2538
|
+
},
|
|
2539
|
+
pdfs: {
|
|
2540
|
+
supported: true,
|
|
2541
|
+
methods: ["url", "base64"],
|
|
2542
|
+
maxSize: 30,
|
|
2543
|
+
// 30 MB per file
|
|
2544
|
+
maxPages: void 0,
|
|
2545
|
+
notes: "OpenAI-compatible format. Inline via type: file. Also supports DOCX, TXT, MD, CSV."
|
|
2546
|
+
}
|
|
2547
|
+
},
|
|
2548
|
+
outputFormat: {
|
|
2549
|
+
supportsJSON: true,
|
|
2550
|
+
supportsReasoning: true,
|
|
2551
|
+
tokenTracking: true,
|
|
2552
|
+
costTracking: true
|
|
2553
|
+
},
|
|
2554
|
+
pricing: {
|
|
2555
|
+
model: "per-token",
|
|
2556
|
+
inputPer1k: 5e-3,
|
|
2557
|
+
outputPer1k: 0.015,
|
|
2558
|
+
currency: "USD",
|
|
2559
|
+
notes: "Cost calculated from tokens. OpenRouter may include cost in response. Grok-4 baseline."
|
|
2560
|
+
},
|
|
2561
|
+
limits: {
|
|
2562
|
+
maxContextTokens: 131072,
|
|
2563
|
+
maxOutputTokens: void 0,
|
|
2564
|
+
requestsPerMinute: void 0
|
|
2565
|
+
},
|
|
2566
|
+
nativeAPI: {
|
|
2567
|
+
imageFormat: 'type: "image_url", image_url: { url: "data:image/jpeg;base64,..." }',
|
|
2568
|
+
pdfFormat: 'type: "file", file: { filename: "...", file_data: "data:application/pdf;base64,..." }',
|
|
2569
|
+
reasoningConfig: 'reasoning: { effort: "low"|"medium"|"high", exclude?: boolean }'
|
|
2570
|
+
},
|
|
2571
|
+
openRouterAPI: {
|
|
2572
|
+
imageFormat: "Same as native (OpenAI-compatible)",
|
|
2573
|
+
pdfFormat: "Same as native (OpenAI-compatible)",
|
|
2574
|
+
reasoningConfig: "Same as native (OpenAI-compatible)",
|
|
2575
|
+
differences: [
|
|
2576
|
+
"Minimal differences - xAI uses OpenAI-compatible format",
|
|
2577
|
+
"Cost tracking via usage.total_cost field in OpenRouter"
|
|
2578
|
+
]
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
};
|
|
2582
|
+
function isImageTypeSupported(providerId, mimeType) {
|
|
2583
|
+
const provider = PROVIDER_METADATA[providerId];
|
|
2584
|
+
return provider.inputFormats.images.mimeTypes.includes(mimeType);
|
|
2585
|
+
}
|
|
2586
|
+
function supportsPDFsInline(providerId) {
|
|
2587
|
+
const provider = PROVIDER_METADATA[providerId];
|
|
2588
|
+
return provider.inputFormats.pdfs.supported && provider.inputFormats.pdfs.methods.includes("base64");
|
|
2589
|
+
}
|
|
2590
|
+
function getProvidersForNode(nodeType) {
|
|
2591
|
+
return Object.values(PROVIDER_METADATA).filter(
|
|
2592
|
+
(provider) => provider.compatibleNodes[nodeType]
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
function isProviderCompatibleWithNode(providerId, nodeType) {
|
|
2596
|
+
return PROVIDER_METADATA[providerId].compatibleNodes[nodeType];
|
|
2597
|
+
}
|
|
2598
|
+
function estimateCost(providerId, inputTokens, outputTokens) {
|
|
2599
|
+
const provider = PROVIDER_METADATA[providerId];
|
|
2600
|
+
const inputCost = inputTokens / 1e3 * provider.pricing.inputPer1k;
|
|
2601
|
+
const outputCost = outputTokens / 1e3 * provider.pricing.outputPer1k;
|
|
2602
|
+
return inputCost + outputCost;
|
|
2603
|
+
}
|
|
2604
|
+
function getCheapestProvider(inputTokens, outputTokens) {
|
|
2605
|
+
const providers = Object.values(PROVIDER_METADATA);
|
|
2606
|
+
return providers.reduce((cheapest, current) => {
|
|
2607
|
+
const cheapestCost = estimateCost(cheapest.id, inputTokens, outputTokens);
|
|
2608
|
+
const currentCost = estimateCost(current.id, inputTokens, outputTokens);
|
|
2609
|
+
return currentCost < cheapestCost ? current : cheapest;
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
function compareNativeVsOpenRouter(providerId) {
|
|
2613
|
+
const provider = PROVIDER_METADATA[providerId];
|
|
2614
|
+
return {
|
|
2615
|
+
provider: provider.name,
|
|
2616
|
+
nativeAvailable: provider.accessMethods.native.available,
|
|
2617
|
+
openRouterAvailable: provider.accessMethods.openrouter.available,
|
|
2618
|
+
differences: provider.openRouterAPI.differences
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// src/index.ts
|
|
2623
|
+
if (!providerRegistry.has("openai")) {
|
|
2624
|
+
providerRegistry.register("openai", (config) => new OpenAIProvider(config));
|
|
2625
|
+
}
|
|
2626
|
+
if (!providerRegistry.has("anthropic")) {
|
|
2627
|
+
providerRegistry.register("anthropic", (config) => new AnthropicProvider(config));
|
|
2628
|
+
}
|
|
2629
|
+
if (!providerRegistry.has("google")) {
|
|
2630
|
+
providerRegistry.register("google", (config) => new GoogleProvider(config));
|
|
2631
|
+
}
|
|
2632
|
+
if (!providerRegistry.has("xai")) {
|
|
2633
|
+
providerRegistry.register("xai", (config) => new XAIProvider(config));
|
|
2634
|
+
}
|
|
2635
|
+
function createVLMProvider(config) {
|
|
2636
|
+
let internalProvider;
|
|
2637
|
+
const providerConfig = {
|
|
2638
|
+
provider: config.provider,
|
|
2639
|
+
model: config.model,
|
|
2640
|
+
apiKey: config.apiKey,
|
|
2641
|
+
via: config.via,
|
|
2642
|
+
baseUrl: config.baseUrl
|
|
2643
|
+
};
|
|
2644
|
+
internalProvider = createProviderFromRegistry(providerConfig);
|
|
2645
|
+
return {
|
|
2646
|
+
name: internalProvider.name,
|
|
2647
|
+
capabilities: {
|
|
2648
|
+
supportsImages: true,
|
|
2649
|
+
supportsPDFs: internalProvider.capabilities.supportsPDFs,
|
|
2650
|
+
maxPDFPages: internalProvider.capabilities.maxPDFPages
|
|
2651
|
+
},
|
|
2652
|
+
async completeJson(input) {
|
|
2653
|
+
let multimodalInput;
|
|
2654
|
+
if (typeof input.prompt === "string") {
|
|
2655
|
+
multimodalInput = { text: input.prompt };
|
|
2656
|
+
} else {
|
|
2657
|
+
multimodalInput = input.prompt;
|
|
2658
|
+
}
|
|
2659
|
+
const response = await internalProvider.completeJson({
|
|
2660
|
+
input: multimodalInput,
|
|
2661
|
+
schema: input.schema,
|
|
2662
|
+
max_tokens: input.max_tokens,
|
|
2663
|
+
reasoning: input.reasoning
|
|
2664
|
+
});
|
|
2665
|
+
return {
|
|
2666
|
+
json: response.json,
|
|
2667
|
+
rawText: response.rawText,
|
|
2668
|
+
costUSD: response.metrics.costUSD,
|
|
2669
|
+
inputTokens: response.metrics.inputTokens,
|
|
2670
|
+
outputTokens: response.metrics.outputTokens
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
function buildLLMProvider(config) {
|
|
2676
|
+
const manager = new FallbackManager(config);
|
|
2677
|
+
let observabilityContext;
|
|
2678
|
+
const coreProvider = {
|
|
2679
|
+
name: config.providers.map((p) => `${p.provider}:${p.model}`).join(" -> "),
|
|
2680
|
+
capabilities: {
|
|
2681
|
+
supportsImages: true,
|
|
2682
|
+
supportsPDFs: true,
|
|
2683
|
+
maxPDFPages: Math.min(...config.providers.map((_) => 100))
|
|
2684
|
+
},
|
|
2685
|
+
async completeJson(input) {
|
|
2686
|
+
let multimodalInput;
|
|
2687
|
+
if (typeof input.prompt === "string") {
|
|
2688
|
+
multimodalInput = { text: input.prompt };
|
|
2689
|
+
} else {
|
|
2690
|
+
multimodalInput = input.prompt;
|
|
2691
|
+
}
|
|
2692
|
+
const response = await manager.executeWithFallback(
|
|
2693
|
+
multimodalInput,
|
|
2694
|
+
input.schema,
|
|
2695
|
+
input.max_tokens,
|
|
2696
|
+
input.reasoning,
|
|
2697
|
+
void 0,
|
|
2698
|
+
// mode - defaults to strict when schema provided
|
|
2699
|
+
observabilityContext
|
|
2700
|
+
);
|
|
2701
|
+
return {
|
|
2702
|
+
json: response.json,
|
|
2703
|
+
rawText: response.rawText,
|
|
2704
|
+
costUSD: response.metrics.costUSD,
|
|
2705
|
+
inputTokens: response.metrics.inputTokens,
|
|
2706
|
+
outputTokens: response.metrics.outputTokens,
|
|
2707
|
+
cacheCreationInputTokens: response.metrics.cacheCreationInputTokens,
|
|
2708
|
+
cacheReadInputTokens: response.metrics.cacheReadInputTokens
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
coreProvider.__setObservabilityContext = (ctx) => {
|
|
2713
|
+
observabilityContext = ctx;
|
|
2714
|
+
};
|
|
2715
|
+
return coreProvider;
|
|
2716
|
+
}
|
|
2717
|
+
export {
|
|
2718
|
+
AnthropicProvider,
|
|
2719
|
+
FallbackManager,
|
|
2720
|
+
GoogleProvider,
|
|
2721
|
+
OpenAIProvider,
|
|
2722
|
+
PROVIDER_METADATA,
|
|
2723
|
+
SUPPORTED_IMAGE_TYPES,
|
|
2724
|
+
SchemaTranslator,
|
|
2725
|
+
XAIProvider,
|
|
2726
|
+
adaptToCoreLLMProvider,
|
|
2727
|
+
buildLLMProvider,
|
|
2728
|
+
buildSchemaPromptSection,
|
|
2729
|
+
combineSchemaAndUserPrompt,
|
|
2730
|
+
compareNativeVsOpenRouter,
|
|
2731
|
+
createProviderFromRegistry,
|
|
2732
|
+
createVLMProvider,
|
|
2733
|
+
estimateCost,
|
|
2734
|
+
formatSchemaForPrompt,
|
|
2735
|
+
getCheapestProvider,
|
|
2736
|
+
getProvidersForNode,
|
|
2737
|
+
isImageTypeSupported,
|
|
2738
|
+
isProviderCompatibleWithNode,
|
|
2739
|
+
providerRegistry,
|
|
2740
|
+
registerProvider,
|
|
2741
|
+
supportsPDFsInline
|
|
2742
|
+
};
|
|
2743
|
+
//# sourceMappingURL=index.js.map
|