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 CHANGED
@@ -109,15 +109,45 @@ for await (const chunk of generator) {
109
109
 
110
110
  ## Supported LLM Providers
111
111
 
112
- By default, call-ai uses the OpenRouter API which provides access to multiple LLM models. You can also configure it to use other providers with OpenAI-compatible APIs:
112
+ Call-AI supports all models available through OpenRouter, including:
113
113
 
114
- - [OpenRouter](https://openrouter.ai/) (default)
115
- - [OpenAI](https://openai.com/)
116
- - [Anthropic Claude](https://www.anthropic.com/) (via OpenRouter)
117
- - [Mistral](https://mistral.ai/) (via OpenRouter)
118
- - Any API with OpenAI-compatible endpoints
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
- See [llms.txt](./llms.txt) for a full list of compatible models.
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
@@ -8,6 +8,7 @@ export type Message = {
8
8
  export interface Schema {
9
9
  /**
10
10
  * Optional schema name - will be sent to OpenRouter if provided
11
+ * If not specified, defaults to "result"
11
12
  */
12
13
  name?: string;
13
14
  /**
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
- // Handle non-streaming mode (default)
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
- const result = await response.json();
79
- const content = result.choices[0]?.message?.content || '';
80
- return content;
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 "Sorry, I couldn't process that request.";
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 text = '';
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
- if (line.includes('[DONE]'))
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 json = JSON.parse(line.replace('data: ', ''));
110
- const content = json.choices[0]?.delta?.content || '';
111
- text += content;
112
- yield text;
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("Error parsing chunk:", e);
593
+ console.error(`[callAIStreaming] Error parsing JSON chunk:`, e);
116
594
  }
117
595
  }
118
596
  }
119
597
  }
120
- return text;
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 "Sorry, I couldn't process that request.";
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.1",
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"