call-ai 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +575 -45
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -109,15 +109,45 @@ for await (const chunk of generator) {
|
|
|
109
109
|
|
|
110
110
|
## Supported LLM Providers
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
Call-AI supports all models available through OpenRouter, including:
|
|
113
113
|
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
114
|
+
- OpenAI models (GPT-4, GPT-3.5, etc.)
|
|
115
|
+
- Anthropic Claude
|
|
116
|
+
- Gemini
|
|
117
|
+
- Llama 3
|
|
118
|
+
- Mistral
|
|
119
|
+
- And many more
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
## Choosing a model
|
|
122
|
+
|
|
123
|
+
Different LLMs have different strengths when working with structured data. Based on our testing, here's a guide to help you choose the right model for your schema needs:
|
|
124
|
+
|
|
125
|
+
### Schema Complexity Guide
|
|
126
|
+
|
|
127
|
+
| Model Family | Grade | Simple Flat Schema | Complex Flat Schema | Nested Schema | Best For |
|
|
128
|
+
|--------------|-------|-------------------|---------------------|---------------|----------|
|
|
129
|
+
| OpenAI | A | ✅ Excellent | ✅ Excellent | ✅ Excellent | Most reliable for all schema types |
|
|
130
|
+
| Gemini | A | ✅ Excellent | ✅ Excellent | ✅ Good | Good all-around performance, especially with flat schemas |
|
|
131
|
+
| Claude | B | ✅ Excellent | ⚠️ Good (occasional JSON errors) | ✅ Good | Simple schemas, robust handling of complex prompts |
|
|
132
|
+
| Llama 3 | C | ✅ Good | ✅ Good | ❌ Poor | Simpler flat schemas, may struggle with nested structures |
|
|
133
|
+
| Deepseek | C | ✅ Good | ✅ Good | ❌ Poor | Basic flat schemas only |
|
|
134
|
+
|
|
135
|
+
### Schema Structure Recommendations
|
|
136
|
+
|
|
137
|
+
1. **Flat schemas perform better across all models**. If you need maximum compatibility, avoid deeply nested structures.
|
|
138
|
+
|
|
139
|
+
2. **Field names matter**. Some models have preferences for certain property naming patterns:
|
|
140
|
+
- Use simple, common naming patterns like `name`, `type`, `items`, `price`
|
|
141
|
+
- Avoid deeply nested object hierarchies (more than 2 levels deep)
|
|
142
|
+
- Keep array items simple (strings or flat objects)
|
|
143
|
+
|
|
144
|
+
3. **Model-specific considerations**:
|
|
145
|
+
- **OpenAI models**: Best overall schema adherence and handle complex nesting well
|
|
146
|
+
- **Claude models**: Great for simple schemas, occasional JSON formatting issues with complex structures
|
|
147
|
+
- **Gemini models**: Good general performance, handles array properties well
|
|
148
|
+
- **Llama/Mistral/Deepseek**: Strong with flat schemas, but often ignore nesting structure and provide their own organization
|
|
149
|
+
|
|
150
|
+
4. **For mission-critical applications** requiring schema adherence, use OpenAI models or implement fallback mechanisms.
|
|
121
151
|
|
|
122
152
|
## Setting API Keys
|
|
123
153
|
|
|
@@ -201,6 +231,15 @@ MIT or Apache-2.0, at your option
|
|
|
201
231
|
5. Run type checking: `npm run typecheck`
|
|
202
232
|
6. Create a pull request
|
|
203
233
|
|
|
234
|
+
### Integration Tests
|
|
235
|
+
|
|
236
|
+
The project includes integration tests that make real API calls to verify functionality with actual LLM models:
|
|
237
|
+
|
|
238
|
+
1. Copy `.env.example` to `.env` and add your OpenRouter API key
|
|
239
|
+
2. Run integration tests: `npm run test:integration`
|
|
240
|
+
|
|
241
|
+
Note: Integration tests are excluded from the normal test suite to avoid making API calls during CI/CD. They require a valid API key to execute and will be skipped if no key is provided.
|
|
242
|
+
|
|
204
243
|
### Release Process
|
|
205
244
|
|
|
206
245
|
This library uses GitHub Actions to automate the release process:
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -4,6 +4,253 @@
|
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.callAI = callAI;
|
|
7
|
+
/**
|
|
8
|
+
* OpenAI/GPT strategy for handling JSON schema
|
|
9
|
+
*/
|
|
10
|
+
const openAIStrategy = {
|
|
11
|
+
name: 'openai',
|
|
12
|
+
prepareRequest: (schema, messages) => {
|
|
13
|
+
if (!schema)
|
|
14
|
+
return {};
|
|
15
|
+
// Process schema for JSON schema approach
|
|
16
|
+
const requiredFields = schema.required || Object.keys(schema.properties || {});
|
|
17
|
+
const processedSchema = recursivelyAddAdditionalProperties({
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: schema.properties || {},
|
|
20
|
+
required: requiredFields,
|
|
21
|
+
additionalProperties: schema.additionalProperties !== undefined
|
|
22
|
+
? schema.additionalProperties
|
|
23
|
+
: false,
|
|
24
|
+
// Copy any additional schema properties
|
|
25
|
+
...Object.fromEntries(Object.entries(schema).filter(([key]) => !['name', 'properties', 'required', 'additionalProperties'].includes(key)))
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
response_format: {
|
|
29
|
+
type: 'json_schema',
|
|
30
|
+
json_schema: {
|
|
31
|
+
name: schema.name || "result",
|
|
32
|
+
strict: true,
|
|
33
|
+
schema: processedSchema
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
processResponse: (content) => {
|
|
39
|
+
if (typeof content !== 'string') {
|
|
40
|
+
return JSON.stringify(content);
|
|
41
|
+
}
|
|
42
|
+
return content;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Gemini strategy for handling JSON schema (similar to OpenAI)
|
|
47
|
+
*/
|
|
48
|
+
const geminiStrategy = {
|
|
49
|
+
name: 'gemini',
|
|
50
|
+
prepareRequest: openAIStrategy.prepareRequest,
|
|
51
|
+
processResponse: (content) => {
|
|
52
|
+
if (typeof content !== 'string') {
|
|
53
|
+
return JSON.stringify(content);
|
|
54
|
+
}
|
|
55
|
+
// Try to extract JSON from content if it might be wrapped
|
|
56
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) ||
|
|
57
|
+
content.match(/```\s*([\s\S]*?)\s*```/) ||
|
|
58
|
+
content.match(/\{[\s\S]*\}/) ||
|
|
59
|
+
[null, content];
|
|
60
|
+
return jsonMatch[1] || content;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Claude strategy using tool mode for structured output
|
|
65
|
+
*/
|
|
66
|
+
const claudeStrategy = {
|
|
67
|
+
name: 'anthropic',
|
|
68
|
+
shouldForceStream: true,
|
|
69
|
+
prepareRequest: (schema, messages) => {
|
|
70
|
+
if (!schema)
|
|
71
|
+
return {};
|
|
72
|
+
// Process schema for tool use - format for OpenRouter/Claude
|
|
73
|
+
const processedSchema = {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: schema.properties || {},
|
|
76
|
+
required: schema.required || Object.keys(schema.properties || {}),
|
|
77
|
+
additionalProperties: schema.additionalProperties !== undefined
|
|
78
|
+
? schema.additionalProperties
|
|
79
|
+
: false,
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
tools: [{
|
|
83
|
+
type: 'function',
|
|
84
|
+
function: {
|
|
85
|
+
name: schema.name || 'generate_structured_data',
|
|
86
|
+
description: 'Generate data according to the required schema',
|
|
87
|
+
parameters: processedSchema
|
|
88
|
+
}
|
|
89
|
+
}],
|
|
90
|
+
tool_choice: {
|
|
91
|
+
type: 'function',
|
|
92
|
+
function: {
|
|
93
|
+
name: schema.name || 'generate_structured_data'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
processResponse: (content) => {
|
|
99
|
+
// Handle tool use response
|
|
100
|
+
if (typeof content === 'object') {
|
|
101
|
+
if (content.type === 'tool_use') {
|
|
102
|
+
return JSON.stringify(content.input);
|
|
103
|
+
}
|
|
104
|
+
// Handle newer tool_calls format
|
|
105
|
+
if (content.tool_calls && Array.isArray(content.tool_calls) && content.tool_calls.length > 0) {
|
|
106
|
+
const toolCall = content.tool_calls[0];
|
|
107
|
+
if (toolCall.function && toolCall.function.arguments) {
|
|
108
|
+
try {
|
|
109
|
+
// Try to parse as JSON first
|
|
110
|
+
return toolCall.function.arguments;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
// Return as is if not valid JSON
|
|
114
|
+
return JSON.stringify(toolCall.function.arguments);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return JSON.stringify(content);
|
|
119
|
+
}
|
|
120
|
+
if (typeof content !== 'string') {
|
|
121
|
+
return JSON.stringify(content);
|
|
122
|
+
}
|
|
123
|
+
// Try to extract JSON from content if it might be wrapped
|
|
124
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) ||
|
|
125
|
+
content.match(/```\s*([\s\S]*?)\s*```/) ||
|
|
126
|
+
content.match(/\{[\s\S]*\}/) ||
|
|
127
|
+
[null, content];
|
|
128
|
+
return jsonMatch[1] || content;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* System message approach for other models (Llama, DeepSeek, etc.)
|
|
133
|
+
*/
|
|
134
|
+
const systemMessageStrategy = {
|
|
135
|
+
name: 'system_message',
|
|
136
|
+
prepareRequest: (schema, messages) => {
|
|
137
|
+
if (!schema)
|
|
138
|
+
return { messages };
|
|
139
|
+
// Check if there's already a system message
|
|
140
|
+
const hasSystemMessage = messages.some(m => m.role === 'system');
|
|
141
|
+
if (!hasSystemMessage) {
|
|
142
|
+
// Build a schema description
|
|
143
|
+
const schemaProperties = Object.entries(schema.properties || {})
|
|
144
|
+
.map(([key, value]) => {
|
|
145
|
+
const type = value.type || 'string';
|
|
146
|
+
const description = value.description ? ` // ${value.description}` : '';
|
|
147
|
+
return ` "${key}": ${type}${description}`;
|
|
148
|
+
})
|
|
149
|
+
.join(',\n');
|
|
150
|
+
const systemMessage = {
|
|
151
|
+
role: 'system',
|
|
152
|
+
content: `Please return your response as JSON following this schema exactly:\n{\n${schemaProperties}\n}\nDo not include any explanation or text outside of the JSON object.`
|
|
153
|
+
};
|
|
154
|
+
// Return modified messages array with system message prepended
|
|
155
|
+
return { messages: [systemMessage, ...messages] };
|
|
156
|
+
}
|
|
157
|
+
return { messages };
|
|
158
|
+
},
|
|
159
|
+
processResponse: (content) => {
|
|
160
|
+
if (typeof content !== 'string') {
|
|
161
|
+
return JSON.stringify(content);
|
|
162
|
+
}
|
|
163
|
+
// Try to extract JSON from content if it might be wrapped
|
|
164
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) ||
|
|
165
|
+
content.match(/```\s*([\s\S]*?)\s*```/) ||
|
|
166
|
+
content.match(/\{[\s\S]*\}/) ||
|
|
167
|
+
[null, content];
|
|
168
|
+
return jsonMatch[1] || content;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Default strategy for models without schema
|
|
173
|
+
*/
|
|
174
|
+
const defaultStrategy = {
|
|
175
|
+
name: 'default',
|
|
176
|
+
prepareRequest: () => ({}),
|
|
177
|
+
processResponse: (content) => typeof content === 'string' ? content : JSON.stringify(content)
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Choose the appropriate schema strategy based on model and schema
|
|
181
|
+
*/
|
|
182
|
+
function chooseSchemaStrategy(model, schema) {
|
|
183
|
+
// Default model if not provided
|
|
184
|
+
const resolvedModel = model || (schema ? 'openai/gpt-4o' : 'openrouter/auto');
|
|
185
|
+
// No schema case - use default strategy
|
|
186
|
+
if (!schema) {
|
|
187
|
+
return {
|
|
188
|
+
strategy: 'none',
|
|
189
|
+
model: resolvedModel,
|
|
190
|
+
prepareRequest: defaultStrategy.prepareRequest,
|
|
191
|
+
processResponse: defaultStrategy.processResponse,
|
|
192
|
+
shouldForceStream: false
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Check for Claude models
|
|
196
|
+
if (/claude/i.test(resolvedModel)) {
|
|
197
|
+
return {
|
|
198
|
+
strategy: 'tool_mode',
|
|
199
|
+
model: resolvedModel,
|
|
200
|
+
prepareRequest: claudeStrategy.prepareRequest,
|
|
201
|
+
processResponse: claudeStrategy.processResponse,
|
|
202
|
+
shouldForceStream: !!claudeStrategy.shouldForceStream
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Check for Gemini models
|
|
206
|
+
if (/gemini/i.test(resolvedModel)) {
|
|
207
|
+
return {
|
|
208
|
+
strategy: 'json_schema',
|
|
209
|
+
model: resolvedModel,
|
|
210
|
+
prepareRequest: geminiStrategy.prepareRequest,
|
|
211
|
+
processResponse: geminiStrategy.processResponse,
|
|
212
|
+
shouldForceStream: !!geminiStrategy.shouldForceStream
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// Check for GPT-4 Turbo models - use system message approach
|
|
216
|
+
if (/gpt-4-turbo/i.test(resolvedModel)) {
|
|
217
|
+
return {
|
|
218
|
+
strategy: 'system_message',
|
|
219
|
+
model: resolvedModel,
|
|
220
|
+
prepareRequest: systemMessageStrategy.prepareRequest,
|
|
221
|
+
processResponse: systemMessageStrategy.processResponse,
|
|
222
|
+
shouldForceStream: !!systemMessageStrategy.shouldForceStream
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Check for OpenAI models
|
|
226
|
+
if (/openai|gpt/i.test(resolvedModel)) {
|
|
227
|
+
return {
|
|
228
|
+
strategy: 'json_schema',
|
|
229
|
+
model: resolvedModel,
|
|
230
|
+
prepareRequest: openAIStrategy.prepareRequest,
|
|
231
|
+
processResponse: openAIStrategy.processResponse,
|
|
232
|
+
shouldForceStream: !!openAIStrategy.shouldForceStream
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// Check for other specific models that need system message approach
|
|
236
|
+
if (/llama-3|deepseek/i.test(resolvedModel)) {
|
|
237
|
+
return {
|
|
238
|
+
strategy: 'system_message',
|
|
239
|
+
model: resolvedModel,
|
|
240
|
+
prepareRequest: systemMessageStrategy.prepareRequest,
|
|
241
|
+
processResponse: systemMessageStrategy.processResponse,
|
|
242
|
+
shouldForceStream: !!systemMessageStrategy.shouldForceStream
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// Default to system message approach for unknown models with schema
|
|
246
|
+
return {
|
|
247
|
+
strategy: 'system_message',
|
|
248
|
+
model: resolvedModel,
|
|
249
|
+
prepareRequest: systemMessageStrategy.prepareRequest,
|
|
250
|
+
processResponse: systemMessageStrategy.processResponse,
|
|
251
|
+
shouldForceStream: !!systemMessageStrategy.shouldForceStream
|
|
252
|
+
};
|
|
253
|
+
}
|
|
7
254
|
/**
|
|
8
255
|
* Make an AI API call with the given options
|
|
9
256
|
* @param prompt User prompt as string or an array of message objects
|
|
@@ -12,59 +259,98 @@ exports.callAI = callAI;
|
|
|
12
259
|
* or an AsyncGenerator that yields partial responses when streaming is enabled
|
|
13
260
|
*/
|
|
14
261
|
function callAI(prompt, options = {}) {
|
|
15
|
-
//
|
|
262
|
+
// Check if we need to force streaming based on model strategy
|
|
263
|
+
const schemaStrategy = chooseSchemaStrategy(options.model, options.schema || null);
|
|
264
|
+
// Handle special case: Claude with tools requires streaming
|
|
265
|
+
if (!options.stream && schemaStrategy.shouldForceStream) {
|
|
266
|
+
// Buffer streaming results into a single response
|
|
267
|
+
return bufferStreamingResults(prompt, options);
|
|
268
|
+
}
|
|
269
|
+
// Handle normal non-streaming mode
|
|
16
270
|
if (options.stream !== true) {
|
|
17
271
|
return callAINonStreaming(prompt, options);
|
|
18
272
|
}
|
|
19
273
|
// Handle streaming mode
|
|
20
274
|
return callAIStreaming(prompt, options);
|
|
21
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Buffer streaming results into a single response for cases where
|
|
278
|
+
* we need to use streaming internally but the caller requested non-streaming
|
|
279
|
+
*/
|
|
280
|
+
async function bufferStreamingResults(prompt, options) {
|
|
281
|
+
// Create a copy of options with streaming enabled
|
|
282
|
+
const streamingOptions = {
|
|
283
|
+
...options,
|
|
284
|
+
stream: true
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
// Get streaming generator
|
|
288
|
+
const generator = callAIStreaming(prompt, streamingOptions);
|
|
289
|
+
// Buffer all chunks
|
|
290
|
+
let finalResult = '';
|
|
291
|
+
let chunkCount = 0;
|
|
292
|
+
for await (const chunk of generator) {
|
|
293
|
+
finalResult = chunk; // Each chunk contains the full accumulated text
|
|
294
|
+
chunkCount++;
|
|
295
|
+
}
|
|
296
|
+
return finalResult;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
console.error("[bufferStreamingResults] Streaming buffer error:", error);
|
|
300
|
+
return JSON.stringify({
|
|
301
|
+
error: String(error),
|
|
302
|
+
message: "Error while processing streaming response: " + String(error)
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
22
306
|
/**
|
|
23
307
|
* Prepare request parameters common to both streaming and non-streaming calls
|
|
24
308
|
*/
|
|
25
309
|
function prepareRequestParams(prompt, options) {
|
|
26
310
|
const apiKey = options.apiKey || (typeof window !== 'undefined' ? window.CALLAI_API_KEY : null);
|
|
27
|
-
const model = options.model || 'openrouter/auto';
|
|
28
|
-
const endpoint = options.endpoint || 'https://openrouter.ai/api/v1/chat/completions';
|
|
29
311
|
const schema = options.schema || null;
|
|
30
312
|
if (!apiKey) {
|
|
31
313
|
throw new Error('API key is required. Provide it via options.apiKey or set window.CALLAI_API_KEY');
|
|
32
314
|
}
|
|
315
|
+
// Select the appropriate strategy based on model and schema
|
|
316
|
+
const schemaStrategy = chooseSchemaStrategy(options.model, schema);
|
|
317
|
+
const model = schemaStrategy.model;
|
|
318
|
+
const endpoint = options.endpoint || 'https://openrouter.ai/api/v1/chat/completions';
|
|
33
319
|
// Handle both string prompts and message arrays for backward compatibility
|
|
34
320
|
const messages = Array.isArray(prompt)
|
|
35
321
|
? prompt
|
|
36
322
|
: [{ role: 'user', content: prompt }];
|
|
323
|
+
// Build request parameters
|
|
324
|
+
const requestParams = {
|
|
325
|
+
model: model,
|
|
326
|
+
stream: options.stream === true,
|
|
327
|
+
messages: messages,
|
|
328
|
+
};
|
|
329
|
+
// Apply the strategy's request preparation
|
|
330
|
+
const strategyParams = schemaStrategy.prepareRequest(schema, messages);
|
|
331
|
+
// If the strategy returns custom messages, use those instead
|
|
332
|
+
if (strategyParams.messages) {
|
|
333
|
+
requestParams.messages = strategyParams.messages;
|
|
334
|
+
}
|
|
335
|
+
// Add all other strategy parameters
|
|
336
|
+
Object.entries(strategyParams).forEach(([key, value]) => {
|
|
337
|
+
if (key !== 'messages') {
|
|
338
|
+
requestParams[key] = value;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
// Add any other options provided, but exclude internal keys
|
|
342
|
+
Object.entries(options).forEach(([key, value]) => {
|
|
343
|
+
if (!['apiKey', 'model', 'endpoint', 'stream', 'schema'].includes(key)) {
|
|
344
|
+
requestParams[key] = value;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
37
347
|
const requestOptions = {
|
|
38
348
|
method: 'POST',
|
|
39
349
|
headers: {
|
|
40
350
|
'Authorization': `Bearer ${apiKey}`,
|
|
41
351
|
'Content-Type': 'application/json'
|
|
42
352
|
},
|
|
43
|
-
body: JSON.stringify(
|
|
44
|
-
model: model,
|
|
45
|
-
stream: options.stream === true,
|
|
46
|
-
messages: messages,
|
|
47
|
-
// Pass through any additional options like temperature, but exclude internal keys
|
|
48
|
-
...Object.fromEntries(Object.entries(options).filter(([key]) => !['apiKey', 'model', 'endpoint', 'stream', 'schema'].includes(key))),
|
|
49
|
-
// Handle schema if provided
|
|
50
|
-
...(schema && {
|
|
51
|
-
response_format: {
|
|
52
|
-
type: 'json_schema',
|
|
53
|
-
json_schema: {
|
|
54
|
-
// Include name if provided
|
|
55
|
-
...(schema.name && { name: schema.name }),
|
|
56
|
-
type: 'object',
|
|
57
|
-
properties: schema.properties || {},
|
|
58
|
-
required: schema.required || Object.keys(schema.properties || {}),
|
|
59
|
-
additionalProperties: schema.additionalProperties !== undefined
|
|
60
|
-
? schema.additionalProperties
|
|
61
|
-
: false,
|
|
62
|
-
// Copy any additional schema properties (excluding properties we've already handled)
|
|
63
|
-
...Object.fromEntries(Object.entries(schema).filter(([key]) => !['name', 'properties', 'required', 'additionalProperties'].includes(key)))
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
})
|
|
353
|
+
body: JSON.stringify(requestParams)
|
|
68
354
|
};
|
|
69
355
|
return { apiKey, model, endpoint, requestOptions };
|
|
70
356
|
}
|
|
@@ -73,15 +359,95 @@ function prepareRequestParams(prompt, options) {
|
|
|
73
359
|
*/
|
|
74
360
|
async function callAINonStreaming(prompt, options = {}) {
|
|
75
361
|
try {
|
|
76
|
-
const { endpoint, requestOptions } = prepareRequestParams(prompt, options);
|
|
362
|
+
const { endpoint, requestOptions, model } = prepareRequestParams(prompt, options);
|
|
363
|
+
const schemaStrategy = chooseSchemaStrategy(model, options.schema || null);
|
|
77
364
|
const response = await fetch(endpoint, requestOptions);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
365
|
+
let result;
|
|
366
|
+
// For Claude, use text() instead of json() to avoid potential hanging
|
|
367
|
+
if (/claude/i.test(model)) {
|
|
368
|
+
// Create a timeout wrapper for text() to prevent hanging
|
|
369
|
+
try {
|
|
370
|
+
let textResponse;
|
|
371
|
+
const textPromise = response.text();
|
|
372
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
reject(new Error('Text extraction timed out after 5 seconds'));
|
|
375
|
+
}, 5000);
|
|
376
|
+
});
|
|
377
|
+
try {
|
|
378
|
+
textResponse = await Promise.race([textPromise, timeoutPromise]);
|
|
379
|
+
}
|
|
380
|
+
catch (textError) {
|
|
381
|
+
console.error(`Text extraction timed out or failed:`, textError);
|
|
382
|
+
return JSON.stringify({
|
|
383
|
+
error: true,
|
|
384
|
+
message: "Claude response text extraction timed out. This is likely an issue with the Claude API's response format."
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
result = JSON.parse(textResponse);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
console.error(`Failed to parse Claude response as JSON:`, err);
|
|
392
|
+
throw new Error(`Failed to parse Claude response as JSON: ${err}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
console.error(`Claude text extraction error:`, error);
|
|
397
|
+
return JSON.stringify({
|
|
398
|
+
error: true,
|
|
399
|
+
message: `Claude API response processing failed: ${error}`
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
result = await response.json();
|
|
405
|
+
}
|
|
406
|
+
// Handle error responses
|
|
407
|
+
if (result.error) {
|
|
408
|
+
console.error("API returned an error:", result.error);
|
|
409
|
+
return JSON.stringify({
|
|
410
|
+
error: result.error,
|
|
411
|
+
message: result.error.message || "API returned an error"
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// Find tool use content or normal content
|
|
415
|
+
let content;
|
|
416
|
+
// Extract tool use content if necessary
|
|
417
|
+
if (schemaStrategy.strategy === 'tool_mode' && result.stop_reason === 'tool_use') {
|
|
418
|
+
// Try to find tool_use block in different response formats
|
|
419
|
+
if (result.content && Array.isArray(result.content)) {
|
|
420
|
+
const toolUseBlock = result.content.find((block) => block.type === 'tool_use');
|
|
421
|
+
if (toolUseBlock) {
|
|
422
|
+
content = toolUseBlock;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!content && result.choices && Array.isArray(result.choices)) {
|
|
426
|
+
const choice = result.choices[0];
|
|
427
|
+
if (choice.message && Array.isArray(choice.message.content)) {
|
|
428
|
+
const toolUseBlock = choice.message.content.find((block) => block.type === 'tool_use');
|
|
429
|
+
if (toolUseBlock) {
|
|
430
|
+
content = toolUseBlock;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// If no tool use content was found, use the standard message content
|
|
436
|
+
if (!content) {
|
|
437
|
+
if (!result.choices || !result.choices.length) {
|
|
438
|
+
throw new Error('Invalid response format from API');
|
|
439
|
+
}
|
|
440
|
+
content = result.choices[0]?.message?.content || '';
|
|
441
|
+
}
|
|
442
|
+
// Process the content based on model type
|
|
443
|
+
return schemaStrategy.processResponse(content);
|
|
81
444
|
}
|
|
82
445
|
catch (error) {
|
|
83
446
|
console.error("AI call failed:", error);
|
|
84
|
-
return
|
|
447
|
+
return JSON.stringify({
|
|
448
|
+
error,
|
|
449
|
+
message: "Sorry, I couldn't process that request."
|
|
450
|
+
});
|
|
85
451
|
}
|
|
86
452
|
}
|
|
87
453
|
/**
|
|
@@ -89,38 +455,202 @@ async function callAINonStreaming(prompt, options = {}) {
|
|
|
89
455
|
*/
|
|
90
456
|
async function* callAIStreaming(prompt, options = {}) {
|
|
91
457
|
try {
|
|
92
|
-
const { endpoint, requestOptions } = prepareRequestParams(prompt, { ...options, stream: true });
|
|
458
|
+
const { endpoint, requestOptions, model } = prepareRequestParams(prompt, { ...options, stream: true });
|
|
459
|
+
const schemaStrategy = chooseSchemaStrategy(model, options.schema || null);
|
|
93
460
|
const response = await fetch(endpoint, requestOptions);
|
|
461
|
+
if (!response.ok) {
|
|
462
|
+
const errorText = await response.text();
|
|
463
|
+
console.error(`API Error: ${response.status} ${response.statusText}`, errorText);
|
|
464
|
+
throw new Error(`API returned error ${response.status}: ${response.statusText}`);
|
|
465
|
+
}
|
|
94
466
|
// Handle streaming response
|
|
467
|
+
if (!response.body) {
|
|
468
|
+
throw new Error("Response body is undefined - API endpoint may not support streaming");
|
|
469
|
+
}
|
|
95
470
|
const reader = response.body.getReader();
|
|
96
471
|
const decoder = new TextDecoder();
|
|
97
|
-
let
|
|
472
|
+
let completeText = '';
|
|
473
|
+
let chunkCount = 0;
|
|
474
|
+
let toolCallsAssembled = '';
|
|
98
475
|
while (true) {
|
|
99
476
|
const { done, value } = await reader.read();
|
|
100
|
-
if (done)
|
|
477
|
+
if (done) {
|
|
101
478
|
break;
|
|
479
|
+
}
|
|
102
480
|
const chunk = decoder.decode(value);
|
|
103
481
|
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
|
104
482
|
for (const line of lines) {
|
|
105
483
|
if (line.startsWith('data: ')) {
|
|
106
|
-
|
|
484
|
+
// Skip [DONE] marker or OPENROUTER PROCESSING lines
|
|
485
|
+
if (line.includes('[DONE]') || line.includes('OPENROUTER PROCESSING')) {
|
|
107
486
|
continue;
|
|
487
|
+
}
|
|
108
488
|
try {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
489
|
+
const jsonLine = line.replace('data: ', '');
|
|
490
|
+
if (!jsonLine.trim()) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
chunkCount++;
|
|
494
|
+
// Parse the JSON chunk
|
|
495
|
+
const json = JSON.parse(jsonLine);
|
|
496
|
+
// Handle tool use response - Claude with schema cases
|
|
497
|
+
const isClaudeWithSchema = /claude/i.test(model) && schemaStrategy.strategy === 'tool_mode';
|
|
498
|
+
if (isClaudeWithSchema) {
|
|
499
|
+
// Claude streaming tool calls - need to assemble arguments
|
|
500
|
+
if (json.choices && json.choices.length > 0) {
|
|
501
|
+
const choice = json.choices[0];
|
|
502
|
+
// Handle finish reason tool_calls
|
|
503
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
504
|
+
try {
|
|
505
|
+
// Parse the assembled JSON
|
|
506
|
+
completeText = toolCallsAssembled;
|
|
507
|
+
yield completeText;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
console.error('[callAIStreaming] Error parsing assembled tool call:', e);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Assemble tool_calls arguments from delta
|
|
515
|
+
if (choice.delta && choice.delta.tool_calls) {
|
|
516
|
+
const toolCall = choice.delta.tool_calls[0];
|
|
517
|
+
if (toolCall && toolCall.function && toolCall.function.arguments !== undefined) {
|
|
518
|
+
toolCallsAssembled += toolCall.function.arguments;
|
|
519
|
+
// We don't yield here to avoid partial JSON
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Handle tool use response - old format
|
|
525
|
+
if (isClaudeWithSchema && (json.stop_reason === 'tool_use' || json.type === 'tool_use')) {
|
|
526
|
+
// First try direct tool use object format
|
|
527
|
+
if (json.type === 'tool_use') {
|
|
528
|
+
completeText = schemaStrategy.processResponse(json);
|
|
529
|
+
yield completeText;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
// Extract the tool use content
|
|
533
|
+
if (json.content && Array.isArray(json.content)) {
|
|
534
|
+
const toolUseBlock = json.content.find((block) => block.type === 'tool_use');
|
|
535
|
+
if (toolUseBlock) {
|
|
536
|
+
completeText = schemaStrategy.processResponse(toolUseBlock);
|
|
537
|
+
yield completeText;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Find tool_use in assistant's content blocks
|
|
542
|
+
if (json.choices && Array.isArray(json.choices)) {
|
|
543
|
+
const choice = json.choices[0];
|
|
544
|
+
if (choice.message && Array.isArray(choice.message.content)) {
|
|
545
|
+
const toolUseBlock = choice.message.content.find((block) => block.type === 'tool_use');
|
|
546
|
+
if (toolUseBlock) {
|
|
547
|
+
completeText = schemaStrategy.processResponse(toolUseBlock);
|
|
548
|
+
yield completeText;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Handle case where the tool use is in the delta
|
|
553
|
+
if (choice.delta && Array.isArray(choice.delta.content)) {
|
|
554
|
+
const toolUseBlock = choice.delta.content.find((block) => block.type === 'tool_use');
|
|
555
|
+
if (toolUseBlock) {
|
|
556
|
+
completeText = schemaStrategy.processResponse(toolUseBlock);
|
|
557
|
+
yield completeText;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Extract content from the delta
|
|
564
|
+
if (json.choices?.[0]?.delta?.content !== undefined) {
|
|
565
|
+
const content = json.choices[0].delta.content || '';
|
|
566
|
+
// Treat all models the same - yield as content arrives
|
|
567
|
+
completeText += content;
|
|
568
|
+
yield schemaStrategy.processResponse(completeText);
|
|
569
|
+
}
|
|
570
|
+
// Handle message content format (non-streaming deltas)
|
|
571
|
+
else if (json.choices?.[0]?.message?.content !== undefined) {
|
|
572
|
+
const content = json.choices[0].message.content || '';
|
|
573
|
+
completeText += content;
|
|
574
|
+
yield schemaStrategy.processResponse(completeText);
|
|
575
|
+
}
|
|
576
|
+
// Handle content blocks for Claude/Anthropic response format
|
|
577
|
+
else if (json.choices?.[0]?.message?.content && Array.isArray(json.choices[0].message.content)) {
|
|
578
|
+
const contentBlocks = json.choices[0].message.content;
|
|
579
|
+
// Find text or tool_use blocks
|
|
580
|
+
for (const block of contentBlocks) {
|
|
581
|
+
if (block.type === 'text') {
|
|
582
|
+
completeText += block.text || '';
|
|
583
|
+
}
|
|
584
|
+
else if (isClaudeWithSchema && block.type === 'tool_use') {
|
|
585
|
+
completeText = schemaStrategy.processResponse(block);
|
|
586
|
+
break; // We found what we need
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
yield schemaStrategy.processResponse(completeText);
|
|
590
|
+
}
|
|
113
591
|
}
|
|
114
592
|
catch (e) {
|
|
115
|
-
console.error(
|
|
593
|
+
console.error(`[callAIStreaming] Error parsing JSON chunk:`, e);
|
|
116
594
|
}
|
|
117
595
|
}
|
|
118
596
|
}
|
|
119
597
|
}
|
|
120
|
-
|
|
598
|
+
// If we have assembled tool calls but haven't yielded them yet
|
|
599
|
+
if (toolCallsAssembled && (!completeText || completeText.length === 0)) {
|
|
600
|
+
return toolCallsAssembled;
|
|
601
|
+
}
|
|
602
|
+
// Ensure the final return has proper, processed content
|
|
603
|
+
return schemaStrategy.processResponse(completeText);
|
|
121
604
|
}
|
|
122
605
|
catch (error) {
|
|
123
|
-
console.error("AI call failed:", error);
|
|
124
|
-
return
|
|
606
|
+
console.error("[callAIStreaming] AI call failed:", error);
|
|
607
|
+
return JSON.stringify({
|
|
608
|
+
error: String(error),
|
|
609
|
+
message: "Sorry, I couldn't process that request."
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Recursively adds additionalProperties: false to all object types in a schema
|
|
615
|
+
* This is needed for OpenAI's strict schema validation in streaming mode
|
|
616
|
+
*/
|
|
617
|
+
function recursivelyAddAdditionalProperties(schema) {
|
|
618
|
+
// Clone to avoid modifying the original
|
|
619
|
+
const result = { ...schema };
|
|
620
|
+
// If this is an object type, ensure it has additionalProperties: false
|
|
621
|
+
if (result.type === 'object') {
|
|
622
|
+
// Set additionalProperties if not already set
|
|
623
|
+
if (result.additionalProperties === undefined) {
|
|
624
|
+
result.additionalProperties = false;
|
|
625
|
+
}
|
|
626
|
+
// Process nested properties if they exist
|
|
627
|
+
if (result.properties) {
|
|
628
|
+
result.properties = { ...result.properties };
|
|
629
|
+
// Set required if not already set - OpenAI requires this for all nested objects
|
|
630
|
+
if (result.required === undefined) {
|
|
631
|
+
result.required = Object.keys(result.properties);
|
|
632
|
+
}
|
|
633
|
+
// Check each property
|
|
634
|
+
Object.keys(result.properties).forEach(key => {
|
|
635
|
+
const prop = result.properties[key];
|
|
636
|
+
// If property is an object or array type, recursively process it
|
|
637
|
+
if (prop && typeof prop === 'object') {
|
|
638
|
+
result.properties[key] = recursivelyAddAdditionalProperties(prop);
|
|
639
|
+
// For nested objects, ensure they also have all properties in their required field
|
|
640
|
+
if (prop.type === 'object' && prop.properties) {
|
|
641
|
+
prop.required = Object.keys(prop.properties);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Handle nested objects in arrays
|
|
648
|
+
if (result.type === 'array' && result.items && typeof result.items === 'object') {
|
|
649
|
+
result.items = recursivelyAddAdditionalProperties(result.items);
|
|
650
|
+
// If array items are objects, ensure they have all properties in required
|
|
651
|
+
if (result.items.type === 'object' && result.items.properties) {
|
|
652
|
+
result.items.required = Object.keys(result.items.properties);
|
|
653
|
+
}
|
|
125
654
|
}
|
|
655
|
+
return result;
|
|
126
656
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "call-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Lightweight library for making AI API calls with streaming support",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"browser": "dist/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc",
|
|
21
21
|
"test": "jest",
|
|
22
|
+
"test:integration": "jest --testMatch=\"**/test/integration.test.ts\" --testPathIgnorePatterns=''",
|
|
22
23
|
"prepublishOnly": "npm run build",
|
|
23
24
|
"typecheck": "tsc --noEmit"
|
|
24
25
|
},
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/jest": "^29.5.3",
|
|
38
39
|
"@types/node": "^20.4.2",
|
|
40
|
+
"dotenv": "^16.4.7",
|
|
39
41
|
"jest": "^29.6.1",
|
|
40
42
|
"ts-jest": "^29.1.1",
|
|
41
43
|
"typescript": "^5.1.6"
|