@proteinjs/conversation 2.7.2 → 3.0.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/src/CodegenConversation.js +1 -1
  8. package/dist/src/CodegenConversation.js.map +1 -1
  9. package/dist/src/Conversation.d.ts +173 -99
  10. package/dist/src/Conversation.d.ts.map +1 -1
  11. package/dist/src/Conversation.js +903 -502
  12. package/dist/src/Conversation.js.map +1 -1
  13. package/dist/src/OpenAi.d.ts +20 -0
  14. package/dist/src/OpenAi.d.ts.map +1 -1
  15. package/dist/src/OpenAi.js +16 -0
  16. package/dist/src/OpenAi.js.map +1 -1
  17. package/dist/src/OpenAiStreamProcessor.d.ts +9 -3
  18. package/dist/src/OpenAiStreamProcessor.d.ts.map +1 -1
  19. package/dist/src/OpenAiStreamProcessor.js +5 -3
  20. package/dist/src/OpenAiStreamProcessor.js.map +1 -1
  21. package/dist/src/UsageData.d.ts.map +1 -1
  22. package/dist/src/UsageData.js +22 -0
  23. package/dist/src/UsageData.js.map +1 -1
  24. package/dist/src/code_template/Code.d.ts.map +1 -1
  25. package/dist/src/code_template/Code.js +8 -2
  26. package/dist/src/code_template/Code.js.map +1 -1
  27. package/dist/src/resolveModel.d.ts +17 -0
  28. package/dist/src/resolveModel.d.ts.map +1 -0
  29. package/dist/src/resolveModel.js +121 -0
  30. package/dist/src/resolveModel.js.map +1 -0
  31. package/dist/test/conversation/conversation.generateObject.test.d.ts +2 -0
  32. package/dist/test/conversation/conversation.generateObject.test.d.ts.map +1 -0
  33. package/dist/test/conversation/conversation.generateObject.test.js +153 -0
  34. package/dist/test/conversation/conversation.generateObject.test.js.map +1 -0
  35. package/dist/test/conversation/conversation.generateResponse.test.d.ts +2 -0
  36. package/dist/test/conversation/conversation.generateResponse.test.d.ts.map +1 -0
  37. package/dist/test/conversation/conversation.generateResponse.test.js +167 -0
  38. package/dist/test/conversation/conversation.generateResponse.test.js.map +1 -0
  39. package/dist/test/conversation/conversation.generateStream.test.d.ts +2 -0
  40. package/dist/test/conversation/conversation.generateStream.test.d.ts.map +1 -0
  41. package/dist/test/conversation/conversation.generateStream.test.js +255 -0
  42. package/dist/test/conversation/conversation.generateStream.test.js.map +1 -0
  43. package/index.ts +5 -0
  44. package/package.json +7 -2
  45. package/src/CodegenConversation.ts +1 -1
  46. package/src/Conversation.ts +938 -496
  47. package/src/OpenAi.ts +20 -0
  48. package/src/OpenAiStreamProcessor.ts +9 -3
  49. package/src/UsageData.ts +25 -0
  50. package/src/code_template/Code.ts +5 -1
  51. package/src/resolveModel.ts +130 -0
  52. package/test/conversation/conversation.generateObject.test.ts +132 -0
  53. package/test/conversation/conversation.generateResponse.test.ts +132 -0
  54. package/test/conversation/conversation.generateStream.test.ts +173 -0
package/src/OpenAi.ts CHANGED
@@ -60,6 +60,7 @@ export const isToolInvocationFinishEvent = (event: ToolInvocationProgressEvent):
60
60
 
61
61
  export type ToolInvocationProgressEvent = ToolInvocationStartEvent | ToolInvocationFinishEvent;
62
62
 
63
+ /** @deprecated Use `GenerateStreamParams` from `Conversation` instead. */
63
64
  export type GenerateResponseParams = {
64
65
  messages: (string | ChatCompletionMessageParam)[];
65
66
  model?: TiktokenModel;
@@ -69,6 +70,7 @@ export type GenerateResponseParams = {
69
70
  reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
70
71
  };
71
72
 
73
+ /** @deprecated Use `GenerateResponseResult` from `Conversation` instead. */
72
74
  export type GenerateResponseReturn = {
73
75
  message: string;
74
76
  usagedata: UsageData;
@@ -76,6 +78,7 @@ export type GenerateResponseReturn = {
76
78
  toolInvocations: ToolInvocationResult[];
77
79
  };
78
80
 
81
+ /** @deprecated Use `GenerateStreamParams` from `Conversation` instead. */
79
82
  export type GenerateStreamingResponseParams = GenerateResponseParams & {
80
83
  abortSignal?: AbortSignal;
81
84
  onUsageData?: (usageData: UsageData) => Promise<void>;
@@ -90,6 +93,7 @@ type GenerateResponseHelperParams = GenerateStreamingResponseParams & {
90
93
  toolInvocations?: ToolInvocationResult[];
91
94
  };
92
95
 
96
+ /** @deprecated Use `ConversationParams` from `Conversation` instead. */
93
97
  export type OpenAiParams = {
94
98
  model?: TiktokenModel;
95
99
  history?: MessageHistory;
@@ -99,9 +103,25 @@ export type OpenAiParams = {
99
103
  logLevel?: LogLevel;
100
104
  };
101
105
 
106
+ /** @deprecated Use `Conversation` with `defaultModel` instead. */
102
107
  export const DEFAULT_MODEL: TiktokenModel = 'gpt-4o';
108
+ /** @deprecated Use `GenerateStreamParams.maxToolCalls` instead. */
103
109
  export const DEFAULT_MAX_FUNCTION_CALLS = 50;
104
110
 
111
+ /**
112
+ * @deprecated Use the `Conversation` class instead.
113
+ *
114
+ * `Conversation` provides multi-provider support (OpenAI, Anthropic, Google, xAI)
115
+ * via the Vercel AI SDK, with streaming-first text generation (`generateStream`),
116
+ * structured object output (`generateObject`), and a non-streaming convenience
117
+ * method (`generateResponse`).
118
+ *
119
+ * Migration guide:
120
+ * - `new OpenAi({ model, functions })` → `new Conversation({ name, modules, defaultModel })`
121
+ * - `openAi.generateResponse({ messages })` → `conversation.generateResponse({ messages })`
122
+ * - `openAi.generateStreamingResponse({ messages })` → `conversation.generateStream({ messages })`
123
+ * - `openAi.generateList({ messages })` → `conversation.generateObject({ messages, schema })`
124
+ */
105
125
  export class OpenAi {
106
126
  private model: TiktokenModel;
107
127
  private history: MessageHistory;
@@ -4,15 +4,21 @@ import { Stream } from 'openai/streaming';
4
4
  import { Readable, Transform, TransformCallback, PassThrough } from 'stream';
5
5
  import { UsageData, UsageDataAccumulator } from './UsageData';
6
6
 
7
+ /**
8
+ * @deprecated Use `StreamResult.textStream` from `Conversation.generateStream` instead.
9
+ * The AI SDK handles streaming natively.
10
+ */
7
11
  export interface AssistantResponseStreamChunk {
8
12
  content?: string;
9
13
  finishReason?: string;
10
14
  }
11
15
 
12
16
  /**
13
- * Processes streaming responses from OpenAI's `ChatCompletions` api.
14
- * - When a tool call is received, it delegates processing to `onToolCalls`; this can happen recursively
15
- * - When a response to the user is received, it writes to `outputStream`
17
+ * @deprecated Use `Conversation.generateStream` instead.
18
+ *
19
+ * The Vercel AI SDK handles streaming tool call loops internally via
20
+ * `streamText()` with `execute` functions on each tool. This class
21
+ * is only retained for backward compatibility with the legacy `OpenAi` class.
16
22
  */
17
23
  export class OpenAiStreamProcessor {
18
24
  private logger: Logger;
package/src/UsageData.ts CHANGED
@@ -246,6 +246,31 @@ export const MODEL_API_COST_USD_PER_1M_TOKENS_STANDARD: Record<string, ModelApiC
246
246
 
247
247
  'gpt-5.1-codex-mini': { inputUsdPer1M: 0.25, cachedInputUsdPer1M: 0.025, outputUsdPer1M: 2.0 },
248
248
  'codex-mini-latest': { inputUsdPer1M: 1.5, cachedInputUsdPer1M: 0.375, outputUsdPer1M: 6.0 },
249
+
250
+ // ── Anthropic Claude models ──
251
+ 'claude-opus-4.5': { inputUsdPer1M: 5.0, cachedInputUsdPer1M: 0.5, outputUsdPer1M: 25.0 },
252
+ 'claude-opus-4-20250514': { inputUsdPer1M: 15.0, cachedInputUsdPer1M: 1.5, outputUsdPer1M: 75.0 },
253
+ 'claude-opus-4.1': { inputUsdPer1M: 15.0, cachedInputUsdPer1M: 1.5, outputUsdPer1M: 75.0 },
254
+ 'claude-sonnet-4.6': { inputUsdPer1M: 3.0, cachedInputUsdPer1M: 0.3, outputUsdPer1M: 15.0 },
255
+ 'claude-sonnet-4.5': { inputUsdPer1M: 3.0, cachedInputUsdPer1M: 0.3, outputUsdPer1M: 15.0 },
256
+ 'claude-sonnet-4-20250514': { inputUsdPer1M: 3.0, cachedInputUsdPer1M: 0.3, outputUsdPer1M: 15.0 },
257
+ 'claude-haiku-4.5': { inputUsdPer1M: 1.0, cachedInputUsdPer1M: 0.1, outputUsdPer1M: 5.0 },
258
+ 'claude-3-haiku-20240307': { inputUsdPer1M: 0.25, cachedInputUsdPer1M: 0.03, outputUsdPer1M: 1.25 },
259
+
260
+ // ── Google Gemini models ──
261
+ 'gemini-3-pro-preview': { inputUsdPer1M: 2.0, outputUsdPer1M: 12.0 },
262
+ 'gemini-3-flash': { inputUsdPer1M: 0.5, outputUsdPer1M: 3.0 },
263
+ 'gemini-2.5-pro': { inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10.0 },
264
+ 'gemini-2.5-flash': { inputUsdPer1M: 0.3, cachedInputUsdPer1M: 0.03, outputUsdPer1M: 2.5 },
265
+ 'gemini-2.0-flash': { inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
266
+ 'gemini-2.0-flash-lite': { inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
267
+
268
+ // ── xAI Grok models ──
269
+ 'grok-4': { inputUsdPer1M: 3.0, outputUsdPer1M: 15.0 },
270
+ 'grok-4-fast': { inputUsdPer1M: 0.2, outputUsdPer1M: 0.5 },
271
+ 'grok-4.1-fast': { inputUsdPer1M: 0.2, outputUsdPer1M: 0.5 },
272
+ 'grok-3': { inputUsdPer1M: 3.0, outputUsdPer1M: 15.0 },
273
+ 'grok-3-mini': { inputUsdPer1M: 0.3, outputUsdPer1M: 0.5 },
249
274
  };
250
275
 
251
276
  export const MODEL_API_COST_USD_PER_1M_TOKENS_BATCH: Record<string, ModelApiCost> = {
@@ -31,7 +31,11 @@ export class Code {
31
31
  this.addImports(this.args.imports, this.args.conversation);
32
32
  }
33
33
 
34
- return await this.args.conversation.generateCode({ description: this.args.description, model: 'gpt-4' });
34
+ const result = await this.args.conversation.generateResponse({
35
+ messages: this.args.description,
36
+ model: 'gpt-4',
37
+ });
38
+ return result.text;
35
39
  }
36
40
 
37
41
  private addImports(imports: Import[], conversation: Conversation) {
@@ -0,0 +1,130 @@
1
+ import type { LanguageModel } from 'ai';
2
+
3
+ /**
4
+ * Known provider prefixes and their model factory functions.
5
+ *
6
+ * Each factory lazily imports the provider package and creates a model instance.
7
+ * This keeps imports optional: if a provider package is not installed, the
8
+ * factory simply throws a helpful error at call time.
9
+ */
10
+ const PROVIDER_FACTORIES: Record<string, (modelId: string) => LanguageModel> = {
11
+ openai: (modelId) => {
12
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
13
+ const { openai } = require('@ai-sdk/openai');
14
+ return openai(modelId);
15
+ },
16
+ anthropic: (modelId) => {
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const { anthropic } = require('@ai-sdk/anthropic');
19
+ return anthropic(modelId);
20
+ },
21
+ google: (modelId) => {
22
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
23
+ const { google } = require('@ai-sdk/google');
24
+ return google(modelId);
25
+ },
26
+ xai: (modelId) => {
27
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
28
+ const { xai } = require('@ai-sdk/xai');
29
+ return xai(modelId);
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Model name patterns that map to a provider.
35
+ *
36
+ * Order matters: first match wins. Patterns are tested against the raw model
37
+ * string (case-insensitive).
38
+ */
39
+ const MODEL_PROVIDER_PATTERNS: Array<{ test: RegExp; provider: string }> = [
40
+ // OpenAI models
41
+ { test: /^(gpt-|o[134]-|o[134]$|chatgpt|dall-e|computer-use|codex)/i, provider: 'openai' },
42
+
43
+ // Anthropic models
44
+ { test: /^claude/i, provider: 'anthropic' },
45
+
46
+ // Google models
47
+ { test: /^(gemini|gemma)/i, provider: 'google' },
48
+
49
+ // xAI models
50
+ { test: /^grok/i, provider: 'xai' },
51
+ ];
52
+
53
+ /**
54
+ * Resolve a model identifier to a concrete `LanguageModel` instance.
55
+ *
56
+ * Accepted inputs:
57
+ * - A `LanguageModel` instance (returned as-is)
58
+ * - A prefixed string like `"openai:gpt-5"` or `"anthropic:claude-sonnet-4-20250514"`
59
+ * - A bare model name like `"gpt-5"`, `"claude-sonnet-4-20250514"`, `"gemini-2.5-pro"`, `"grok-3"`
60
+ * (provider inferred from name patterns)
61
+ */
62
+ export function resolveModel(model: LanguageModel | string): LanguageModel {
63
+ // Already a model instance
64
+ if (typeof model !== 'string') {
65
+ return model;
66
+ }
67
+
68
+ const raw = model.trim();
69
+ if (!raw) {
70
+ throw new Error('resolveModel: empty model string');
71
+ }
72
+
73
+ // Explicit provider prefix: "provider:model-id"
74
+ const colonIdx = raw.indexOf(':');
75
+ if (colonIdx > 0) {
76
+ const prefix = raw.slice(0, colonIdx).toLowerCase();
77
+ const modelId = raw.slice(colonIdx + 1);
78
+ const factory = PROVIDER_FACTORIES[prefix];
79
+ if (!factory) {
80
+ throw new Error(
81
+ `resolveModel: unknown provider prefix "${prefix}" in "${raw}". ` +
82
+ `Known providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}`
83
+ );
84
+ }
85
+ return factory(modelId);
86
+ }
87
+
88
+ // Infer provider from model name patterns
89
+ for (const { test, provider } of MODEL_PROVIDER_PATTERNS) {
90
+ if (test.test(raw)) {
91
+ return PROVIDER_FACTORIES[provider](raw);
92
+ }
93
+ }
94
+
95
+ // Default to OpenAI for unrecognized model names
96
+ // (OpenAI has the most model aliases and is the most common provider)
97
+ return PROVIDER_FACTORIES.openai(raw);
98
+ }
99
+
100
+ /**
101
+ * Extract the provider name from a model identifier string.
102
+ * Returns 'openai', 'anthropic', 'google', 'xai', or 'unknown'.
103
+ */
104
+ export function inferProvider(model: LanguageModel | string): string {
105
+ if (typeof model !== 'string') {
106
+ // Try to extract from the model's provider property if available
107
+ const modelId = (model as any).modelId ?? '';
108
+ return inferProvider(modelId);
109
+ }
110
+
111
+ const raw = model.trim();
112
+
113
+ // Explicit prefix
114
+ const colonIdx = raw.indexOf(':');
115
+ if (colonIdx > 0) {
116
+ const prefix = raw.slice(0, colonIdx).toLowerCase();
117
+ if (PROVIDER_FACTORIES[prefix]) {
118
+ return prefix;
119
+ }
120
+ }
121
+
122
+ // Pattern match
123
+ for (const { test, provider } of MODEL_PROVIDER_PATTERNS) {
124
+ if (test.test(raw)) {
125
+ return provider;
126
+ }
127
+ }
128
+
129
+ return 'unknown';
130
+ }
@@ -0,0 +1,132 @@
1
+ import { Conversation } from '../../src/Conversation';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Integration tests for Conversation.generateObject.
6
+ *
7
+ * These hit the real OpenAI API (requires OPENAI_API_KEY env var).
8
+ * They verify structured output generation, schema strictification,
9
+ * JSON repair, and usage data extraction.
10
+ */
11
+
12
+ const hasApiKey = !!process.env.OPENAI_API_KEY;
13
+ const describeIfKey = hasApiKey ? describe : describe.skip;
14
+
15
+ const TEST_MODEL = 'gpt-4.1-nano';
16
+ const TIMEOUT = 60_000;
17
+
18
+ type CountryInfo = {
19
+ name: string;
20
+ population: number;
21
+ continent: string;
22
+ };
23
+
24
+ type ColorList = {
25
+ colors: string[];
26
+ };
27
+
28
+ type PersonProfile = {
29
+ person: {
30
+ firstName: string;
31
+ lastName: string;
32
+ age: number;
33
+ };
34
+ occupation: string;
35
+ };
36
+
37
+ describeIfKey('Conversation.generateObject', () => {
38
+ test(
39
+ 'returns a typed object matching a Zod schema',
40
+ async () => {
41
+ const schema = z.object({
42
+ name: z.string(),
43
+ population: z.number(),
44
+ continent: z.string(),
45
+ });
46
+
47
+ const conversation = new Conversation({ name: 'test-object-zod' });
48
+ const result = await conversation.generateObject<CountryInfo>({
49
+ messages: [
50
+ 'Give me info about France. Return the country name, population (approximate number), and continent.',
51
+ ],
52
+ model: TEST_MODEL,
53
+ schema,
54
+ });
55
+
56
+ expect(result.object).toBeDefined();
57
+ expect(typeof result.object.name).toBe('string');
58
+ expect(typeof result.object.population).toBe('number');
59
+ expect(typeof result.object.continent).toBe('string');
60
+ expect(result.object.name.toLowerCase()).toContain('france');
61
+ expect(result.object.population).toBeGreaterThan(1_000_000);
62
+
63
+ // Usage should be populated
64
+ expect(result.usage.totalTokenUsage.inputTokens).toBeGreaterThan(0);
65
+ expect(result.usage.totalTokenUsage.outputTokens).toBeGreaterThan(0);
66
+ },
67
+ TIMEOUT
68
+ );
69
+
70
+ test(
71
+ 'returns a typed object matching a JSON Schema',
72
+ async () => {
73
+ const jsonSchema = {
74
+ type: 'object',
75
+ properties: {
76
+ colors: {
77
+ type: 'array',
78
+ items: { type: 'string' },
79
+ },
80
+ },
81
+ required: ['colors'],
82
+ };
83
+
84
+ const conversation = new Conversation({ name: 'test-object-json-schema' });
85
+ const result = await conversation.generateObject<ColorList>({
86
+ messages: ['List the 3 primary colors (red, blue, yellow).'],
87
+ model: TEST_MODEL,
88
+ schema: jsonSchema,
89
+ });
90
+
91
+ expect(result.object).toBeDefined();
92
+ expect(Array.isArray(result.object.colors)).toBe(true);
93
+ expect(result.object.colors.length).toBe(3);
94
+
95
+ const lower = result.object.colors.map((c) => c.toLowerCase());
96
+ expect(lower).toContain('red');
97
+ expect(lower).toContain('blue');
98
+ expect(lower).toContain('yellow');
99
+ },
100
+ TIMEOUT
101
+ );
102
+
103
+ test(
104
+ 'handles nested object schemas (strictification)',
105
+ async () => {
106
+ const schema = z.object({
107
+ person: z.object({
108
+ firstName: z.string(),
109
+ lastName: z.string(),
110
+ age: z.number(),
111
+ }),
112
+ occupation: z.string(),
113
+ });
114
+
115
+ const conversation = new Conversation({ name: 'test-object-nested' });
116
+ const result = await conversation.generateObject<PersonProfile>({
117
+ messages: [
118
+ 'Create a fictional person profile. Use firstName "Ada", lastName "Lovelace", age 36, occupation "Mathematician".',
119
+ ],
120
+ model: TEST_MODEL,
121
+ schema,
122
+ });
123
+
124
+ expect(result.object.person).toBeDefined();
125
+ expect(result.object.person.firstName).toBe('Ada');
126
+ expect(result.object.person.lastName).toBe('Lovelace');
127
+ expect(result.object.person.age).toBe(36);
128
+ expect(result.object.occupation).toBe('Mathematician');
129
+ },
130
+ TIMEOUT
131
+ );
132
+ });
@@ -0,0 +1,132 @@
1
+ import { Conversation } from '../../src/Conversation';
2
+ import { ConversationModule } from '../../src/ConversationModule';
3
+ import { Function } from '../../src/Function';
4
+ import { MessageModerator } from '../../src/history/MessageModerator';
5
+
6
+ /**
7
+ * Integration tests for Conversation.generateResponse (non-streaming convenience).
8
+ *
9
+ * These hit the real OpenAI API (requires OPENAI_API_KEY env var).
10
+ */
11
+
12
+ const hasApiKey = !!process.env.OPENAI_API_KEY;
13
+ const describeIfKey = hasApiKey ? describe : describe.skip;
14
+
15
+ const TEST_MODEL = 'gpt-4.1-nano';
16
+ const TIMEOUT = 60_000;
17
+
18
+ function createTestModule(systemMessage: string, functions: Function[]): ConversationModule {
19
+ return {
20
+ getName: () => 'TestModule',
21
+ getSystemMessages: () => [systemMessage],
22
+ getFunctions: () => functions,
23
+ getMessageModerators: () => [] as MessageModerator[],
24
+ };
25
+ }
26
+
27
+ describeIfKey('Conversation.generateResponse', () => {
28
+ test(
29
+ 'returns a complete text response with usage',
30
+ async () => {
31
+ const conversation = new Conversation({ name: 'test-response' });
32
+ const result = await conversation.generateResponse({
33
+ messages: ['Say "hello" and nothing else.'],
34
+ model: TEST_MODEL,
35
+ });
36
+
37
+ expect(result.text.toLowerCase()).toContain('hello');
38
+ expect(result.usage.totalTokenUsage.inputTokens).toBeGreaterThan(0);
39
+ expect(result.usage.totalTokenUsage.outputTokens).toBeGreaterThan(0);
40
+ expect(result.toolInvocations).toEqual([]);
41
+ },
42
+ TIMEOUT
43
+ );
44
+
45
+ test(
46
+ 'accumulates usage across multiple tool call steps',
47
+ async () => {
48
+ const lookupCalls: string[] = [];
49
+
50
+ const lookupTool: Function = {
51
+ definition: {
52
+ name: 'lookupCapital',
53
+ description: 'Looks up the capital city of a country.',
54
+ parameters: {
55
+ type: 'object',
56
+ properties: {
57
+ country: { type: 'string', description: 'The country name' },
58
+ },
59
+ required: ['country'],
60
+ },
61
+ },
62
+ async call(args: { country: string }) {
63
+ lookupCalls.push(args.country);
64
+ const capitals: Record<string, string> = {
65
+ france: 'Paris',
66
+ japan: 'Tokyo',
67
+ brazil: 'Brasília',
68
+ };
69
+ return { capital: capitals[args.country.toLowerCase()] ?? 'Unknown' };
70
+ },
71
+ };
72
+
73
+ const conversation = new Conversation({
74
+ name: 'test-multi-tool',
75
+ modules: [
76
+ createTestModule(
77
+ 'You are a geography assistant. Use lookupCapital for each country the user asks about. Make a separate call for each country.',
78
+ [lookupTool]
79
+ ),
80
+ ],
81
+ });
82
+
83
+ const result = await conversation.generateResponse({
84
+ messages: ['What are the capitals of France, Japan, and Brazil?'],
85
+ model: TEST_MODEL,
86
+ });
87
+
88
+ // Should have called the tool at least 2 times (ideally 3, but LLMs can batch)
89
+ expect(lookupCalls.length).toBeGreaterThanOrEqual(2);
90
+
91
+ // Response should mention the capitals
92
+ expect(result.text).toContain('Paris');
93
+ expect(result.text).toContain('Tokyo');
94
+
95
+ // Usage should reflect multiple steps
96
+ expect(result.usage.totalRequestsToAssistant).toBeGreaterThanOrEqual(2);
97
+ expect(result.usage.totalToolCalls).toBeGreaterThanOrEqual(2);
98
+ expect(result.usage.callsPerTool['lookupCapital']).toBeGreaterThanOrEqual(2);
99
+
100
+ // Total tokens should be substantial (multiple round trips)
101
+ expect(result.usage.totalTokenUsage.inputTokens).toBeGreaterThan(50);
102
+ expect(result.usage.totalTokenUsage.outputTokens).toBeGreaterThan(10);
103
+
104
+ // Cost should be calculated (gpt-4.1-nano is in our pricing table)
105
+ expect(result.usage.totalCostUsd.totalUsd).toBeGreaterThanOrEqual(0);
106
+ },
107
+ TIMEOUT
108
+ );
109
+
110
+ test(
111
+ 'handles conversation modules with system messages',
112
+ async () => {
113
+ const conversation = new Conversation({
114
+ name: 'test-system-msg',
115
+ modules: [createTestModule('You are a pirate. Always respond in pirate speak.', [])],
116
+ });
117
+
118
+ const result = await conversation.generateResponse({
119
+ messages: ['Say hello.'],
120
+ model: TEST_MODEL,
121
+ });
122
+
123
+ // Should have pirate-ish language
124
+ const lower = result.text.toLowerCase();
125
+ const hasPirateWord = ['ahoy', 'matey', 'arr', 'ye', 'aye', 'avast', 'yarr', 'shiver'].some((w) =>
126
+ lower.includes(w)
127
+ );
128
+ expect(hasPirateWord).toBe(true);
129
+ },
130
+ TIMEOUT
131
+ );
132
+ });
@@ -0,0 +1,173 @@
1
+ import { Conversation } from '../../src/Conversation';
2
+ import { ConversationModule } from '../../src/ConversationModule';
3
+ import { Function } from '../../src/Function';
4
+ import { MessageModerator } from '../../src/history/MessageModerator';
5
+
6
+ /**
7
+ * Integration tests for Conversation.generateStream.
8
+ *
9
+ * These hit the real OpenAI API (requires OPENAI_API_KEY env var).
10
+ * They verify the full path through the AI SDK: message building,
11
+ * tool schema wiring, streaming, usage extraction, and tool invocation reporting.
12
+ */
13
+
14
+ const hasApiKey = !!process.env.OPENAI_API_KEY;
15
+ const describeIfKey = hasApiKey ? describe : describe.skip;
16
+
17
+ const TEST_MODEL = 'gpt-4.1-nano';
18
+ const TIMEOUT = 60_000;
19
+
20
+ /** A simple tool that adds two numbers. */
21
+ function createAddTool(): { fn: Function; calls: Array<{ a: number; b: number }> } {
22
+ const calls: Array<{ a: number; b: number }> = [];
23
+ const fn: Function = {
24
+ definition: {
25
+ name: 'addNumbers',
26
+ description: 'Adds two numbers together and returns the sum.',
27
+ parameters: {
28
+ type: 'object',
29
+ properties: {
30
+ a: { type: 'number', description: 'First number' },
31
+ b: { type: 'number', description: 'Second number' },
32
+ },
33
+ required: ['a', 'b'],
34
+ },
35
+ },
36
+ async call(args: { a: number; b: number }) {
37
+ calls.push(args);
38
+ return { sum: args.a + args.b };
39
+ },
40
+ };
41
+ return { fn, calls };
42
+ }
43
+
44
+ /** A tool with no parameters (the case that caused the type: "None" bug). */
45
+ function createNoParamTool(): { fn: Function; callCount: number[] } {
46
+ const callCount = [0];
47
+ const fn: Function = {
48
+ definition: {
49
+ name: 'getServerTime',
50
+ description: 'Returns the current server time. Takes no parameters.',
51
+ },
52
+ async call() {
53
+ callCount[0]++;
54
+ return { time: new Date().toISOString() };
55
+ },
56
+ };
57
+ return { fn, callCount };
58
+ }
59
+
60
+ /** A simple module that provides a system message and a tool. */
61
+ function createTestModule(systemMessage: string, functions: Function[]): ConversationModule {
62
+ return {
63
+ getName: () => 'TestModule',
64
+ getSystemMessages: () => [systemMessage],
65
+ getFunctions: () => functions,
66
+ getMessageModerators: () => [] as MessageModerator[],
67
+ };
68
+ }
69
+
70
+ describeIfKey('Conversation.generateStream', () => {
71
+ test(
72
+ 'streams a text response and resolves usage data',
73
+ async () => {
74
+ const conversation = new Conversation({ name: 'test-stream' });
75
+
76
+ const result = await conversation.generateStream({
77
+ messages: ['What is 2+2? Reply with just the number.'],
78
+ model: TEST_MODEL,
79
+ });
80
+
81
+ // Collect streamed text
82
+ const chunks: string[] = [];
83
+ for await (const chunk of result.textStream) {
84
+ chunks.push(chunk);
85
+ }
86
+
87
+ // Should have streamed something
88
+ expect(chunks.length).toBeGreaterThan(0);
89
+
90
+ // Full text should resolve and contain "4"
91
+ const text = await result.text;
92
+ expect(text).toContain('4');
93
+
94
+ // Usage data should be populated with non-zero tokens
95
+ const usage = await result.usage;
96
+ expect(usage.totalTokenUsage.inputTokens).toBeGreaterThan(0);
97
+ expect(usage.totalTokenUsage.outputTokens).toBeGreaterThan(0);
98
+ expect(usage.totalTokenUsage.totalTokens).toBeGreaterThan(0);
99
+ expect(usage.model).toBeTruthy();
100
+ },
101
+ TIMEOUT
102
+ );
103
+
104
+ test(
105
+ 'invokes a tool with parameters and reports tool invocations',
106
+ async () => {
107
+ const { fn: addTool, calls } = createAddTool();
108
+
109
+ const conversation = new Conversation({
110
+ name: 'test-tool-call',
111
+ modules: [createTestModule('You are a calculator. Use the addNumbers tool to compute sums.', [addTool])],
112
+ });
113
+
114
+ const result = await conversation.generateStream({
115
+ messages: ['What is 7 + 13?'],
116
+ model: TEST_MODEL,
117
+ });
118
+
119
+ // Consume the stream
120
+ const text = await result.text;
121
+ const usage = await result.usage;
122
+ const toolInvocations = await result.toolInvocations;
123
+
124
+ // The tool should have been called
125
+ expect(calls.length).toBeGreaterThan(0);
126
+ expect(calls[0].a + calls[0].b).toBe(20);
127
+
128
+ // The response should contain "20"
129
+ expect(text).toContain('20');
130
+
131
+ // Tool invocations should be reported
132
+ expect(toolInvocations.length).toBeGreaterThan(0);
133
+ expect(toolInvocations[0].name).toBe('addNumbers');
134
+
135
+ // Usage should reflect multiple steps (at least one tool call step + final response)
136
+ expect(usage.totalRequestsToAssistant).toBeGreaterThanOrEqual(2);
137
+ expect(usage.totalToolCalls).toBeGreaterThan(0);
138
+ expect(usage.callsPerTool['addNumbers']).toBeGreaterThan(0);
139
+ },
140
+ TIMEOUT
141
+ );
142
+
143
+ test(
144
+ 'handles a tool with no parameters (the type: "None" regression)',
145
+ async () => {
146
+ const { fn: noParamTool, callCount } = createNoParamTool();
147
+
148
+ const conversation = new Conversation({
149
+ name: 'test-no-param-tool',
150
+ modules: [
151
+ createTestModule('You have access to a getServerTime tool. When the user asks for the time, call it.', [
152
+ noParamTool,
153
+ ]),
154
+ ],
155
+ });
156
+
157
+ const result = await conversation.generateStream({
158
+ messages: ['What time is it on the server?'],
159
+ model: TEST_MODEL,
160
+ });
161
+
162
+ // This should not throw — the old bug caused an API error here
163
+ const text = await result.text;
164
+ const toolInvocations = await result.toolInvocations;
165
+
166
+ // The tool should have been called
167
+ expect(callCount[0]).toBeGreaterThan(0);
168
+ expect(toolInvocations.length).toBeGreaterThan(0);
169
+ expect(toolInvocations[0].name).toBe('getServerTime');
170
+ },
171
+ TIMEOUT
172
+ );
173
+ });