kimi-vercel-ai-sdk-provider 0.3.0 → 0.4.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 +157 -2
- package/dist/index.d.mts +142 -1
- package/dist/index.d.ts +142 -1
- package/dist/index.js +222 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +222 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/file-cache.test.ts +310 -0
- package/src/__tests__/model-config.test.ts +120 -0
- package/src/__tests__/reasoning-utils.test.ts +164 -0
- package/src/__tests__/tools.test.ts +75 -7
- package/src/chat/kimi-chat-language-model.ts +21 -2
- package/src/core/index.ts +10 -3
- package/src/core/types.ts +57 -2
- package/src/core/utils.ts +138 -0
- package/src/files/attachment-processor.ts +51 -4
- package/src/files/file-cache.ts +260 -0
- package/src/files/index.ts +16 -1
- package/src/tools/prepare-tools.ts +88 -2
package/package.json
CHANGED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for file content caching.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
FileCache,
|
|
8
|
+
type FileCacheEntry,
|
|
9
|
+
clearDefaultFileCache,
|
|
10
|
+
generateCacheKey,
|
|
11
|
+
generateContentHash,
|
|
12
|
+
getDefaultFileCache,
|
|
13
|
+
setDefaultFileCache
|
|
14
|
+
} from '../files';
|
|
15
|
+
|
|
16
|
+
describe('FileCache', () => {
|
|
17
|
+
describe('basic operations', () => {
|
|
18
|
+
it('should store and retrieve entries', () => {
|
|
19
|
+
const cache = new FileCache();
|
|
20
|
+
const entry: FileCacheEntry = {
|
|
21
|
+
fileId: 'file_123',
|
|
22
|
+
content: 'extracted text',
|
|
23
|
+
createdAt: Date.now(),
|
|
24
|
+
purpose: 'file-extract'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
cache.set('hash123', entry);
|
|
28
|
+
const retrieved = cache.get('hash123');
|
|
29
|
+
|
|
30
|
+
expect(retrieved).toEqual(entry);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return undefined for missing entries', () => {
|
|
34
|
+
const cache = new FileCache();
|
|
35
|
+
|
|
36
|
+
expect(cache.get('nonexistent')).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should delete entries', () => {
|
|
40
|
+
const cache = new FileCache();
|
|
41
|
+
const entry: FileCacheEntry = {
|
|
42
|
+
fileId: 'file_123',
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
purpose: 'file-extract'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
cache.set('hash123', entry);
|
|
48
|
+
expect(cache.has('hash123')).toBe(true);
|
|
49
|
+
|
|
50
|
+
cache.delete('hash123');
|
|
51
|
+
expect(cache.has('hash123')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should clear all entries', () => {
|
|
55
|
+
const cache = new FileCache();
|
|
56
|
+
|
|
57
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
58
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'image' });
|
|
59
|
+
cache.set('hash3', { fileId: 'f3', createdAt: Date.now(), purpose: 'video' });
|
|
60
|
+
|
|
61
|
+
expect(cache.size).toBe(3);
|
|
62
|
+
|
|
63
|
+
cache.clear();
|
|
64
|
+
expect(cache.size).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should report correct size', () => {
|
|
68
|
+
const cache = new FileCache();
|
|
69
|
+
|
|
70
|
+
expect(cache.size).toBe(0);
|
|
71
|
+
|
|
72
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
73
|
+
expect(cache.size).toBe(1);
|
|
74
|
+
|
|
75
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'file-extract' });
|
|
76
|
+
expect(cache.size).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('LRU eviction', () => {
|
|
81
|
+
it('should evict oldest entries when at capacity', () => {
|
|
82
|
+
const cache = new FileCache({ maxSize: 3 });
|
|
83
|
+
|
|
84
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
85
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'file-extract' });
|
|
86
|
+
cache.set('hash3', { fileId: 'f3', createdAt: Date.now(), purpose: 'file-extract' });
|
|
87
|
+
|
|
88
|
+
expect(cache.size).toBe(3);
|
|
89
|
+
|
|
90
|
+
// Add a 4th entry, should evict hash1
|
|
91
|
+
cache.set('hash4', { fileId: 'f4', createdAt: Date.now(), purpose: 'file-extract' });
|
|
92
|
+
|
|
93
|
+
expect(cache.size).toBe(3);
|
|
94
|
+
expect(cache.has('hash1')).toBe(false);
|
|
95
|
+
expect(cache.has('hash2')).toBe(true);
|
|
96
|
+
expect(cache.has('hash3')).toBe(true);
|
|
97
|
+
expect(cache.has('hash4')).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should update LRU order on get', () => {
|
|
101
|
+
const cache = new FileCache({ maxSize: 3 });
|
|
102
|
+
|
|
103
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
104
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'file-extract' });
|
|
105
|
+
cache.set('hash3', { fileId: 'f3', createdAt: Date.now(), purpose: 'file-extract' });
|
|
106
|
+
|
|
107
|
+
// Access hash1 to make it recently used
|
|
108
|
+
cache.get('hash1');
|
|
109
|
+
|
|
110
|
+
// Add a 4th entry, should evict hash2 (not hash1)
|
|
111
|
+
cache.set('hash4', { fileId: 'f4', createdAt: Date.now(), purpose: 'file-extract' });
|
|
112
|
+
|
|
113
|
+
expect(cache.has('hash1')).toBe(true);
|
|
114
|
+
expect(cache.has('hash2')).toBe(false);
|
|
115
|
+
expect(cache.has('hash3')).toBe(true);
|
|
116
|
+
expect(cache.has('hash4')).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should handle updating existing entries', () => {
|
|
120
|
+
const cache = new FileCache({ maxSize: 3 });
|
|
121
|
+
|
|
122
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
123
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'file-extract' });
|
|
124
|
+
cache.set('hash3', { fileId: 'f3', createdAt: Date.now(), purpose: 'file-extract' });
|
|
125
|
+
|
|
126
|
+
// Update hash1
|
|
127
|
+
cache.set('hash1', { fileId: 'f1-updated', createdAt: Date.now(), purpose: 'file-extract' });
|
|
128
|
+
|
|
129
|
+
expect(cache.size).toBe(3);
|
|
130
|
+
expect(cache.get('hash1')?.fileId).toBe('f1-updated');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('TTL expiration', () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
vi.useFakeTimers();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
vi.useRealTimers();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should expire entries after TTL', () => {
|
|
144
|
+
const cache = new FileCache({ ttlMs: 1000 }); // 1 second TTL
|
|
145
|
+
|
|
146
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
147
|
+
|
|
148
|
+
expect(cache.get('hash1')).toBeDefined();
|
|
149
|
+
|
|
150
|
+
// Advance time past TTL
|
|
151
|
+
vi.advanceTimersByTime(1500);
|
|
152
|
+
|
|
153
|
+
expect(cache.get('hash1')).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should not expire entries before TTL', () => {
|
|
157
|
+
const cache = new FileCache({ ttlMs: 1000 });
|
|
158
|
+
|
|
159
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
160
|
+
|
|
161
|
+
// Advance time but not past TTL
|
|
162
|
+
vi.advanceTimersByTime(500);
|
|
163
|
+
|
|
164
|
+
expect(cache.get('hash1')).toBeDefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should prune expired entries', () => {
|
|
168
|
+
const cache = new FileCache({ ttlMs: 1000 });
|
|
169
|
+
|
|
170
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
171
|
+
cache.set('hash2', { fileId: 'f2', createdAt: Date.now(), purpose: 'file-extract' });
|
|
172
|
+
|
|
173
|
+
vi.advanceTimersByTime(1500);
|
|
174
|
+
|
|
175
|
+
cache.set('hash3', { fileId: 'f3', createdAt: Date.now(), purpose: 'file-extract' });
|
|
176
|
+
|
|
177
|
+
const pruned = cache.prune();
|
|
178
|
+
|
|
179
|
+
expect(pruned).toBe(2);
|
|
180
|
+
expect(cache.size).toBe(1);
|
|
181
|
+
expect(cache.has('hash3')).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('default options', () => {
|
|
186
|
+
it('should use default maxSize of 100', () => {
|
|
187
|
+
const cache = new FileCache();
|
|
188
|
+
|
|
189
|
+
// Add 100 entries
|
|
190
|
+
for (let i = 0; i < 100; i++) {
|
|
191
|
+
cache.set(`hash${i}`, { fileId: `f${i}`, createdAt: Date.now(), purpose: 'file-extract' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
expect(cache.size).toBe(100);
|
|
195
|
+
|
|
196
|
+
// Add one more, should evict
|
|
197
|
+
cache.set('hash100', { fileId: 'f100', createdAt: Date.now(), purpose: 'file-extract' });
|
|
198
|
+
expect(cache.size).toBe(100);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should use default TTL of 1 hour', () => {
|
|
202
|
+
vi.useFakeTimers();
|
|
203
|
+
|
|
204
|
+
const cache = new FileCache();
|
|
205
|
+
cache.set('hash1', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
206
|
+
|
|
207
|
+
// 59 minutes - should still be valid
|
|
208
|
+
vi.advanceTimersByTime(59 * 60 * 1000);
|
|
209
|
+
expect(cache.get('hash1')).toBeDefined();
|
|
210
|
+
|
|
211
|
+
// 61 minutes - should be expired
|
|
212
|
+
vi.advanceTimersByTime(2 * 60 * 1000);
|
|
213
|
+
expect(cache.get('hash1')).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
vi.useRealTimers();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('generateContentHash', () => {
|
|
221
|
+
it('should generate consistent hashes for same content', () => {
|
|
222
|
+
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
|
223
|
+
|
|
224
|
+
const hash1 = generateContentHash(data);
|
|
225
|
+
const hash2 = generateContentHash(data);
|
|
226
|
+
|
|
227
|
+
expect(hash1).toBe(hash2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should generate different hashes for different content', () => {
|
|
231
|
+
const data1 = new Uint8Array([1, 2, 3]);
|
|
232
|
+
const data2 = new Uint8Array([4, 5, 6]);
|
|
233
|
+
|
|
234
|
+
const hash1 = generateContentHash(data1);
|
|
235
|
+
const hash2 = generateContentHash(data2);
|
|
236
|
+
|
|
237
|
+
expect(hash1).not.toBe(hash2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should work with string input', () => {
|
|
241
|
+
const hash1 = generateContentHash('hello world');
|
|
242
|
+
const hash2 = generateContentHash('hello world');
|
|
243
|
+
const hash3 = generateContentHash('goodbye world');
|
|
244
|
+
|
|
245
|
+
expect(hash1).toBe(hash2);
|
|
246
|
+
expect(hash1).not.toBe(hash3);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should return 8-character hex string', () => {
|
|
250
|
+
const hash = generateContentHash('test');
|
|
251
|
+
|
|
252
|
+
expect(hash).toMatch(/^[0-9a-f]{8}$/);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('generateCacheKey', () => {
|
|
257
|
+
it('should include content hash, size, and filename', () => {
|
|
258
|
+
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
|
259
|
+
const key = generateCacheKey(data, 'document.pdf');
|
|
260
|
+
|
|
261
|
+
expect(key).toContain('_5_'); // size
|
|
262
|
+
expect(key).toContain('document.pdf');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should normalize filename', () => {
|
|
266
|
+
const data = new Uint8Array([1, 2, 3]);
|
|
267
|
+
const key = generateCacheKey(data, 'My Document (1).PDF');
|
|
268
|
+
|
|
269
|
+
expect(key).toContain('my_document__1_.pdf');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should generate different keys for same content with different names', () => {
|
|
273
|
+
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
|
274
|
+
|
|
275
|
+
const key1 = generateCacheKey(data, 'file1.pdf');
|
|
276
|
+
const key2 = generateCacheKey(data, 'file2.pdf');
|
|
277
|
+
|
|
278
|
+
expect(key1).not.toBe(key2);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('Default Cache', () => {
|
|
283
|
+
afterEach(() => {
|
|
284
|
+
clearDefaultFileCache();
|
|
285
|
+
setDefaultFileCache(null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should create default cache on first access', () => {
|
|
289
|
+
const cache1 = getDefaultFileCache();
|
|
290
|
+
const cache2 = getDefaultFileCache();
|
|
291
|
+
|
|
292
|
+
expect(cache1).toBe(cache2);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should allow setting custom default cache', () => {
|
|
296
|
+
const customCache = new FileCache({ maxSize: 10 });
|
|
297
|
+
setDefaultFileCache(customCache);
|
|
298
|
+
|
|
299
|
+
expect(getDefaultFileCache()).toBe(customCache);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should clear default cache', () => {
|
|
303
|
+
const cache = getDefaultFileCache();
|
|
304
|
+
cache.set('test', { fileId: 'f1', createdAt: Date.now(), purpose: 'file-extract' });
|
|
305
|
+
|
|
306
|
+
clearDefaultFileCache();
|
|
307
|
+
|
|
308
|
+
expect(cache.size).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for model configuration features:
|
|
3
|
+
* - Temperature locking for thinking models
|
|
4
|
+
* - Default max_tokens
|
|
5
|
+
* - Model capability inference
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
STANDARD_MODEL_DEFAULT_MAX_TOKENS,
|
|
11
|
+
THINKING_MODEL_DEFAULT_MAX_TOKENS,
|
|
12
|
+
THINKING_MODEL_TEMPERATURE,
|
|
13
|
+
inferModelCapabilities
|
|
14
|
+
} from '../core';
|
|
15
|
+
|
|
16
|
+
describe('Model Configuration', () => {
|
|
17
|
+
describe('inferModelCapabilities', () => {
|
|
18
|
+
it('should detect thinking models by suffix', () => {
|
|
19
|
+
const caps = inferModelCapabilities('kimi-k2.5-thinking');
|
|
20
|
+
|
|
21
|
+
expect(caps.thinking).toBe(true);
|
|
22
|
+
expect(caps.alwaysThinking).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should detect non-thinking models', () => {
|
|
26
|
+
const caps = inferModelCapabilities('kimi-k2.5');
|
|
27
|
+
|
|
28
|
+
expect(caps.thinking).toBe(false);
|
|
29
|
+
expect(caps.alwaysThinking).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should detect K2.5 models for video support', () => {
|
|
33
|
+
const k25Caps = inferModelCapabilities('kimi-k2.5');
|
|
34
|
+
const k2Caps = inferModelCapabilities('kimi-k2-turbo');
|
|
35
|
+
|
|
36
|
+
expect(k25Caps.videoInput).toBe(true);
|
|
37
|
+
expect(k2Caps.videoInput).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should support alternative K2.5 naming', () => {
|
|
41
|
+
const caps = inferModelCapabilities('kimi-k2-5-thinking');
|
|
42
|
+
|
|
43
|
+
expect(caps.videoInput).toBe(true);
|
|
44
|
+
expect(caps.thinking).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('Temperature Locking', () => {
|
|
49
|
+
it('should set locked temperature for thinking models', () => {
|
|
50
|
+
const caps = inferModelCapabilities('kimi-k2.5-thinking');
|
|
51
|
+
|
|
52
|
+
expect(caps.temperatureLocked).toBe(true);
|
|
53
|
+
expect(caps.defaultTemperature).toBe(THINKING_MODEL_TEMPERATURE);
|
|
54
|
+
expect(caps.defaultTemperature).toBe(1.0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should not lock temperature for standard models', () => {
|
|
58
|
+
const caps = inferModelCapabilities('kimi-k2.5');
|
|
59
|
+
|
|
60
|
+
expect(caps.temperatureLocked).toBe(false);
|
|
61
|
+
expect(caps.defaultTemperature).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should use correct constant value', () => {
|
|
65
|
+
expect(THINKING_MODEL_TEMPERATURE).toBe(1.0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('Default Max Tokens', () => {
|
|
70
|
+
it('should set higher default for thinking models', () => {
|
|
71
|
+
const caps = inferModelCapabilities('kimi-k2.5-thinking');
|
|
72
|
+
|
|
73
|
+
expect(caps.defaultMaxOutputTokens).toBe(THINKING_MODEL_DEFAULT_MAX_TOKENS);
|
|
74
|
+
expect(caps.defaultMaxOutputTokens).toBe(32768);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should set standard default for regular models', () => {
|
|
78
|
+
const caps = inferModelCapabilities('kimi-k2.5');
|
|
79
|
+
|
|
80
|
+
expect(caps.defaultMaxOutputTokens).toBe(STANDARD_MODEL_DEFAULT_MAX_TOKENS);
|
|
81
|
+
expect(caps.defaultMaxOutputTokens).toBe(4096);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should use correct constant values', () => {
|
|
85
|
+
expect(THINKING_MODEL_DEFAULT_MAX_TOKENS).toBe(32768);
|
|
86
|
+
expect(STANDARD_MODEL_DEFAULT_MAX_TOKENS).toBe(4096);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('All models have common capabilities', () => {
|
|
91
|
+
const testModels = ['kimi-k2.5', 'kimi-k2.5-thinking', 'kimi-k2-turbo', 'kimi-k2-thinking'];
|
|
92
|
+
|
|
93
|
+
for (const modelId of testModels) {
|
|
94
|
+
it(`${modelId} should have imageInput support`, () => {
|
|
95
|
+
const caps = inferModelCapabilities(modelId);
|
|
96
|
+
expect(caps.imageInput).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it(`${modelId} should have 256k context`, () => {
|
|
100
|
+
const caps = inferModelCapabilities(modelId);
|
|
101
|
+
expect(caps.maxContextSize).toBe(256_000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it(`${modelId} should support tool calling`, () => {
|
|
105
|
+
const caps = inferModelCapabilities(modelId);
|
|
106
|
+
expect(caps.toolCalling).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it(`${modelId} should support JSON mode`, () => {
|
|
110
|
+
const caps = inferModelCapabilities(modelId);
|
|
111
|
+
expect(caps.jsonMode).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it(`${modelId} should support structured outputs`, () => {
|
|
115
|
+
const caps = inferModelCapabilities(modelId);
|
|
116
|
+
expect(caps.structuredOutputs).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for multi-turn reasoning utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { analyzeReasoningPreservation, recommendThinkingModel } from '../core';
|
|
7
|
+
|
|
8
|
+
describe('analyzeReasoningPreservation', () => {
|
|
9
|
+
it('should detect messages with reasoning content', () => {
|
|
10
|
+
const messages = [
|
|
11
|
+
{ role: 'user', content: 'What is 2+2?' },
|
|
12
|
+
{
|
|
13
|
+
role: 'assistant',
|
|
14
|
+
content: 'The answer is 4.',
|
|
15
|
+
reasoning_content: 'Let me calculate: 2+2 = 4'
|
|
16
|
+
}
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
20
|
+
|
|
21
|
+
expect(analysis.messagesWithReasoning).toBe(1);
|
|
22
|
+
expect(analysis.isPreserved).toBe(true);
|
|
23
|
+
expect(analysis.missingReasoningIndices).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should handle reasoning field variant', () => {
|
|
27
|
+
const messages = [
|
|
28
|
+
{ role: 'user', content: 'Test' },
|
|
29
|
+
{
|
|
30
|
+
role: 'assistant',
|
|
31
|
+
content: 'Answer',
|
|
32
|
+
reasoning: 'My reasoning process...'
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
37
|
+
|
|
38
|
+
expect(analysis.messagesWithReasoning).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should estimate reasoning tokens', () => {
|
|
42
|
+
const messages = [
|
|
43
|
+
{ role: 'user', content: 'Test' },
|
|
44
|
+
{
|
|
45
|
+
role: 'assistant',
|
|
46
|
+
content: 'Answer',
|
|
47
|
+
reasoning_content: 'A'.repeat(400) // 400 chars ≈ 100 tokens
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
52
|
+
|
|
53
|
+
expect(analysis.estimatedReasoningTokens).toBe(100);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should detect missing reasoning after tool calls', () => {
|
|
57
|
+
const messages = [
|
|
58
|
+
{ role: 'user', content: 'Search for X' },
|
|
59
|
+
{
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: null,
|
|
62
|
+
reasoning_content: 'I need to search...',
|
|
63
|
+
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }]
|
|
64
|
+
},
|
|
65
|
+
{ role: 'tool', tool_call_id: 'call_1', content: 'Search results...' },
|
|
66
|
+
{
|
|
67
|
+
role: 'assistant',
|
|
68
|
+
content: 'Here are the results', // Missing reasoning!
|
|
69
|
+
reasoning_content: null
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
74
|
+
|
|
75
|
+
expect(analysis.isPreserved).toBe(false);
|
|
76
|
+
expect(analysis.missingReasoningIndices).toContain(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should handle proper reasoning preservation in tool loops', () => {
|
|
80
|
+
const messages = [
|
|
81
|
+
{ role: 'user', content: 'Search for X' },
|
|
82
|
+
{
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
content: null,
|
|
85
|
+
reasoning_content: 'I need to search...',
|
|
86
|
+
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }]
|
|
87
|
+
},
|
|
88
|
+
{ role: 'tool', tool_call_id: 'call_1', content: 'Search results...' },
|
|
89
|
+
{
|
|
90
|
+
role: 'assistant',
|
|
91
|
+
content: 'Here are the results',
|
|
92
|
+
reasoning_content: 'Based on the search results, I can now answer...'
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
97
|
+
|
|
98
|
+
expect(analysis.isPreserved).toBe(true);
|
|
99
|
+
expect(analysis.messagesWithReasoning).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle empty conversations', () => {
|
|
103
|
+
const analysis = analyzeReasoningPreservation([]);
|
|
104
|
+
|
|
105
|
+
expect(analysis.messagesWithReasoning).toBe(0);
|
|
106
|
+
expect(analysis.isPreserved).toBe(true);
|
|
107
|
+
expect(analysis.estimatedReasoningTokens).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle conversations without assistant messages', () => {
|
|
111
|
+
const messages = [
|
|
112
|
+
{ role: 'user', content: 'Hello' },
|
|
113
|
+
{ role: 'system', content: 'You are helpful' }
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const analysis = analyzeReasoningPreservation(messages);
|
|
117
|
+
|
|
118
|
+
expect(analysis.messagesWithReasoning).toBe(0);
|
|
119
|
+
expect(analysis.isPreserved).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('recommendThinkingModel', () => {
|
|
124
|
+
it('should recommend for high complexity tasks', () => {
|
|
125
|
+
const recommendation = recommendThinkingModel(1, false, 0.8);
|
|
126
|
+
|
|
127
|
+
expect(recommendation.recommended).toBe(true);
|
|
128
|
+
expect(recommendation.reason).toContain('High complexity');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should recommend for multi-turn tool usage', () => {
|
|
132
|
+
const recommendation = recommendThinkingModel(5, true, 0.3);
|
|
133
|
+
|
|
134
|
+
expect(recommendation.recommended).toBe(true);
|
|
135
|
+
expect(recommendation.reason).toContain('tool usage');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should recommend for moderate complexity', () => {
|
|
139
|
+
const recommendation = recommendThinkingModel(1, false, 0.6);
|
|
140
|
+
|
|
141
|
+
expect(recommendation.recommended).toBe(true);
|
|
142
|
+
expect(recommendation.reason).toContain('Moderate complexity');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should not recommend for simple tasks', () => {
|
|
146
|
+
const recommendation = recommendThinkingModel(1, false, 0.2);
|
|
147
|
+
|
|
148
|
+
expect(recommendation.recommended).toBe(false);
|
|
149
|
+
expect(recommendation.reason).toContain('Standard model sufficient');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should not recommend for short tool conversations', () => {
|
|
153
|
+
const recommendation = recommendThinkingModel(2, true, 0.3);
|
|
154
|
+
|
|
155
|
+
expect(recommendation.recommended).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should prioritize high complexity over other factors', () => {
|
|
159
|
+
const recommendation = recommendThinkingModel(1, false, 0.9);
|
|
160
|
+
|
|
161
|
+
expect(recommendation.recommended).toBe(true);
|
|
162
|
+
expect(recommendation.reason).toContain('High complexity');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -225,7 +225,8 @@ describe('prepareKimiTools', () => {
|
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
it('should
|
|
228
|
+
it('should not pass strict mode to Kimi for better compatibility', () => {
|
|
229
|
+
// Kimi doesn't fully support strict mode, so we don't pass it
|
|
229
230
|
const result = prepareKimiTools({
|
|
230
231
|
tools: [
|
|
231
232
|
{
|
|
@@ -238,13 +239,80 @@ describe('prepareKimiTools', () => {
|
|
|
238
239
|
]
|
|
239
240
|
});
|
|
240
241
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
// Strict should not be present in the output
|
|
243
|
+
const tool = result.tools?.[0];
|
|
244
|
+
expect(tool).toBeDefined();
|
|
245
|
+
if (tool && 'function' in tool) {
|
|
246
|
+
expect(tool.function).not.toHaveProperty('strict');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should sanitize JSON schema by removing unsupported keywords', () => {
|
|
251
|
+
const result = prepareKimiTools({
|
|
252
|
+
tools: [
|
|
253
|
+
{
|
|
254
|
+
type: 'function',
|
|
255
|
+
name: 'test',
|
|
256
|
+
description: 'Test tool',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
259
|
+
$id: 'test-schema',
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
name: { type: 'string' }
|
|
263
|
+
},
|
|
264
|
+
$defs: { unused: { type: 'string' } },
|
|
265
|
+
$comment: 'This is a comment'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
]
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const tool = result.tools?.[0];
|
|
272
|
+
expect(tool).toBeDefined();
|
|
273
|
+
expect(tool?.type).toBe('function');
|
|
274
|
+
if (tool && tool.type === 'function') {
|
|
275
|
+
const params = tool.function.parameters as Record<string, unknown>;
|
|
276
|
+
expect(params).not.toHaveProperty('$schema');
|
|
277
|
+
expect(params).not.toHaveProperty('$id');
|
|
278
|
+
expect(params).not.toHaveProperty('$defs');
|
|
279
|
+
expect(params).not.toHaveProperty('$comment');
|
|
280
|
+
expect(params.type).toBe('object');
|
|
281
|
+
expect(params.properties).toEqual({ name: { type: 'string' } });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should preserve basic schema properties', () => {
|
|
286
|
+
const result = prepareKimiTools({
|
|
287
|
+
tools: [
|
|
288
|
+
{
|
|
289
|
+
type: 'function',
|
|
290
|
+
name: 'test',
|
|
291
|
+
description: 'Test tool',
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
name: { type: 'string', description: 'Name field' },
|
|
296
|
+
count: { type: 'number', minimum: 0 }
|
|
297
|
+
},
|
|
298
|
+
required: ['name']
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
]
|
|
247
302
|
});
|
|
303
|
+
|
|
304
|
+
const tool = result.tools?.[0];
|
|
305
|
+
expect(tool).toBeDefined();
|
|
306
|
+
expect(tool?.type).toBe('function');
|
|
307
|
+
if (tool && tool.type === 'function') {
|
|
308
|
+
const params = tool.function.parameters as Record<string, unknown>;
|
|
309
|
+
expect(params.type).toBe('object');
|
|
310
|
+
expect(params.required).toEqual(['name']);
|
|
311
|
+
expect((params.properties as Record<string, unknown>).name).toEqual({
|
|
312
|
+
type: 'string',
|
|
313
|
+
description: 'Name field'
|
|
314
|
+
});
|
|
315
|
+
}
|
|
248
316
|
});
|
|
249
317
|
});
|
|
250
318
|
|