universal-llm-client 4.2.0 → 4.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/CHANGELOG.md +142 -103
- package/LICENSE +21 -21
- package/README.md +640 -591
- package/dist/ai-model.d.ts +12 -1
- package/dist/ai-model.d.ts.map +1 -1
- package/dist/ai-model.js +36 -1
- package/dist/ai-model.js.map +1 -1
- package/dist/gemma-channel.d.ts +14 -0
- package/dist/gemma-channel.d.ts.map +1 -0
- package/dist/gemma-channel.js +38 -0
- package/dist/gemma-channel.js.map +1 -0
- package/dist/gemma-diffusion.d.ts +49 -0
- package/dist/gemma-diffusion.d.ts.map +1 -0
- package/dist/gemma-diffusion.js +147 -0
- package/dist/gemma-diffusion.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +14 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +183 -7
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +28 -3
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/google.d.ts +22 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/providers/google.js +225 -13
- package/dist/providers/google.js.map +1 -1
- package/dist/providers/ollama.d.ts +2 -0
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +59 -30
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +14 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +200 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/router.d.ts +2 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -0
- package/dist/router.js.map +1 -1
- package/dist/stream-decoder.d.ts +12 -0
- package/dist/stream-decoder.d.ts.map +1 -1
- package/dist/stream-decoder.js +182 -5
- package/dist/stream-decoder.js.map +1 -1
- package/dist/thinking.d.ts +36 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +52 -0
- package/dist/thinking.js.map +1 -0
- package/package.json +118 -116
- package/src/ai-model.ts +400 -350
- package/src/auditor.ts +213 -213
- package/src/client.ts +402 -402
- package/src/debug/debug-google-streaming.ts +1 -1
- package/src/demos/basic/universal-llm-examples.ts +3 -3
- package/src/demos/diffusion-gemma/.env +29 -0
- package/src/demos/diffusion-gemma/.env.example +27 -0
- package/src/demos/diffusion-gemma/CLAUDE.md +95 -0
- package/src/demos/diffusion-gemma/README.md +59 -0
- package/src/demos/diffusion-gemma/canvas.ts +1606 -0
- package/src/demos/diffusion-gemma/docker-compose.yml +29 -0
- package/src/demos/diffusion-gemma/probe-stream.ts +51 -0
- package/src/demos/diffusion-gemma/probe-tools.ts +55 -0
- package/src/demos/diffusion-gemma/server.ts +1205 -0
- package/src/demos/diffusion-gemma/start-vllm.sh +98 -0
- package/src/gemma-channel.ts +47 -0
- package/src/gemma-diffusion.ts +167 -0
- package/src/http.ts +261 -247
- package/src/index.ts +180 -161
- package/src/interfaces.ts +843 -657
- package/src/mcp.ts +345 -345
- package/src/providers/anthropic.ts +796 -762
- package/src/providers/google.ts +840 -620
- package/src/providers/index.ts +8 -8
- package/src/providers/ollama.ts +503 -469
- package/src/providers/openai.ts +587 -392
- package/src/router.ts +785 -780
- package/src/stream-decoder.ts +535 -361
- package/src/structured-output.ts +759 -759
- package/src/test-scripts/test-google-deep-research.ts +33 -0
- package/src/test-scripts/test-google-streaming-enhanced.ts +147 -147
- package/src/test-scripts/test-google-streaming.ts +1 -1
- package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -189
- package/src/test-scripts/test-google-thinking.ts +46 -0
- package/src/test-scripts/test-system-message-positions.ts +163 -163
- package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -83
- package/src/test-scripts/test-vllm-qwen36.ts +256 -0
- package/src/tests/ai-model.test.ts +1614 -1614
- package/src/tests/auditor.test.ts +224 -224
- package/src/tests/gemma-diffusion.test.ts +115 -0
- package/src/tests/http.test.ts +200 -200
- package/src/tests/interfaces.test.ts +117 -117
- package/src/tests/providers/anthropic.test.ts +118 -0
- package/src/tests/providers/google.test.ts +841 -660
- package/src/tests/providers/ollama.test.ts +1034 -954
- package/src/tests/providers/openai.test.ts +1511 -1122
- package/src/tests/router.test.ts +254 -254
- package/src/tests/stream-decoder.test.ts +263 -179
- package/src/tests/structured-output.test.ts +1450 -1450
- package/src/tests/thinking.test.ts +65 -0
- package/src/tests/tools.test.ts +175 -175
- package/src/thinking.ts +73 -0
- package/src/tools.ts +246 -246
- package/src/zod-adapter.ts +72 -72
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the unified thinking resolver and per-provider budget maps.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, test, expect } from 'bun:test';
|
|
5
|
+
import {
|
|
6
|
+
resolveThinking,
|
|
7
|
+
isOpenAIReasoningModel,
|
|
8
|
+
geminiThinkingBudget,
|
|
9
|
+
anthropicThinkingBudget,
|
|
10
|
+
} from '../thinking.js';
|
|
11
|
+
|
|
12
|
+
describe('resolveThinking', () => {
|
|
13
|
+
test('returns undefined when neither per-call nor config is set', () => {
|
|
14
|
+
expect(resolveThinking(undefined, undefined)).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('boolean true/false map to enabled', () => {
|
|
18
|
+
expect(resolveThinking(undefined, true)).toEqual({ enabled: true });
|
|
19
|
+
expect(resolveThinking(undefined, false)).toEqual({ enabled: false });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a level enables thinking and carries the level', () => {
|
|
23
|
+
expect(resolveThinking('high', undefined)).toEqual({ enabled: true, level: 'high' });
|
|
24
|
+
expect(resolveThinking(undefined, 'low')).toEqual({ enabled: true, level: 'low' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('per-call overrides config', () => {
|
|
28
|
+
expect(resolveThinking(false, true)).toEqual({ enabled: false });
|
|
29
|
+
expect(resolveThinking('medium', false)).toEqual({ enabled: true, level: 'medium' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('ignores unknown strings defensively', () => {
|
|
33
|
+
expect(resolveThinking('ultra' as never, undefined)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('isOpenAIReasoningModel', () => {
|
|
38
|
+
test('matches o-series and gpt-5 families', () => {
|
|
39
|
+
for (const m of ['o1', 'o3', 'o4-mini', 'gpt-5', 'gpt-5-mini', 'GPT-5']) {
|
|
40
|
+
expect(isOpenAIReasoningModel(m)).toBe(true);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test('does not match chat / vLLM model names', () => {
|
|
44
|
+
for (const m of ['gpt-4o', 'qwen3.6-nvfp4', 'gemini-3.5-flash', 'claude-sonnet-4-5']) {
|
|
45
|
+
expect(isOpenAIReasoningModel(m)).toBe(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('budget maps', () => {
|
|
51
|
+
test('gemini 2.5 budget by level (0/-1 semantics)', () => {
|
|
52
|
+
expect(geminiThinkingBudget('minimal')).toBe(512);
|
|
53
|
+
expect(geminiThinkingBudget('low')).toBe(2048);
|
|
54
|
+
expect(geminiThinkingBudget('medium')).toBe(8192);
|
|
55
|
+
expect(geminiThinkingBudget('high')).toBe(24576);
|
|
56
|
+
expect(geminiThinkingBudget(undefined)).toBe(-1); // dynamic
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('anthropic budget stays >= 1024 and < max_tokens', () => {
|
|
60
|
+
expect(anthropicThinkingBudget('high', 32000)).toBe(16384);
|
|
61
|
+
expect(anthropicThinkingBudget('high', 4096)).toBe(3072); // clamped to max-1024
|
|
62
|
+
expect(anthropicThinkingBudget(undefined, 4096)).toBe(2048); // bare true
|
|
63
|
+
expect(anthropicThinkingBudget('low', 4096)).toBe(1024);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/tests/tools.test.ts
CHANGED
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for tools.ts — ToolBuilder and ToolExecutor
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect } from 'bun:test';
|
|
5
|
-
import { ToolBuilder, ToolExecutor, createTimeTool, createRandomNumberTool } from '../tools.js';
|
|
6
|
-
|
|
7
|
-
describe('ToolBuilder', () => {
|
|
8
|
-
it('builds a basic tool definition', () => {
|
|
9
|
-
const tool = new ToolBuilder('test_tool')
|
|
10
|
-
.description('A test tool')
|
|
11
|
-
.build();
|
|
12
|
-
|
|
13
|
-
expect(tool.type).toBe('function');
|
|
14
|
-
expect(tool.function.name).toBe('test_tool');
|
|
15
|
-
expect(tool.function.description).toBe('A test tool');
|
|
16
|
-
expect(tool.function.parameters.type).toBe('object');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('adds required parameters', () => {
|
|
20
|
-
const tool = new ToolBuilder('search')
|
|
21
|
-
.description('Search')
|
|
22
|
-
.addParameter('query', 'string', 'Search query', true)
|
|
23
|
-
.build();
|
|
24
|
-
|
|
25
|
-
expect(tool.function.parameters.properties).toHaveProperty('query');
|
|
26
|
-
expect(tool.function.parameters.required).toEqual(['query']);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('adds optional parameters', () => {
|
|
30
|
-
const tool = new ToolBuilder('search')
|
|
31
|
-
.description('Search')
|
|
32
|
-
.addParameter('query', 'string', 'Search query', true)
|
|
33
|
-
.addParameter('limit', 'number', 'Max results', false)
|
|
34
|
-
.build();
|
|
35
|
-
|
|
36
|
-
expect(tool.function.parameters.properties).toHaveProperty('limit');
|
|
37
|
-
expect(tool.function.parameters.required).toEqual(['query']);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('adds parameters with extra schema properties', () => {
|
|
41
|
-
const tool = new ToolBuilder('config')
|
|
42
|
-
.description('Configure')
|
|
43
|
-
.addParameter('theme', 'string', 'Color theme', false, {
|
|
44
|
-
enum: ['light', 'dark'],
|
|
45
|
-
})
|
|
46
|
-
.build();
|
|
47
|
-
|
|
48
|
-
const themeParam = (tool.function.parameters.properties as Record<string, Record<string, unknown>>)?.['theme'];
|
|
49
|
-
expect(themeParam?.enum).toEqual(['light', 'dark']);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('builds function definition only', () => {
|
|
53
|
-
const fn = new ToolBuilder('test')
|
|
54
|
-
.description('Test')
|
|
55
|
-
.buildFunction();
|
|
56
|
-
|
|
57
|
-
expect(fn.name).toBe('test');
|
|
58
|
-
expect(fn.description).toBe('Test');
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('ToolExecutor', () => {
|
|
63
|
-
describe('withTimeout', () => {
|
|
64
|
-
it('resolves if handler completes in time', async () => {
|
|
65
|
-
const handler = async () => 'result';
|
|
66
|
-
const wrapped = ToolExecutor.withTimeout(handler, 1000);
|
|
67
|
-
expect(await wrapped({})).toBe('result');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('rejects if handler exceeds timeout', async () => {
|
|
71
|
-
const handler = async () => {
|
|
72
|
-
await new Promise(r => setTimeout(r, 500));
|
|
73
|
-
return 'too late';
|
|
74
|
-
};
|
|
75
|
-
const wrapped = ToolExecutor.withTimeout(handler, 50);
|
|
76
|
-
expect(wrapped({})).rejects.toThrow('timeout');
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('safe', () => {
|
|
81
|
-
it('returns result on success', async () => {
|
|
82
|
-
const handler = async () => 42;
|
|
83
|
-
const wrapped = ToolExecutor.safe(handler);
|
|
84
|
-
expect(await wrapped({})).toBe(42);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('catches errors and returns error object', async () => {
|
|
88
|
-
const handler = async () => { throw new Error('boom'); };
|
|
89
|
-
const wrapped = ToolExecutor.safe(handler);
|
|
90
|
-
const result = await wrapped({}) as { error: string };
|
|
91
|
-
expect(result.error).toBe('boom');
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('withValidation', () => {
|
|
96
|
-
it('passes through if all required fields present', async () => {
|
|
97
|
-
const handler = async (args: unknown) => args;
|
|
98
|
-
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
99
|
-
expect(await wrapped({ name: 'test' })).toEqual({ name: 'test' });
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('throws if required field is missing', async () => {
|
|
103
|
-
const handler = async () => 'ok';
|
|
104
|
-
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
105
|
-
expect(wrapped({ other: 'value' })).rejects.toThrow('Missing required argument: name');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('throws if args is not an object', async () => {
|
|
109
|
-
const handler = async () => 'ok';
|
|
110
|
-
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
111
|
-
expect(wrapped(null)).rejects.toThrow('must be an object');
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe('timed', () => {
|
|
116
|
-
it('returns result with duration', async () => {
|
|
117
|
-
const handler = async () => 'hello';
|
|
118
|
-
const wrapped = ToolExecutor.timed(handler);
|
|
119
|
-
const result = await wrapped({}) as { result: string; duration: number };
|
|
120
|
-
expect(result.result).toBe('hello');
|
|
121
|
-
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('compose', () => {
|
|
126
|
-
it('composes multiple wrappers', async () => {
|
|
127
|
-
const handler = async () => 'result';
|
|
128
|
-
const composed = ToolExecutor.compose(
|
|
129
|
-
handler,
|
|
130
|
-
h => ToolExecutor.safe(h),
|
|
131
|
-
h => ToolExecutor.withTimeout(h, 5000),
|
|
132
|
-
);
|
|
133
|
-
expect(await composed({})).toBe('result');
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe('Common Tools', () => {
|
|
139
|
-
describe('createTimeTool', () => {
|
|
140
|
-
it('returns a valid tool definition', () => {
|
|
141
|
-
const tool = createTimeTool();
|
|
142
|
-
expect(tool.name).toBe('get_current_time');
|
|
143
|
-
expect(tool.description).toBeTruthy();
|
|
144
|
-
expect(tool.parameters.type).toBe('object');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('handler returns time info', async () => {
|
|
148
|
-
const tool = createTimeTool();
|
|
149
|
-
const result = await tool.handler({}) as { iso: string; timestamp: number };
|
|
150
|
-
expect(result.iso).toBeTruthy();
|
|
151
|
-
expect(result.timestamp).toBeGreaterThan(0);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('handler respects timezone parameter', async () => {
|
|
155
|
-
const tool = createTimeTool();
|
|
156
|
-
const result = await tool.handler({ timezone: 'America/New_York' }) as { timezone: string };
|
|
157
|
-
expect(result.timezone).toBe('America/New_York');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('createRandomNumberTool', () => {
|
|
162
|
-
it('returns a valid tool definition', () => {
|
|
163
|
-
const tool = createRandomNumberTool();
|
|
164
|
-
expect(tool.name).toBe('random_number');
|
|
165
|
-
expect(tool.description).toBeTruthy();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('handler returns number in range', async () => {
|
|
169
|
-
const tool = createRandomNumberTool();
|
|
170
|
-
const result = await tool.handler({ min: 1, max: 10 }) as { value: number };
|
|
171
|
-
expect(result.value).toBeGreaterThanOrEqual(1);
|
|
172
|
-
expect(result.value).toBeLessThanOrEqual(10);
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for tools.ts — ToolBuilder and ToolExecutor
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { ToolBuilder, ToolExecutor, createTimeTool, createRandomNumberTool } from '../tools.js';
|
|
6
|
+
|
|
7
|
+
describe('ToolBuilder', () => {
|
|
8
|
+
it('builds a basic tool definition', () => {
|
|
9
|
+
const tool = new ToolBuilder('test_tool')
|
|
10
|
+
.description('A test tool')
|
|
11
|
+
.build();
|
|
12
|
+
|
|
13
|
+
expect(tool.type).toBe('function');
|
|
14
|
+
expect(tool.function.name).toBe('test_tool');
|
|
15
|
+
expect(tool.function.description).toBe('A test tool');
|
|
16
|
+
expect(tool.function.parameters.type).toBe('object');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('adds required parameters', () => {
|
|
20
|
+
const tool = new ToolBuilder('search')
|
|
21
|
+
.description('Search')
|
|
22
|
+
.addParameter('query', 'string', 'Search query', true)
|
|
23
|
+
.build();
|
|
24
|
+
|
|
25
|
+
expect(tool.function.parameters.properties).toHaveProperty('query');
|
|
26
|
+
expect(tool.function.parameters.required).toEqual(['query']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('adds optional parameters', () => {
|
|
30
|
+
const tool = new ToolBuilder('search')
|
|
31
|
+
.description('Search')
|
|
32
|
+
.addParameter('query', 'string', 'Search query', true)
|
|
33
|
+
.addParameter('limit', 'number', 'Max results', false)
|
|
34
|
+
.build();
|
|
35
|
+
|
|
36
|
+
expect(tool.function.parameters.properties).toHaveProperty('limit');
|
|
37
|
+
expect(tool.function.parameters.required).toEqual(['query']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('adds parameters with extra schema properties', () => {
|
|
41
|
+
const tool = new ToolBuilder('config')
|
|
42
|
+
.description('Configure')
|
|
43
|
+
.addParameter('theme', 'string', 'Color theme', false, {
|
|
44
|
+
enum: ['light', 'dark'],
|
|
45
|
+
})
|
|
46
|
+
.build();
|
|
47
|
+
|
|
48
|
+
const themeParam = (tool.function.parameters.properties as Record<string, Record<string, unknown>>)?.['theme'];
|
|
49
|
+
expect(themeParam?.enum).toEqual(['light', 'dark']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('builds function definition only', () => {
|
|
53
|
+
const fn = new ToolBuilder('test')
|
|
54
|
+
.description('Test')
|
|
55
|
+
.buildFunction();
|
|
56
|
+
|
|
57
|
+
expect(fn.name).toBe('test');
|
|
58
|
+
expect(fn.description).toBe('Test');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('ToolExecutor', () => {
|
|
63
|
+
describe('withTimeout', () => {
|
|
64
|
+
it('resolves if handler completes in time', async () => {
|
|
65
|
+
const handler = async () => 'result';
|
|
66
|
+
const wrapped = ToolExecutor.withTimeout(handler, 1000);
|
|
67
|
+
expect(await wrapped({})).toBe('result');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects if handler exceeds timeout', async () => {
|
|
71
|
+
const handler = async () => {
|
|
72
|
+
await new Promise(r => setTimeout(r, 500));
|
|
73
|
+
return 'too late';
|
|
74
|
+
};
|
|
75
|
+
const wrapped = ToolExecutor.withTimeout(handler, 50);
|
|
76
|
+
expect(wrapped({})).rejects.toThrow('timeout');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('safe', () => {
|
|
81
|
+
it('returns result on success', async () => {
|
|
82
|
+
const handler = async () => 42;
|
|
83
|
+
const wrapped = ToolExecutor.safe(handler);
|
|
84
|
+
expect(await wrapped({})).toBe(42);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('catches errors and returns error object', async () => {
|
|
88
|
+
const handler = async () => { throw new Error('boom'); };
|
|
89
|
+
const wrapped = ToolExecutor.safe(handler);
|
|
90
|
+
const result = await wrapped({}) as { error: string };
|
|
91
|
+
expect(result.error).toBe('boom');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('withValidation', () => {
|
|
96
|
+
it('passes through if all required fields present', async () => {
|
|
97
|
+
const handler = async (args: unknown) => args;
|
|
98
|
+
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
99
|
+
expect(await wrapped({ name: 'test' })).toEqual({ name: 'test' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('throws if required field is missing', async () => {
|
|
103
|
+
const handler = async () => 'ok';
|
|
104
|
+
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
105
|
+
expect(wrapped({ other: 'value' })).rejects.toThrow('Missing required argument: name');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws if args is not an object', async () => {
|
|
109
|
+
const handler = async () => 'ok';
|
|
110
|
+
const wrapped = ToolExecutor.withValidation(handler, ['name']);
|
|
111
|
+
expect(wrapped(null)).rejects.toThrow('must be an object');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('timed', () => {
|
|
116
|
+
it('returns result with duration', async () => {
|
|
117
|
+
const handler = async () => 'hello';
|
|
118
|
+
const wrapped = ToolExecutor.timed(handler);
|
|
119
|
+
const result = await wrapped({}) as { result: string; duration: number };
|
|
120
|
+
expect(result.result).toBe('hello');
|
|
121
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('compose', () => {
|
|
126
|
+
it('composes multiple wrappers', async () => {
|
|
127
|
+
const handler = async () => 'result';
|
|
128
|
+
const composed = ToolExecutor.compose(
|
|
129
|
+
handler,
|
|
130
|
+
h => ToolExecutor.safe(h),
|
|
131
|
+
h => ToolExecutor.withTimeout(h, 5000),
|
|
132
|
+
);
|
|
133
|
+
expect(await composed({})).toBe('result');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('Common Tools', () => {
|
|
139
|
+
describe('createTimeTool', () => {
|
|
140
|
+
it('returns a valid tool definition', () => {
|
|
141
|
+
const tool = createTimeTool();
|
|
142
|
+
expect(tool.name).toBe('get_current_time');
|
|
143
|
+
expect(tool.description).toBeTruthy();
|
|
144
|
+
expect(tool.parameters.type).toBe('object');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('handler returns time info', async () => {
|
|
148
|
+
const tool = createTimeTool();
|
|
149
|
+
const result = await tool.handler({}) as { iso: string; timestamp: number };
|
|
150
|
+
expect(result.iso).toBeTruthy();
|
|
151
|
+
expect(result.timestamp).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('handler respects timezone parameter', async () => {
|
|
155
|
+
const tool = createTimeTool();
|
|
156
|
+
const result = await tool.handler({ timezone: 'America/New_York' }) as { timezone: string };
|
|
157
|
+
expect(result.timezone).toBe('America/New_York');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('createRandomNumberTool', () => {
|
|
162
|
+
it('returns a valid tool definition', () => {
|
|
163
|
+
const tool = createRandomNumberTool();
|
|
164
|
+
expect(tool.name).toBe('random_number');
|
|
165
|
+
expect(tool.description).toBeTruthy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('handler returns number in range', async () => {
|
|
169
|
+
const tool = createRandomNumberTool();
|
|
170
|
+
const result = await tool.handler({ min: 1, max: 10 }) as { value: number };
|
|
171
|
+
expect(result.value).toBeGreaterThanOrEqual(1);
|
|
172
|
+
expect(result.value).toBeLessThanOrEqual(10);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/thinking.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified thinking/reasoning resolution shared by all providers.
|
|
3
|
+
*
|
|
4
|
+
* Applications set a single `thinking` value — `true`/`false` or a level
|
|
5
|
+
* ('minimal' | 'low' | 'medium' | 'high') — at the model level and/or per call.
|
|
6
|
+
* Each provider maps the resolved intent to its native control (Gemini
|
|
7
|
+
* `thinkingLevel`/`thinkingBudget`, OpenAI `reasoning_effort`, vLLM
|
|
8
|
+
* `enable_thinking`, Anthropic `budget_tokens`, Ollama `think`).
|
|
9
|
+
*/
|
|
10
|
+
import type { ThinkingLevel } from './interfaces.js';
|
|
11
|
+
|
|
12
|
+
export interface ResolvedThinking {
|
|
13
|
+
/** Whether reasoning should be enabled at all. */
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Explicit level when the user provided one (absent for a bare `true`). */
|
|
16
|
+
level?: ThinkingLevel;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LEVELS: readonly string[] = ['minimal', 'low', 'medium', 'high'];
|
|
20
|
+
|
|
21
|
+
function isLevel(v: unknown): v is ThinkingLevel {
|
|
22
|
+
return typeof v === 'string' && LEVELS.includes(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the effective thinking intent from a per-call value (highest
|
|
27
|
+
* precedence) and the model-level config value. Returns `undefined` when
|
|
28
|
+
* neither is set, so providers omit the control entirely (and don't perturb
|
|
29
|
+
* servers that reject unknown fields).
|
|
30
|
+
*/
|
|
31
|
+
export function resolveThinking(
|
|
32
|
+
perCall: boolean | ThinkingLevel | undefined,
|
|
33
|
+
config: boolean | ThinkingLevel | undefined,
|
|
34
|
+
): ResolvedThinking | undefined {
|
|
35
|
+
const value = perCall ?? config;
|
|
36
|
+
if (value === undefined) return undefined;
|
|
37
|
+
if (value === false) return { enabled: false };
|
|
38
|
+
if (value === true) return { enabled: true };
|
|
39
|
+
if (isLevel(value)) return { enabled: true, level: value };
|
|
40
|
+
return undefined; // unknown string — ignore defensively
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Heuristic: OpenAI reasoning models use `reasoning_effort` (o-series, GPT-5). */
|
|
44
|
+
export function isOpenAIReasoningModel(model: string): boolean {
|
|
45
|
+
return /^(o\d|gpt-5)/i.test(model);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gemini 2.5 `thinkingBudget` for a level. 0 disables, -1 is dynamic, and the
|
|
50
|
+
* Flash range is 0–24576. A bare `true` (no level) maps to dynamic (-1).
|
|
51
|
+
*/
|
|
52
|
+
export function geminiThinkingBudget(level: ThinkingLevel | undefined): number {
|
|
53
|
+
switch (level) {
|
|
54
|
+
case 'minimal': return 512;
|
|
55
|
+
case 'low': return 2048;
|
|
56
|
+
case 'medium': return 8192;
|
|
57
|
+
case 'high': return 24576;
|
|
58
|
+
default: return -1; // enabled without an explicit level → dynamic
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Anthropic extended-thinking `budget_tokens` for a level, kept >= 1024 (the
|
|
64
|
+
* API minimum) and < `maxTokens` (the API requires headroom for the answer).
|
|
65
|
+
*/
|
|
66
|
+
export function anthropicThinkingBudget(level: ThinkingLevel | undefined, maxTokens: number): number {
|
|
67
|
+
const base = level === 'high' ? 16384
|
|
68
|
+
: level === 'medium' ? 4096
|
|
69
|
+
: level === 'low' ? 1024
|
|
70
|
+
: level === 'minimal' ? 1024
|
|
71
|
+
: 2048; // bare `true`
|
|
72
|
+
return Math.max(1024, Math.min(base, maxTokens - 1024));
|
|
73
|
+
}
|