modelmix 4.7.4 → 5.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/README.md +210 -89
- package/demo/demo.js +4 -4
- package/demo/prompt.md +2 -2
- package/http-client.js +15 -2
- package/index.d.ts +100 -11
- package/index.js +896 -273
- package/package.json +4 -1
- package/skills/modelmix/SKILL.md +75 -18
- package/test/README.md +10 -9
- package/test/anthropic.test.js +146 -9
- package/test/effort.test.js +2 -2
- package/test/fallback.test.js +192 -2
- package/test/fixtures/account-details.txt +4 -0
- package/test/fixtures/choice-options.txt +6 -0
- package/test/fixtures/choice-template.txt +2 -0
- package/test/fixtures/system-rules.txt +1 -0
- package/test/fixtures/system-template.txt +2 -0
- package/test/fixtures/template.txt +4 -11
- package/test/grok.test.js +28 -1
- package/test/history.test.js +2 -2
- package/test/live.mcp.js +20 -20
- package/test/live.test.js +11 -11
- package/test/templates.test.js +471 -281
- package/test/tokens.test.js +409 -16
package/test/templates.test.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
2
|
const sinon = require('sinon');
|
|
3
3
|
const nock = require('nock');
|
|
4
|
-
const fs = require('fs');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const { ModelMix } = require('../index.js');
|
|
7
6
|
|
|
8
|
-
describe('Template and File Operations Tests', () => {
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
describe('EJS Template and File Operations Tests', () => {
|
|
8
|
+
const fixturesPath = path.join(__dirname, 'fixtures');
|
|
9
|
+
|
|
11
10
|
if (global.setupTestHooks) {
|
|
12
11
|
global.setupTestHooks();
|
|
13
12
|
}
|
|
@@ -17,367 +16,558 @@ describe('Template and File Operations Tests', () => {
|
|
|
17
16
|
sinon.restore();
|
|
18
17
|
});
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
function mockOpenAI(assertRequest, responseText = 'Template processed successfully') {
|
|
20
|
+
nock('https://api.openai.com')
|
|
21
|
+
.post('/v1/responses')
|
|
22
|
+
.reply(function (uri, body) {
|
|
23
|
+
assertRequest(body);
|
|
24
|
+
return [200, testUtils.createMockResponse('openai-responses', responseText)];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
function userTexts(body) {
|
|
29
|
+
return body.input
|
|
30
|
+
.filter(message => message.role === 'user')
|
|
31
|
+
.flatMap(message => message.content)
|
|
32
|
+
.filter(content => content.type === 'input_text')
|
|
33
|
+
.map(content => content.text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('EJS rendering', () => {
|
|
37
|
+
it('renders inline variables with plain data keys', async () => {
|
|
38
|
+
const model = ModelMix.new()
|
|
39
|
+
.gpt51()
|
|
40
|
+
.replace({ name: 'Alice', age: 30, city: 'New York' })
|
|
41
|
+
.addText('Hello <%- name %>, you are <%- age %> years old and live in <%- city %>.');
|
|
42
|
+
|
|
43
|
+
mockOpenAI(body => {
|
|
44
|
+
expect(userTexts(body)).to.deep.equal([
|
|
45
|
+
'Hello Alice, you are 30 years old and live in New York.'
|
|
46
|
+
]);
|
|
26
47
|
});
|
|
48
|
+
|
|
49
|
+
await model.message();
|
|
27
50
|
});
|
|
28
51
|
|
|
29
|
-
it('
|
|
30
|
-
model.
|
|
52
|
+
it('supports nested data, conditionals, and loops', async () => {
|
|
53
|
+
const model = ModelMix.new()
|
|
54
|
+
.gpt51()
|
|
31
55
|
.replace({
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
user: {
|
|
57
|
+
name: 'Charlie',
|
|
58
|
+
active: true,
|
|
59
|
+
roles: ['admin', 'reviewer']
|
|
60
|
+
}
|
|
35
61
|
})
|
|
36
|
-
.addText('
|
|
62
|
+
.addText('<% if (user.active) { %><%- user.name %>: <% user.roles.forEach((role, index) => { %><%= index ? ", " : "" %><%- role %><% }) %><% } %>');
|
|
37
63
|
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
const userMsg = body.input.find(m => m.role === 'user');
|
|
42
|
-
expect(userMsg.content[0].text).to.equal('Hello Alice, you are 30 years old and live in New York.');
|
|
43
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Template processed successfully')];
|
|
44
|
-
});
|
|
64
|
+
mockOpenAI(body => {
|
|
65
|
+
expect(userTexts(body)).to.deep.equal(['Charlie: admin, reviewer']);
|
|
66
|
+
});
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
expect(response).to.include('Template processed successfully');
|
|
68
|
+
await model.message();
|
|
48
69
|
});
|
|
49
70
|
|
|
50
|
-
it('
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
.replace({
|
|
55
|
-
.addText('
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Multiple templates replaced')];
|
|
63
|
-
});
|
|
71
|
+
it('keeps raw and XML-escaped output distinct', async () => {
|
|
72
|
+
const value = 'Hello & "World" <test>';
|
|
73
|
+
const model = ModelMix.new()
|
|
74
|
+
.gpt51()
|
|
75
|
+
.replace({ value })
|
|
76
|
+
.addText('Escaped: <%= value %>\nRaw: <%- value %>');
|
|
77
|
+
|
|
78
|
+
mockOpenAI(body => {
|
|
79
|
+
expect(userTexts(body)).to.deep.equal([
|
|
80
|
+
'Escaped: Hello & "World" <test>\nRaw: Hello & "World" <test>'
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
expect(response).to.include('Multiple templates replaced');
|
|
84
|
+
await model.message();
|
|
67
85
|
});
|
|
68
86
|
|
|
69
|
-
it('
|
|
70
|
-
model.
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'{{company_name}}': 'TechCorp',
|
|
75
|
-
'{{company_domain}}': 'techcorp.com'
|
|
76
|
-
})
|
|
77
|
-
.addText('User {{user_name}} with role {{user_role}} works at {{company_name}} ({{company_domain}})');
|
|
87
|
+
it('does not execute EJS received through template data', async () => {
|
|
88
|
+
const model = ModelMix.new()
|
|
89
|
+
.gpt51()
|
|
90
|
+
.replace({ payload: '<%- secret %>', secret: 'must-not-render' })
|
|
91
|
+
.addText('Payload: <%- payload %>');
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
|
|
82
|
-
const userMsg = body.input.find(m => m.role === 'user');
|
|
83
|
-
expect(userMsg.content[0].text).to.equal('User Charlie with role admin works at TechCorp (techcorp.com)');
|
|
84
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Nested templates working')];
|
|
85
|
-
});
|
|
93
|
+
mockOpenAI(body => {
|
|
94
|
+
expect(userTexts(body)).to.deep.equal(['Payload: <%- secret %>']);
|
|
95
|
+
});
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
expect(response).to.include('Nested templates working');
|
|
97
|
+
await model.message();
|
|
89
98
|
});
|
|
90
99
|
|
|
91
|
-
it('
|
|
92
|
-
model.
|
|
93
|
-
.
|
|
94
|
-
.addText(
|
|
100
|
+
it('selects uniformly when choice options omit weights', async () => {
|
|
101
|
+
const model = ModelMix.new()
|
|
102
|
+
.gpt51()
|
|
103
|
+
.addText(`<% choice %>
|
|
104
|
+
<% option %>
|
|
105
|
+
Use emojis.
|
|
106
|
+
<% option %>
|
|
107
|
+
Use few emojis.
|
|
108
|
+
<% option %>
|
|
109
|
+
Do not use emojis.
|
|
110
|
+
<% /choice %>`);
|
|
111
|
+
sinon.stub(model, '_choiceRandom').returns(0.5);
|
|
112
|
+
|
|
113
|
+
mockOpenAI(body => {
|
|
114
|
+
expect(userTexts(body)[0].trim()).to.equal('Use few emojis.');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await model.message();
|
|
118
|
+
});
|
|
95
119
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
it('selects weighted options using relative weights', async () => {
|
|
121
|
+
const model = ModelMix.new()
|
|
122
|
+
.gpt51()
|
|
123
|
+
.replace({ language: 'Spanish' })
|
|
124
|
+
.addText(`<% choice %>
|
|
125
|
+
<% option 20 %>
|
|
126
|
+
Use emojis in <%- language %>.
|
|
127
|
+
<% option 40 %>
|
|
128
|
+
Use few emojis in <%- language %>.
|
|
129
|
+
<% option 40 %>
|
|
130
|
+
Do not use emojis in <%- language %>.
|
|
131
|
+
<% /choice %>`);
|
|
132
|
+
sinon.stub(model, '_choiceRandom').returns(0.2);
|
|
133
|
+
|
|
134
|
+
mockOpenAI(body => {
|
|
135
|
+
expect(userTexts(body)[0].trim()).to.equal('Use few emojis in Spanish.');
|
|
136
|
+
});
|
|
103
137
|
|
|
104
|
-
|
|
105
|
-
expect(response).to.include('Partial template replacement');
|
|
138
|
+
await model.message();
|
|
106
139
|
});
|
|
107
140
|
|
|
108
|
-
it('
|
|
109
|
-
model.
|
|
110
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
it('supports nested choices', async () => {
|
|
142
|
+
const model = ModelMix.new()
|
|
143
|
+
.gpt51()
|
|
144
|
+
.addText(`<% choice %>
|
|
145
|
+
<% option %>
|
|
146
|
+
Tone:
|
|
147
|
+
<% choice %>
|
|
148
|
+
<% option %>
|
|
149
|
+
formal
|
|
150
|
+
<% option %>
|
|
151
|
+
casual
|
|
152
|
+
<% /choice %>
|
|
153
|
+
<% option %>
|
|
154
|
+
No tone instruction.
|
|
155
|
+
<% /choice %>`);
|
|
156
|
+
const random = sinon.stub(model, '_choiceRandom');
|
|
157
|
+
random.onFirstCall().returns(0.1);
|
|
158
|
+
random.onSecondCall().returns(0.9);
|
|
159
|
+
|
|
160
|
+
mockOpenAI(body => {
|
|
161
|
+
expect(userTexts(body)[0].trim()).to.equal('Tone:\ncasual');
|
|
162
|
+
});
|
|
117
163
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const userMsg = body.input.find(m => m.role === 'user');
|
|
122
|
-
expect(userMsg.content[0].text).to.equal('Empty: , Special: Hello & "World" <test>, Number: 42, Boolean: true');
|
|
123
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Special characters handled')];
|
|
124
|
-
});
|
|
164
|
+
await model.message();
|
|
165
|
+
expect(random.callCount).to.equal(2);
|
|
166
|
+
});
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
it('rerolls choices on each new request', async () => {
|
|
169
|
+
const template = `<% choice %>
|
|
170
|
+
<% option %>
|
|
171
|
+
first
|
|
172
|
+
<% option %>
|
|
173
|
+
second
|
|
174
|
+
<% /choice %>`;
|
|
175
|
+
const model = ModelMix.new().gpt51().addText(template);
|
|
176
|
+
const random = sinon.stub(model, '_choiceRandom');
|
|
177
|
+
random.onFirstCall().returns(0.1);
|
|
178
|
+
random.onSecondCall().returns(0.9);
|
|
179
|
+
|
|
180
|
+
mockOpenAI(body => {
|
|
181
|
+
expect(userTexts(body)[0].trim()).to.equal('first');
|
|
182
|
+
}, 'First response');
|
|
183
|
+
await model.message();
|
|
184
|
+
|
|
185
|
+
model.addText(template);
|
|
186
|
+
mockOpenAI(body => {
|
|
187
|
+
expect(userTexts(body)[0].trim()).to.equal('second');
|
|
188
|
+
}, 'Second response');
|
|
189
|
+
await model.message();
|
|
190
|
+
|
|
191
|
+
expect(random.callCount).to.equal(2);
|
|
128
192
|
});
|
|
129
|
-
});
|
|
130
193
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
194
|
+
it('fails before the request when a variable is missing', async () => {
|
|
195
|
+
const model = ModelMix.new()
|
|
196
|
+
.gpt51()
|
|
197
|
+
.replace({ name: 'David' })
|
|
198
|
+
.addText('Hello <%- name %>, status: <%- status %>');
|
|
199
|
+
|
|
200
|
+
let error;
|
|
201
|
+
try {
|
|
202
|
+
await model.message();
|
|
203
|
+
} catch (caught) {
|
|
204
|
+
error = caught;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
expect(error).to.be.instanceOf(Error);
|
|
208
|
+
expect(error.message).to.include('Failed to render message template');
|
|
209
|
+
expect(error.message).to.include('status is not defined');
|
|
210
|
+
});
|
|
134
211
|
|
|
135
|
-
|
|
136
|
-
model = ModelMix.new(
|
|
137
|
-
|
|
138
|
-
|
|
212
|
+
it('rejects invalid template data immediately', () => {
|
|
213
|
+
const model = ModelMix.new().gpt51();
|
|
214
|
+
|
|
215
|
+
expect(() => model.replace(null)).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
216
|
+
expect(() => model.replace(undefined)).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
217
|
+
expect(() => model.replace([])).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
218
|
+
expect(() => ModelMix.new({ config: { replace: null } })).to.throw(
|
|
219
|
+
TypeError,
|
|
220
|
+
'Template data must be a plain non-null object.'
|
|
221
|
+
);
|
|
222
|
+
expect(() => model.replace({ $mix: 'reserved' })).to.throw(
|
|
223
|
+
TypeError,
|
|
224
|
+
'Template data key "$mix" is reserved.'
|
|
225
|
+
);
|
|
139
226
|
});
|
|
140
227
|
|
|
141
|
-
it('
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
228
|
+
it('reports malformed choice directives with their source line', async () => {
|
|
229
|
+
const cases = [
|
|
230
|
+
{
|
|
231
|
+
source: '<% choice %>\n<% option %>\none\n<% option 2 %>\ntwo\n<% /choice %>',
|
|
232
|
+
message: 'Choice options must either all have weights or all omit them',
|
|
233
|
+
line: 4
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
source: '<% choice %>\n<% option 0 %>\none\n<% /choice %>',
|
|
237
|
+
message: 'Choice weight must be a positive finite number',
|
|
238
|
+
line: 2
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
source: '<% option %>\none',
|
|
242
|
+
message: 'Option directive must be inside a choice',
|
|
243
|
+
line: 1
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
source: '<% choice %>\ntext\n<% option %>\none\n<% /choice %>',
|
|
247
|
+
message: 'Choice content must be inside an option',
|
|
248
|
+
line: 2
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
source: '<% choice %>\n<% option %>\none',
|
|
252
|
+
message: 'Unclosed choice directive',
|
|
253
|
+
line: 1
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const testCase of cases) {
|
|
258
|
+
const model = ModelMix.new().gpt51().addText(testCase.source);
|
|
259
|
+
let error;
|
|
260
|
+
try {
|
|
261
|
+
await model.message();
|
|
262
|
+
} catch (caught) {
|
|
263
|
+
error = caught;
|
|
264
|
+
}
|
|
265
|
+
expect(error).to.be.instanceOf(Error);
|
|
266
|
+
expect(error.message).to.include(testCase.message);
|
|
267
|
+
expect(error.message).to.include(`message template at line ${testCase.line}`);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
154
270
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
271
|
+
it('rerolls earlier choices after a later template fails to render', async () => {
|
|
272
|
+
const model = ModelMix.new()
|
|
273
|
+
.gpt51()
|
|
274
|
+
.addText(`<% choice %>
|
|
275
|
+
<% option %>
|
|
276
|
+
A
|
|
277
|
+
<% option %>
|
|
278
|
+
B
|
|
279
|
+
<% /choice %>`)
|
|
280
|
+
.addText('<%- missing %>');
|
|
281
|
+
const random = sinon.stub(model, '_choiceRandom');
|
|
282
|
+
random.onFirstCall().returns(0.1);
|
|
283
|
+
random.onSecondCall().returns(0.9);
|
|
284
|
+
|
|
285
|
+
let error;
|
|
286
|
+
try {
|
|
287
|
+
await model.message();
|
|
288
|
+
} catch (caught) {
|
|
289
|
+
error = caught;
|
|
290
|
+
}
|
|
291
|
+
expect(error).to.be.instanceOf(Error);
|
|
292
|
+
expect(model.messages[0].content[0].text).to.include('<% choice %>');
|
|
293
|
+
|
|
294
|
+
model.replace({ missing: 'ready' });
|
|
295
|
+
mockOpenAI(body => {
|
|
296
|
+
const text = userTexts(body).join('\n').trim();
|
|
297
|
+
expect(text).to.include('B');
|
|
298
|
+
expect(text).to.not.include('A');
|
|
299
|
+
expect(text).to.include('ready');
|
|
300
|
+
});
|
|
301
|
+
await model.message();
|
|
167
302
|
|
|
168
|
-
|
|
169
|
-
expect(response).to.include('Template file processed');
|
|
303
|
+
expect(random.callCount).to.equal(2);
|
|
170
304
|
});
|
|
171
305
|
|
|
172
|
-
it('
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
306
|
+
it('rerolls choices after a request fails', async () => {
|
|
307
|
+
const template = `<% choice %>
|
|
308
|
+
<% option %>
|
|
309
|
+
A
|
|
310
|
+
<% option %>
|
|
311
|
+
B
|
|
312
|
+
<% /choice %>`;
|
|
313
|
+
const model = ModelMix.new().gpt51().addText(template);
|
|
314
|
+
const random = sinon.stub(model, '_choiceRandom');
|
|
315
|
+
random.onFirstCall().returns(0.1);
|
|
316
|
+
random.onSecondCall().returns(0.9);
|
|
176
317
|
|
|
177
318
|
nock('https://api.openai.com')
|
|
178
319
|
.post('/v1/responses')
|
|
179
|
-
.reply(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
320
|
+
.reply(500, { error: 'temporary failure' });
|
|
321
|
+
let error;
|
|
322
|
+
try {
|
|
323
|
+
await model.message();
|
|
324
|
+
} catch (caught) {
|
|
325
|
+
error = caught;
|
|
326
|
+
}
|
|
327
|
+
expect(error).to.exist;
|
|
328
|
+
expect(model.messages[0].content[0].text).to.equal(template);
|
|
329
|
+
|
|
330
|
+
mockOpenAI(body => {
|
|
331
|
+
expect(userTexts(body)[0].trim()).to.equal('B');
|
|
332
|
+
});
|
|
333
|
+
await model.message();
|
|
191
334
|
|
|
192
|
-
|
|
193
|
-
expect(response).to.include('JSON data processed');
|
|
335
|
+
expect(random.callCount).to.equal(2);
|
|
194
336
|
});
|
|
195
337
|
|
|
196
|
-
it('
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
338
|
+
it('does not let a failed concurrent request overwrite a successful choice', async () => {
|
|
339
|
+
const template = `<% choice %>
|
|
340
|
+
<% option %>
|
|
341
|
+
A
|
|
342
|
+
<% option %>
|
|
343
|
+
B
|
|
344
|
+
<% /choice %>`;
|
|
345
|
+
const model = ModelMix.new({
|
|
346
|
+
config: {
|
|
347
|
+
max_history: 10,
|
|
348
|
+
bottleneck: { maxConcurrent: 2, minTime: 0 }
|
|
349
|
+
}
|
|
350
|
+
}).gpt51().addText(template);
|
|
351
|
+
const content = model.messages[0].content[0];
|
|
352
|
+
const random = sinon.stub(model, '_choiceRandom');
|
|
353
|
+
random.onFirstCall().returns(0.1);
|
|
354
|
+
random.onSecondCall().returns(0.9);
|
|
200
355
|
|
|
201
356
|
nock('https://api.openai.com')
|
|
202
357
|
.post('/v1/responses')
|
|
203
|
-
.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
358
|
+
.delay(100)
|
|
359
|
+
.reply(500, { error: 'delayed failure' });
|
|
360
|
+
nock('https://api.openai.com')
|
|
361
|
+
.post('/v1/responses')
|
|
362
|
+
.reply(200, testUtils.createMockResponse('openai-responses', 'Success'));
|
|
363
|
+
|
|
364
|
+
const results = await Promise.allSettled([model.message(), model.message()]);
|
|
208
365
|
|
|
209
|
-
|
|
210
|
-
expect(
|
|
366
|
+
expect(results.map(result => result.status)).to.deep.equal(['rejected', 'fulfilled']);
|
|
367
|
+
expect(content.text.trim()).to.equal('B');
|
|
368
|
+
expect(model.messageTemplates.has(content)).to.equal(false);
|
|
369
|
+
expect(random.callCount).to.equal(2);
|
|
211
370
|
});
|
|
371
|
+
});
|
|
212
372
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
.
|
|
373
|
+
describe('File templates and data', () => {
|
|
374
|
+
it('renders a file template with a relative include', async () => {
|
|
375
|
+
const model = ModelMix.new()
|
|
376
|
+
.gpt51()
|
|
217
377
|
.replace({
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
378
|
+
name: 'Eve',
|
|
379
|
+
platform: 'ModelMix',
|
|
380
|
+
username: 'eve_user',
|
|
381
|
+
role: 'developer',
|
|
382
|
+
createdDate: '2026-08-07',
|
|
383
|
+
website: 'https://modelmix.dev',
|
|
384
|
+
company: 'AI Solutions',
|
|
385
|
+
showAccount: true
|
|
225
386
|
})
|
|
226
|
-
.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(content).to.include('"theme": "dark"');
|
|
237
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Multiple files processed')];
|
|
238
|
-
});
|
|
387
|
+
.addTextFromFile(path.join(fixturesPath, 'template.txt'));
|
|
388
|
+
|
|
389
|
+
mockOpenAI(body => {
|
|
390
|
+
const content = userTexts(body)[0];
|
|
391
|
+
expect(content).to.include('Hello Eve, welcome to ModelMix!');
|
|
392
|
+
expect(content).to.include('Username: eve_user');
|
|
393
|
+
expect(content).to.include('Role: developer');
|
|
394
|
+
expect(content).to.include('Created: 2026-08-07');
|
|
395
|
+
expect(content).to.include('The AI Solutions Team');
|
|
396
|
+
});
|
|
239
397
|
|
|
240
|
-
|
|
241
|
-
expect(response).to.include('Multiple files processed');
|
|
398
|
+
await model.message();
|
|
242
399
|
});
|
|
243
400
|
|
|
244
|
-
it('
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.replace({
|
|
250
|
-
'{{name}}': 'Grace',
|
|
251
|
-
'{{platform}}': 'AbsolutePath',
|
|
252
|
-
'{{username}}': 'grace_abs',
|
|
253
|
-
'{{role}}': 'admin',
|
|
254
|
-
'{{created_date}}': '2023-12-20',
|
|
255
|
-
'{{website}}': 'https://absolute.com',
|
|
256
|
-
'{{company}}': 'Absolute Corp'
|
|
257
|
-
})
|
|
258
|
-
.addText('Absolute path content: {{absolute}}');
|
|
401
|
+
it('processes choice directives inside relative includes', async () => {
|
|
402
|
+
const model = ModelMix.new()
|
|
403
|
+
.gpt51()
|
|
404
|
+
.addTextFromFile(path.join(fixturesPath, 'choice-template.txt'));
|
|
405
|
+
sinon.stub(model, '_choiceRandom').returns(0.75);
|
|
259
406
|
|
|
260
|
-
|
|
261
|
-
.
|
|
262
|
-
|
|
263
|
-
const userMsg = body.input.find(m => m.role === 'user');
|
|
264
|
-
const content = userMsg.content[0].text;
|
|
265
|
-
expect(content).to.include('Hello Grace, welcome to AbsolutePath!');
|
|
266
|
-
expect(content).to.include('The Absolute Corp Team');
|
|
267
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Absolute path works')];
|
|
268
|
-
});
|
|
407
|
+
mockOpenAI(body => {
|
|
408
|
+
expect(userTexts(body)[0].trim()).to.equal('Style:\nBe concise.');
|
|
409
|
+
});
|
|
269
410
|
|
|
270
|
-
|
|
271
|
-
expect(response).to.include('Absolute path works');
|
|
411
|
+
await model.message();
|
|
272
412
|
});
|
|
273
|
-
});
|
|
274
413
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
414
|
+
it('preserves a system template filename through new instances', async () => {
|
|
415
|
+
const base = ModelMix.new()
|
|
416
|
+
.setSystemFromFile(path.join(fixturesPath, 'system-template.txt'))
|
|
417
|
+
.replace({ role: 'data analyst', language: 'Spanish' });
|
|
418
|
+
const model = base.new().gpt51().addText('Analyze this.');
|
|
278
419
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
420
|
+
mockOpenAI(body => {
|
|
421
|
+
const system = body.input.find(message => message.role === 'developer');
|
|
422
|
+
expect(system.content[0].text).to.include('You are a data analyst.');
|
|
423
|
+
expect(system.content[0].text).to.include('Always respond in Spanish.');
|
|
282
424
|
});
|
|
425
|
+
|
|
426
|
+
await model.message();
|
|
283
427
|
});
|
|
284
428
|
|
|
285
|
-
it('
|
|
286
|
-
model.
|
|
287
|
-
.
|
|
288
|
-
.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
429
|
+
it('injects file contents as raw data without recursively rendering them', async () => {
|
|
430
|
+
const model = ModelMix.new()
|
|
431
|
+
.gpt51()
|
|
432
|
+
.replaceKeyFromFile('templateSource', path.join(fixturesPath, 'template.txt'))
|
|
433
|
+
.replace({ name: 'must-not-render' })
|
|
434
|
+
.addText('Source:\n<%- templateSource %>');
|
|
435
|
+
|
|
436
|
+
mockOpenAI(body => {
|
|
437
|
+
const content = userTexts(body)[0];
|
|
438
|
+
expect(content).to.include('Hello <%- name %>, welcome to <%- platform %>!');
|
|
439
|
+
expect(content).to.not.include('Hello must-not-render');
|
|
440
|
+
});
|
|
294
441
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
.reply(function (uri, body) {
|
|
298
|
-
const userMsg = body.input.find(m => m.role === 'user');
|
|
299
|
-
const content = userMsg.content[0].text;
|
|
300
|
-
expect(content).to.include('Please analyze the following user behavior patterns and generate a detailed report:');
|
|
301
|
-
expect(content).to.include('Alice Smith');
|
|
302
|
-
expect(content).to.include('total_users');
|
|
303
|
-
return [200, testUtils.createMockResponse('openai-responses', 'Complex template integration successful')];
|
|
304
|
-
});
|
|
442
|
+
await model.message();
|
|
443
|
+
});
|
|
305
444
|
|
|
306
|
-
|
|
307
|
-
|
|
445
|
+
it('injects JSON file contents without XML escaping', async () => {
|
|
446
|
+
const model = ModelMix.new()
|
|
447
|
+
.gpt51()
|
|
448
|
+
.replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
|
|
449
|
+
.addText('Process this data:\n<%- data %>');
|
|
450
|
+
|
|
451
|
+
mockOpenAI(body => {
|
|
452
|
+
const content = userTexts(body)[0];
|
|
453
|
+
expect(content).to.include('Alice Smith');
|
|
454
|
+
expect(content).to.include('alice@example.com');
|
|
455
|
+
expect(content).to.include('"theme": "dark"');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
await model.message();
|
|
308
459
|
});
|
|
309
460
|
|
|
310
|
-
it('
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
461
|
+
it('throws immediately when a template or data file is missing', () => {
|
|
462
|
+
const model = ModelMix.new().gpt51();
|
|
463
|
+
const missingPath = path.join(fixturesPath, 'nonexistent.txt');
|
|
464
|
+
|
|
465
|
+
expect(() => model.addTextFromFile(missingPath)).to.throw(`File not found: ${missingPath}`);
|
|
466
|
+
expect(() => model.replaceKeyFromFile('missing', missingPath)).to.throw(`File not found: ${missingPath}`);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
317
469
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
470
|
+
describe('Execution integration', () => {
|
|
471
|
+
it('renders system and message templates for JSON output', async () => {
|
|
472
|
+
const schema = { summary: 'Analysis summary', userCount: 0 };
|
|
473
|
+
const model = ModelMix.new()
|
|
474
|
+
.gpt51()
|
|
475
|
+
.setSystem('You are a <%- role %>.')
|
|
476
|
+
.replace({ role: 'data analyst', instruction: 'Count active users' })
|
|
477
|
+
.replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
|
|
478
|
+
.addText('<%- instruction %> from this data: <%- data %>');
|
|
322
479
|
|
|
323
480
|
nock('https://api.openai.com')
|
|
324
481
|
.post('/v1/responses')
|
|
325
482
|
.reply(function (uri, body) {
|
|
326
|
-
const
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
483
|
+
const system = body.input.find(message => message.role === 'developer');
|
|
484
|
+
expect(system.content[0].text).to.include('You are a data analyst.');
|
|
485
|
+
expect(system.content[0].text).to.include('Output JSON Schema');
|
|
486
|
+
expect(userTexts(body)[0]).to.include('Count active users');
|
|
329
487
|
return [200, {
|
|
330
488
|
output: [{
|
|
331
489
|
type: 'message',
|
|
332
|
-
content: [{
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
roles: ['admin', 'user', 'moderator']
|
|
337
|
-
}) }]
|
|
490
|
+
content: [{
|
|
491
|
+
type: 'output_text',
|
|
492
|
+
text: JSON.stringify({ summary: 'Complete', userCount: 3 })
|
|
493
|
+
}]
|
|
338
494
|
}],
|
|
339
495
|
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }
|
|
340
496
|
}];
|
|
341
497
|
});
|
|
342
498
|
|
|
343
499
|
const result = await model.json(schema);
|
|
344
|
-
expect(result
|
|
345
|
-
expect(result.user_count).to.equal(3);
|
|
346
|
-
expect(result.active_users).to.equal(2);
|
|
347
|
-
expect(result.roles).to.deep.equal(['admin', 'user', 'moderator']);
|
|
500
|
+
expect(result).to.deep.equal({ summary: 'Complete', userCount: 3 });
|
|
348
501
|
});
|
|
349
|
-
});
|
|
350
502
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
503
|
+
it('renders the system before adding block instructions', async () => {
|
|
504
|
+
const model = ModelMix.new()
|
|
505
|
+
.gpt51()
|
|
506
|
+
.setSystem('Act as <%- role %>.')
|
|
507
|
+
.replace({ role: 'reviewer' })
|
|
508
|
+
.addText('Review this.');
|
|
509
|
+
|
|
510
|
+
mockOpenAI(body => {
|
|
511
|
+
const system = body.input.find(message => message.role === 'developer');
|
|
512
|
+
expect(system.content[0].text).to.equal(
|
|
513
|
+
'Act as reviewer.\nReturn the result of the task between triple backtick block code tags ```'
|
|
514
|
+
);
|
|
515
|
+
}, '```\napproved\n```');
|
|
516
|
+
|
|
517
|
+
expect(await model.block()).to.equal('approved');
|
|
358
518
|
});
|
|
359
519
|
|
|
360
|
-
it('
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
520
|
+
it('keeps rendered history snapshots when template data changes', async () => {
|
|
521
|
+
const model = ModelMix.new({ config: { max_history: 10 } })
|
|
522
|
+
.gpt51()
|
|
523
|
+
.replace({ name: 'Alice' })
|
|
524
|
+
.addText('Hello <%- name %>.');
|
|
525
|
+
|
|
526
|
+
mockOpenAI(body => {
|
|
527
|
+
expect(userTexts(body)).to.deep.equal(['Hello Alice.']);
|
|
528
|
+
}, 'First response');
|
|
529
|
+
await model.message();
|
|
530
|
+
|
|
531
|
+
model.replace({ name: 'Bob' }).addText('Hello <%- name %>.');
|
|
532
|
+
mockOpenAI(body => {
|
|
533
|
+
expect(userTexts(body)).to.deep.equal(['Hello Alice.', 'Hello Bob.']);
|
|
534
|
+
}, 'Second response');
|
|
535
|
+
await model.message();
|
|
368
536
|
});
|
|
369
537
|
|
|
370
|
-
it('
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
.
|
|
538
|
+
it('keeps a system choice stable across provider fallback', async () => {
|
|
539
|
+
const systems = [];
|
|
540
|
+
const model = ModelMix.new()
|
|
541
|
+
.gpt51()
|
|
542
|
+
.sonnet46()
|
|
543
|
+
.setSystem(`<% choice %>
|
|
544
|
+
<% option %>
|
|
545
|
+
First system.
|
|
546
|
+
<% option %>
|
|
547
|
+
Second system.
|
|
548
|
+
<% /choice %>`)
|
|
549
|
+
.addText('Hello');
|
|
550
|
+
const random = sinon.stub(model, '_choiceRandom').returns(0.9);
|
|
374
551
|
|
|
375
552
|
nock('https://api.openai.com')
|
|
376
553
|
.post('/v1/responses')
|
|
377
|
-
.reply(
|
|
554
|
+
.reply(function (uri, body) {
|
|
555
|
+
systems.push(body.input.find(message => message.role === 'developer').content[0].text.trim());
|
|
556
|
+
return [500, { error: 'temporary failure' }];
|
|
557
|
+
});
|
|
558
|
+
nock('https://api.anthropic.com')
|
|
559
|
+
.post('/v1/messages')
|
|
560
|
+
.reply(function (uri, body) {
|
|
561
|
+
systems.push(body.system.trim());
|
|
562
|
+
return [200, {
|
|
563
|
+
content: [{ type: 'text', text: 'Fallback response' }],
|
|
564
|
+
usage: { input_tokens: 10, output_tokens: 5 }
|
|
565
|
+
}];
|
|
566
|
+
});
|
|
378
567
|
|
|
379
|
-
|
|
380
|
-
expect(
|
|
568
|
+
expect(await model.message()).to.equal('Fallback response');
|
|
569
|
+
expect(systems).to.deep.equal(['Second system.', 'Second system.']);
|
|
570
|
+
expect(random.callCount).to.equal(1);
|
|
381
571
|
});
|
|
382
572
|
});
|
|
383
|
-
});
|
|
573
|
+
});
|