@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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/src/CodegenConversation.js +1 -1
- package/dist/src/CodegenConversation.js.map +1 -1
- package/dist/src/Conversation.d.ts +173 -99
- package/dist/src/Conversation.d.ts.map +1 -1
- package/dist/src/Conversation.js +903 -502
- package/dist/src/Conversation.js.map +1 -1
- package/dist/src/OpenAi.d.ts +20 -0
- package/dist/src/OpenAi.d.ts.map +1 -1
- package/dist/src/OpenAi.js +16 -0
- package/dist/src/OpenAi.js.map +1 -1
- package/dist/src/OpenAiStreamProcessor.d.ts +9 -3
- package/dist/src/OpenAiStreamProcessor.d.ts.map +1 -1
- package/dist/src/OpenAiStreamProcessor.js +5 -3
- package/dist/src/OpenAiStreamProcessor.js.map +1 -1
- package/dist/src/UsageData.d.ts.map +1 -1
- package/dist/src/UsageData.js +22 -0
- package/dist/src/UsageData.js.map +1 -1
- package/dist/src/code_template/Code.d.ts.map +1 -1
- package/dist/src/code_template/Code.js +8 -2
- package/dist/src/code_template/Code.js.map +1 -1
- package/dist/src/resolveModel.d.ts +17 -0
- package/dist/src/resolveModel.d.ts.map +1 -0
- package/dist/src/resolveModel.js +121 -0
- package/dist/src/resolveModel.js.map +1 -0
- package/dist/test/conversation/conversation.generateObject.test.d.ts +2 -0
- package/dist/test/conversation/conversation.generateObject.test.d.ts.map +1 -0
- package/dist/test/conversation/conversation.generateObject.test.js +153 -0
- package/dist/test/conversation/conversation.generateObject.test.js.map +1 -0
- package/dist/test/conversation/conversation.generateResponse.test.d.ts +2 -0
- package/dist/test/conversation/conversation.generateResponse.test.d.ts.map +1 -0
- package/dist/test/conversation/conversation.generateResponse.test.js +167 -0
- package/dist/test/conversation/conversation.generateResponse.test.js.map +1 -0
- package/dist/test/conversation/conversation.generateStream.test.d.ts +2 -0
- package/dist/test/conversation/conversation.generateStream.test.d.ts.map +1 -0
- package/dist/test/conversation/conversation.generateStream.test.js +255 -0
- package/dist/test/conversation/conversation.generateStream.test.js.map +1 -0
- package/index.ts +5 -0
- package/package.json +7 -2
- package/src/CodegenConversation.ts +1 -1
- package/src/Conversation.ts +938 -496
- package/src/OpenAi.ts +20 -0
- package/src/OpenAiStreamProcessor.ts +9 -3
- package/src/UsageData.ts +25 -0
- package/src/code_template/Code.ts +5 -1
- package/src/resolveModel.ts +130 -0
- package/test/conversation/conversation.generateObject.test.ts +132 -0
- package/test/conversation/conversation.generateResponse.test.ts +132 -0
- 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
+
});
|