ag-ai 0.1.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 +31 -0
- package/dist/client/adapters/cluster.d.ts +12 -0
- package/dist/client/adapters/cluster.js +567 -0
- package/dist/client/adapters/google.d.ts +12 -0
- package/dist/client/adapters/google.js +478 -0
- package/dist/client/client.d.ts +11 -0
- package/dist/client/client.js +192 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +18 -0
- package/dist/client/quota.d.ts +55 -0
- package/dist/client/quota.js +148 -0
- package/dist/client/types.d.ts +124 -0
- package/dist/client/types.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/internal/apikey.d.ts +5 -0
- package/dist/internal/apikey.js +51 -0
- package/dist/internal/array.d.ts +2 -0
- package/dist/internal/array.js +6 -0
- package/dist/internal/log.d.ts +9 -0
- package/dist/internal/log.js +92 -0
- package/dist/internal/node-cache.d.ts +71 -0
- package/dist/internal/node-cache.js +104 -0
- package/dist/internal/retryOnError.d.ts +26 -0
- package/dist/internal/retryOnError.js +123 -0
- package/dist/internal/string/redact.d.ts +2 -0
- package/dist/internal/string/redact.js +94 -0
- package/dist/internal/string/truncate.d.ts +8 -0
- package/dist/internal/string/truncate.js +16 -0
- package/dist/k8s/assets/network-init.sh +52 -0
- package/dist/k8s/common.d.ts +35 -0
- package/dist/k8s/common.js +56 -0
- package/dist/k8s/index.d.ts +9 -0
- package/dist/k8s/index.js +33 -0
- package/dist/k8s/opencode.d.ts +4 -0
- package/dist/k8s/opencode.js +55 -0
- package/dist/k8s/runner.d.ts +4 -0
- package/dist/k8s/runner.js +247 -0
- package/dist/k8s/t3code.d.ts +4 -0
- package/dist/k8s/t3code.js +60 -0
- package/package.json +56 -0
- package/runtime/bridge.mjs +1093 -0
- package/runtime/executor/opencode.mjs +430 -0
- package/runtime/image/adapter.mjs +213 -0
- package/runtime/index.mjs +53 -0
- package/runtime/protocol.mjs +384 -0
- package/runtime/server.mjs +621 -0
- package/runtime/store.mjs +568 -0
- package/runtime/telemetry.mjs +137 -0
- package/runtime/tests/bridge.test.mjs +554 -0
- package/runtime/tests/image-adapter.test.mjs +148 -0
- package/runtime/tests/protocol.test.mjs +156 -0
- package/runtime/tests/server.test.mjs +231 -0
- package/runtime/tests/store.test.mjs +114 -0
- package/runtime/tests/telemetry.test.mjs +138 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
RunnerClient,
|
|
11
|
+
UncertainRequestError,
|
|
12
|
+
normalizeBaseUrl,
|
|
13
|
+
processJob,
|
|
14
|
+
readConfig,
|
|
15
|
+
validateJobEnvelope,
|
|
16
|
+
} from '../bridge.mjs';
|
|
17
|
+
import { createOpenCodeExecutor } from '../executor/opencode.mjs';
|
|
18
|
+
|
|
19
|
+
const PNG_BASE64 = 'iVBORw0KGgo=';
|
|
20
|
+
|
|
21
|
+
const readRequestBody = (request) =>
|
|
22
|
+
new Promise((resolve) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
25
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const listen = (server) =>
|
|
29
|
+
new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
|
|
30
|
+
|
|
31
|
+
const closeServer = (server) => new Promise((resolve) => server.close(() => resolve()));
|
|
32
|
+
|
|
33
|
+
const waitFor = async (predicate, timeoutMs = 2000) => {
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
if (predicate()) return;
|
|
37
|
+
// eslint-disable-next-line no-await-in-loop -- Polling the fake server keeps assertions deterministic.
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
39
|
+
}
|
|
40
|
+
throw new Error('condition was not met before the timeout');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const startFakeOpenCode = async ({ messageHandler } = {}) => {
|
|
44
|
+
const requests = [];
|
|
45
|
+
const server = createServer(async (request, response) => {
|
|
46
|
+
const url = new URL(request.url, 'http://localhost');
|
|
47
|
+
const raw = await readRequestBody(request);
|
|
48
|
+
let body;
|
|
49
|
+
try {
|
|
50
|
+
body = raw.length > 0 ? JSON.parse(raw.toString('utf8')) : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
body = undefined;
|
|
53
|
+
}
|
|
54
|
+
requests.push({
|
|
55
|
+
method: request.method,
|
|
56
|
+
pathname: url.pathname,
|
|
57
|
+
search: url.search,
|
|
58
|
+
headers: request.headers,
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
const send = (status, value) => {
|
|
62
|
+
response.statusCode = status;
|
|
63
|
+
response.setHeader('content-type', 'application/json');
|
|
64
|
+
response.end(JSON.stringify(value));
|
|
65
|
+
};
|
|
66
|
+
if (request.method === 'GET' && url.pathname === '/global/health') {
|
|
67
|
+
send(200, { healthy: true, version: 'test' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (request.method === 'GET' && url.pathname === '/config/providers') {
|
|
71
|
+
send(200, {
|
|
72
|
+
providers: [
|
|
73
|
+
{
|
|
74
|
+
id: 'openai',
|
|
75
|
+
models: {
|
|
76
|
+
'gpt-4': { id: 'gpt-4', capabilities: { input: { image: true } } },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
default: { openai: 'gpt-4' },
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (request.method === 'POST' && url.pathname === '/session') {
|
|
85
|
+
send(200, { id: 'session-1' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (request.method === 'POST' && url.pathname === '/session/session-1/message') {
|
|
89
|
+
if (messageHandler) {
|
|
90
|
+
await messageHandler(request, response, send);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
send(200, {
|
|
94
|
+
info: { modelID: 'gpt-4', time: { completed: 123 }, tokens: { input: 3, output: 4 } },
|
|
95
|
+
parts: [{ type: 'text', text: 'answer' }],
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (request.method === 'POST' && url.pathname === '/session/session-1/abort') {
|
|
100
|
+
send(200, true);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
send(404, { error: 'not found' });
|
|
104
|
+
});
|
|
105
|
+
const port = await listen(server);
|
|
106
|
+
return { url: `http://127.0.0.1:${port}`, requests, close: () => closeServer(server) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const createQueueFetch = (responses, requests) => async (url, init) => {
|
|
110
|
+
const parsed = new URL(url);
|
|
111
|
+
const key = `${init.method} ${parsed.pathname}`;
|
|
112
|
+
requests.push({
|
|
113
|
+
method: init.method,
|
|
114
|
+
pathname: parsed.pathname,
|
|
115
|
+
headers: init.headers,
|
|
116
|
+
body: init.body,
|
|
117
|
+
});
|
|
118
|
+
if (!(key in responses)) throw new Error(`unexpected queue request ${key}`);
|
|
119
|
+
return new Response(JSON.stringify(responses[key]), {
|
|
120
|
+
status: 200,
|
|
121
|
+
headers: { 'content-type': 'application/json' },
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const textModel = {
|
|
126
|
+
id: 'openai/gpt-4',
|
|
127
|
+
inputModalities: ['text'],
|
|
128
|
+
outputModalities: ['text'],
|
|
129
|
+
supportedReasoningEfforts: ['medium'],
|
|
130
|
+
defaultReasoningEffort: 'medium',
|
|
131
|
+
prefer: ['quality'],
|
|
132
|
+
isDefault: true,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const textEnvelope = {
|
|
136
|
+
version: 1,
|
|
137
|
+
input: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
138
|
+
output: ['text'],
|
|
139
|
+
model: 'openai/gpt-4',
|
|
140
|
+
reasoningEffort: 'medium',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const imageModel = {
|
|
144
|
+
...textModel,
|
|
145
|
+
outputModalities: ['text', 'image'],
|
|
146
|
+
imageGeneration: true,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const imageEnvelope = {
|
|
150
|
+
...textEnvelope,
|
|
151
|
+
input: [{ role: 'user', content: [{ type: 'text', text: 'draw a tree' }] }],
|
|
152
|
+
output: ['text', 'image'],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
test('executor creates a session and sends the prompt with model and json_schema', async () => {
|
|
156
|
+
const opencode = await startFakeOpenCode();
|
|
157
|
+
try {
|
|
158
|
+
const executor = await createOpenCodeExecutor({
|
|
159
|
+
baseUrl: opencode.url,
|
|
160
|
+
password: 'secret',
|
|
161
|
+
});
|
|
162
|
+
const result = await executor.execute({
|
|
163
|
+
instructions: 'be brief',
|
|
164
|
+
input: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
165
|
+
output: ['text'],
|
|
166
|
+
model: 'openai/gpt-4',
|
|
167
|
+
outputSchema: { name: 'answer', schema: { type: 'object' } },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const session = opencode.requests.find((request) => request.pathname === '/session');
|
|
171
|
+
assert.ok(session);
|
|
172
|
+
assert.equal(
|
|
173
|
+
session.headers.authorization,
|
|
174
|
+
`Basic ${Buffer.from('opencode:secret').toString('base64')}`,
|
|
175
|
+
);
|
|
176
|
+
const message = opencode.requests.find(
|
|
177
|
+
(request) => request.pathname === '/session/session-1/message',
|
|
178
|
+
);
|
|
179
|
+
assert.ok(message);
|
|
180
|
+
assert.deepEqual(message.body.model, { providerID: 'openai', modelID: 'gpt-4' });
|
|
181
|
+
assert.deepEqual(message.body.format, {
|
|
182
|
+
type: 'json_schema',
|
|
183
|
+
schema: { type: 'object' },
|
|
184
|
+
retryCount: 2,
|
|
185
|
+
});
|
|
186
|
+
assert.deepEqual(message.body.parts[0], { type: 'text', text: 'be brief' });
|
|
187
|
+
assert.deepEqual(message.body.parts[1], { type: 'text', text: 'hi' });
|
|
188
|
+
|
|
189
|
+
assert.deepEqual(result.output, [{ type: 'text', text: 'answer' }]);
|
|
190
|
+
assert.equal(result.model, 'openai/gpt-4');
|
|
191
|
+
assert.equal(result.generatedAt, 123);
|
|
192
|
+
assert.deepEqual(result.usage, { inputTokens: 3, outputTokens: 4, totalTokens: 7 });
|
|
193
|
+
} finally {
|
|
194
|
+
await opencode.close();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('executor maps base64 media input to OpenCode file parts and lists provider models', async () => {
|
|
199
|
+
const opencode = await startFakeOpenCode();
|
|
200
|
+
try {
|
|
201
|
+
const executor = await createOpenCodeExecutor({ baseUrl: opencode.url, password: 'secret' });
|
|
202
|
+
await executor.execute({
|
|
203
|
+
input: [
|
|
204
|
+
{
|
|
205
|
+
role: 'user',
|
|
206
|
+
content: [
|
|
207
|
+
{ type: 'text', text: 'describe' },
|
|
208
|
+
{ type: 'image', mimeType: 'image/png', data: PNG_BASE64, name: 'ref.png' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
output: ['text'],
|
|
213
|
+
model: 'openai/gpt-4',
|
|
214
|
+
});
|
|
215
|
+
const message = opencode.requests.find(
|
|
216
|
+
(request) => request.pathname === '/session/session-1/message',
|
|
217
|
+
);
|
|
218
|
+
assert.deepEqual(message.body.parts[1], {
|
|
219
|
+
type: 'file',
|
|
220
|
+
mime: 'image/png',
|
|
221
|
+
filename: 'ref.png',
|
|
222
|
+
url: `data:image/png;base64,${PNG_BASE64}`,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const models = await executor.listModels();
|
|
226
|
+
assert.deepEqual(models, [
|
|
227
|
+
{
|
|
228
|
+
id: 'openai/gpt-4',
|
|
229
|
+
inputModalities: ['text', 'image'],
|
|
230
|
+
outputModalities: ['text'],
|
|
231
|
+
supportedReasoningEfforts: ['medium'],
|
|
232
|
+
defaultReasoningEffort: 'medium',
|
|
233
|
+
webSearch: false,
|
|
234
|
+
prefer: ['quality'],
|
|
235
|
+
isDefault: true,
|
|
236
|
+
imageGeneration: false,
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
} finally {
|
|
240
|
+
await opencode.close();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('executor aborts the session when the turn is cancelled', async () => {
|
|
245
|
+
const opencode = await startFakeOpenCode({
|
|
246
|
+
messageHandler: async (request) => {
|
|
247
|
+
await new Promise((resolve) => request.on('close', resolve));
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
const executor = await createOpenCodeExecutor({ baseUrl: opencode.url, password: 'secret' });
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const pending = executor.execute(
|
|
254
|
+
{
|
|
255
|
+
input: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
256
|
+
output: ['text'],
|
|
257
|
+
model: 'openai/gpt-4',
|
|
258
|
+
},
|
|
259
|
+
{ signal: controller.signal },
|
|
260
|
+
);
|
|
261
|
+
await waitFor(() =>
|
|
262
|
+
opencode.requests.some((request) => request.pathname === '/session/session-1/message'),
|
|
263
|
+
);
|
|
264
|
+
controller.abort();
|
|
265
|
+
await assert.rejects(pending);
|
|
266
|
+
await waitFor(() =>
|
|
267
|
+
opencode.requests.some((request) => request.pathname === '/session/session-1/abort'),
|
|
268
|
+
);
|
|
269
|
+
} finally {
|
|
270
|
+
await opencode.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('executor delegates image output to the image adapter', async () => {
|
|
275
|
+
const opencode = await startFakeOpenCode();
|
|
276
|
+
const generated = [];
|
|
277
|
+
const executor = await createOpenCodeExecutor({
|
|
278
|
+
baseUrl: opencode.url,
|
|
279
|
+
password: 'secret',
|
|
280
|
+
imageAdapter: {
|
|
281
|
+
name: 'fake',
|
|
282
|
+
generate: async (request) => {
|
|
283
|
+
generated.push(request);
|
|
284
|
+
return { type: 'image', mimeType: 'image/png', data: PNG_BASE64, name: 'image-0.png' };
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
const result = await executor.execute({
|
|
290
|
+
input: [{ role: 'user', content: [{ type: 'text', text: 'draw a tree' }] }],
|
|
291
|
+
output: ['text', 'image'],
|
|
292
|
+
model: 'openai/gpt-4',
|
|
293
|
+
});
|
|
294
|
+
assert.deepEqual(result.output, [
|
|
295
|
+
{ type: 'text', text: 'answer' },
|
|
296
|
+
{ type: 'image', mimeType: 'image/png', data: PNG_BASE64, name: 'image-0.png' },
|
|
297
|
+
]);
|
|
298
|
+
assert.equal(generated.length, 1);
|
|
299
|
+
assert.equal(generated[0].prompt, 'draw a tree');
|
|
300
|
+
} finally {
|
|
301
|
+
await opencode.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('queue client reports job traffic and never reports polling or heartbeats', async () => {
|
|
306
|
+
const events = [];
|
|
307
|
+
const requests = [];
|
|
308
|
+
const responses = {
|
|
309
|
+
'POST /api/ai/runner/heartbeat': { ok: true },
|
|
310
|
+
'POST /api/ai/runner/claim': { job: null },
|
|
311
|
+
'POST /api/ai/runner/jobs/job-1/heartbeat': { cancelRequested: false, leaseExpiresAt: 2 },
|
|
312
|
+
'GET /api/ai/runner/jobs/job-1/input': {},
|
|
313
|
+
'POST /api/ai/runner/jobs/job-1/upload': { key: 'uploads/response.json' },
|
|
314
|
+
'POST /api/ai/runner/jobs/job-1/complete': { ok: true },
|
|
315
|
+
'POST /api/ai/runner/jobs/job-1/fail': { ok: true },
|
|
316
|
+
};
|
|
317
|
+
const client = new RunnerClient({
|
|
318
|
+
baseUrl: 'https://queue.example/api/ai/runner',
|
|
319
|
+
token: 'test-token',
|
|
320
|
+
runnerId: 'runner-1',
|
|
321
|
+
analytics: { emit: (...event) => events.push(event) },
|
|
322
|
+
fetchImpl: createQueueFetch(responses, requests),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await client.heartbeat(0, true, true);
|
|
326
|
+
await client.claim();
|
|
327
|
+
await client.leaseHeartbeat('job-1', 'lease-token');
|
|
328
|
+
await client.input('job-1', 'lease-token');
|
|
329
|
+
|
|
330
|
+
assert.deepEqual(events, [
|
|
331
|
+
['runner-http', 'input', 'get', 'requested'],
|
|
332
|
+
['runner-http', 'input', 'get', 'responded', 200],
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
events.length = 0;
|
|
336
|
+
await client.upload(
|
|
337
|
+
'job-1',
|
|
338
|
+
'lease-token',
|
|
339
|
+
Buffer.from('{}'),
|
|
340
|
+
'response.json',
|
|
341
|
+
'application/json',
|
|
342
|
+
);
|
|
343
|
+
await client.complete('job-1', 'lease-token', { responseKey: 'uploads/response.json' });
|
|
344
|
+
await client.fail('job-1', 'lease-token', 'boom', 'failed');
|
|
345
|
+
assert.deepEqual(
|
|
346
|
+
events.filter((event) => event[3] === 'requested').map((event) => event[1]),
|
|
347
|
+
['upload', 'complete', 'fail'],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const leaseRequest = requests.find(
|
|
351
|
+
(request) => request.pathname === '/api/ai/runner/jobs/job-1/heartbeat',
|
|
352
|
+
);
|
|
353
|
+
assert.equal(leaseRequest.headers['x-ai-lease-token'], 'lease-token');
|
|
354
|
+
const uploadRequest = requests.find(
|
|
355
|
+
(request) => request.pathname === '/api/ai/runner/jobs/job-1/upload',
|
|
356
|
+
);
|
|
357
|
+
assert.equal(uploadRequest.headers['x-ai-result-name'], 'response.json');
|
|
358
|
+
assert.equal(uploadRequest.headers['x-ai-lease-token'], 'lease-token');
|
|
359
|
+
const failRequest = requests.find(
|
|
360
|
+
(request) => request.pathname === '/api/ai/runner/jobs/job-1/fail',
|
|
361
|
+
);
|
|
362
|
+
assert.deepEqual(JSON.parse(failRequest.body.toString('utf8')), {
|
|
363
|
+
error: 'boom',
|
|
364
|
+
outcome: 'failed',
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('processJob uploads the response and image artifacts then completes', async () => {
|
|
369
|
+
const workspace = await mkdtemp(path.join(tmpdir(), 'ag-ai-bridge-ws-'));
|
|
370
|
+
const mainWorkspace = await mkdtemp(path.join(tmpdir(), 'ag-ai-bridge-main-'));
|
|
371
|
+
try {
|
|
372
|
+
const uploads = [];
|
|
373
|
+
let completed;
|
|
374
|
+
const fails = [];
|
|
375
|
+
const runner = {
|
|
376
|
+
input: async () => imageEnvelope,
|
|
377
|
+
leaseHeartbeat: async () => ({ cancelRequested: false, leaseExpiresAt: Date.now() + 60_000 }),
|
|
378
|
+
upload: async (_id, _token, bytes, name) => {
|
|
379
|
+
uploads.push({ name, bytes });
|
|
380
|
+
return { key: `uploads/${name}` };
|
|
381
|
+
},
|
|
382
|
+
complete: async (_id, _token, payload) => {
|
|
383
|
+
completed = payload;
|
|
384
|
+
},
|
|
385
|
+
fail: async (_id, _token, message, outcome) => {
|
|
386
|
+
fails.push({ message, outcome });
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
const state = {
|
|
390
|
+
health: { models: [imageModel] },
|
|
391
|
+
executor: {
|
|
392
|
+
execute: async () => ({
|
|
393
|
+
output: [
|
|
394
|
+
{ type: 'text', text: 'done' },
|
|
395
|
+
{ type: 'image', mimeType: 'image/png', data: PNG_BASE64 },
|
|
396
|
+
],
|
|
397
|
+
model: 'openai/gpt-4',
|
|
398
|
+
generatedAt: 7,
|
|
399
|
+
}),
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
const job = {
|
|
403
|
+
id: 'job-1',
|
|
404
|
+
runId: 'run-1',
|
|
405
|
+
leaseToken: 'lease',
|
|
406
|
+
leaseExpiresAt: Date.now() + 60_000,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
await processJob(runner, job, { workspace, mainWorkspace }, state, 'cloud', {
|
|
410
|
+
emit: () => Promise.resolve(true),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
assert.deepEqual(
|
|
414
|
+
uploads.map((upload) => upload.name),
|
|
415
|
+
['response.json', 'image-0.png'],
|
|
416
|
+
);
|
|
417
|
+
const response = JSON.parse(uploads[0].bytes.toString('utf8'));
|
|
418
|
+
assert.deepEqual(response.output, [
|
|
419
|
+
{ type: 'text', text: 'done' },
|
|
420
|
+
{ type: 'image', mimeType: 'image/png', name: 'image-0.png' },
|
|
421
|
+
]);
|
|
422
|
+
assert.equal(response.model, 'openai/gpt-4');
|
|
423
|
+
assert.equal(response.generatedAt, 7);
|
|
424
|
+
assert.deepEqual(completed, {
|
|
425
|
+
responseKey: 'uploads/response.json',
|
|
426
|
+
imageKeys: ['uploads/image-0.png'],
|
|
427
|
+
});
|
|
428
|
+
assert.equal(fails.length, 0);
|
|
429
|
+
} finally {
|
|
430
|
+
await rm(workspace, { recursive: true, force: true });
|
|
431
|
+
await rm(mainWorkspace, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('processJob reports an uncertain lost turn as unknown and never replays it', async () => {
|
|
436
|
+
const workspace = await mkdtemp(path.join(tmpdir(), 'ag-ai-bridge-ws-'));
|
|
437
|
+
const mainWorkspace = await mkdtemp(path.join(tmpdir(), 'ag-ai-bridge-main-'));
|
|
438
|
+
try {
|
|
439
|
+
let executeCalls = 0;
|
|
440
|
+
const uploads = [];
|
|
441
|
+
const fails = [];
|
|
442
|
+
const runner = {
|
|
443
|
+
input: async () => textEnvelope,
|
|
444
|
+
leaseHeartbeat: async () => ({ cancelRequested: false, leaseExpiresAt: Date.now() + 60_000 }),
|
|
445
|
+
upload: async (_id, _token, bytes, name) => {
|
|
446
|
+
uploads.push(name);
|
|
447
|
+
return { key: `uploads/${name}` };
|
|
448
|
+
},
|
|
449
|
+
complete: async () => undefined,
|
|
450
|
+
fail: async (_id, _token, message, outcome) => {
|
|
451
|
+
fails.push({ message, outcome });
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
const state = {
|
|
455
|
+
health: { models: [textModel] },
|
|
456
|
+
executor: {
|
|
457
|
+
execute: async () => {
|
|
458
|
+
executeCalls += 1;
|
|
459
|
+
throw new UncertainRequestError('model turn was lost');
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
const job = {
|
|
464
|
+
id: 'job-2',
|
|
465
|
+
runId: 'run-2',
|
|
466
|
+
leaseToken: 'lease',
|
|
467
|
+
leaseExpiresAt: Date.now() + 60_000,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
await assert.rejects(
|
|
471
|
+
processJob(runner, job, { workspace, mainWorkspace }, state, 'cloud', {
|
|
472
|
+
emit: () => Promise.resolve(true),
|
|
473
|
+
}),
|
|
474
|
+
/model turn was lost/,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
assert.equal(executeCalls, 1);
|
|
478
|
+
assert.equal(uploads.length, 0);
|
|
479
|
+
assert.equal(fails.length, 1);
|
|
480
|
+
assert.equal(fails[0].outcome, 'unknown');
|
|
481
|
+
} finally {
|
|
482
|
+
await rm(workspace, { recursive: true, force: true });
|
|
483
|
+
await rm(mainWorkspace, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('readConfig reads the AG_AI_* environment and validates the queue URL', async () => {
|
|
488
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ag-ai-bridge-config-'));
|
|
489
|
+
try {
|
|
490
|
+
const runnerToken = path.join(root, 'runner-token');
|
|
491
|
+
const analyticaToken = path.join(root, 'analytica-token');
|
|
492
|
+
await writeFile(runnerToken, 'runner-secret\n');
|
|
493
|
+
await writeFile(analyticaToken, 'analytica-secret\n');
|
|
494
|
+
const config = await readConfig({
|
|
495
|
+
AG_AI_RUNNER_TOKEN_FILE: runnerToken,
|
|
496
|
+
AG_AI_ANALYTICA_TOKEN_FILE: analyticaToken,
|
|
497
|
+
AG_AI_QUEUE_URL: 'https://queue.example/api/ai/runner',
|
|
498
|
+
AG_AI_OPENCODE_URL: 'http://127.0.0.1:5000',
|
|
499
|
+
AG_AI_OPENCODE_USERNAME: 'worker',
|
|
500
|
+
AG_AI_OPENCODE_PASSWORD_FILE: path.join(root, 'opencode-password'),
|
|
501
|
+
AG_AI_OPENCODE_PROVIDER: 'openai',
|
|
502
|
+
AG_AI_IMAGE_PROVIDER: 'openai',
|
|
503
|
+
AG_AI_RUNNER_ID: 'runner-42',
|
|
504
|
+
AG_AI_WORKSPACE: path.join(root, 'workspace'),
|
|
505
|
+
AG_AI_MAIN_WORKSPACE: path.join(root, 'main'),
|
|
506
|
+
AG_AI_GENERATION_STORE: path.join(root, 'store'),
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
assert.equal(config.token, 'runner-secret');
|
|
510
|
+
assert.equal(config.analyticaToken, 'analytica-secret');
|
|
511
|
+
assert.equal(config.runnerId, 'runner-42');
|
|
512
|
+
assert.equal(config.queueUrl, 'https://queue.example/api/ai/runner');
|
|
513
|
+
assert.equal(config.opencodeUrl, 'http://127.0.0.1:5000');
|
|
514
|
+
assert.equal(config.opencodeUsername, 'worker');
|
|
515
|
+
assert.equal(config.opencodeProvider, 'openai');
|
|
516
|
+
assert.equal(config.imageProvider, 'openai');
|
|
517
|
+
assert.equal(config.workspace, path.resolve(path.join(root, 'workspace')));
|
|
518
|
+
assert.equal(config.generationStore, path.resolve(path.join(root, 'store')));
|
|
519
|
+
|
|
520
|
+
assert.throws(() => normalizeBaseUrl('http://queue.example'), /https/);
|
|
521
|
+
assert.throws(() => normalizeBaseUrl('https://user:pass@queue.example'), /credentials/);
|
|
522
|
+
assert.equal(
|
|
523
|
+
normalizeBaseUrl('https://queue.example/anything/else').toString(),
|
|
524
|
+
'https://queue.example/anything/else',
|
|
525
|
+
);
|
|
526
|
+
} finally {
|
|
527
|
+
await rm(root, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('validateJobEnvelope accepts a complete lease and rejects unsafe metadata', () => {
|
|
532
|
+
const envelope = validateJobEnvelope({
|
|
533
|
+
id: 'job-1',
|
|
534
|
+
runId: 'run-1',
|
|
535
|
+
leaseToken: 'lease',
|
|
536
|
+
leaseExpiresAt: 123,
|
|
537
|
+
model: 'openai/gpt-4',
|
|
538
|
+
reasoningEffort: 'medium',
|
|
539
|
+
});
|
|
540
|
+
assert.deepEqual(envelope, {
|
|
541
|
+
id: 'job-1',
|
|
542
|
+
runId: 'run-1',
|
|
543
|
+
leaseToken: 'lease',
|
|
544
|
+
leaseExpiresAt: 123,
|
|
545
|
+
model: 'openai/gpt-4',
|
|
546
|
+
reasoningEffort: 'medium',
|
|
547
|
+
});
|
|
548
|
+
assert.throws(() =>
|
|
549
|
+
validateJobEnvelope({ id: '../escape', runId: 'run-1', leaseToken: 'l', leaseExpiresAt: 1 }),
|
|
550
|
+
);
|
|
551
|
+
assert.throws(() =>
|
|
552
|
+
validateJobEnvelope({ id: 'job-1', runId: 'run-1', leaseToken: '', leaseExpiresAt: 1 }),
|
|
553
|
+
);
|
|
554
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ImageAdapterError,
|
|
7
|
+
createImageAdapter,
|
|
8
|
+
createOpenAIImageAdapter,
|
|
9
|
+
} from '../image/adapter.mjs';
|
|
10
|
+
|
|
11
|
+
const PNG_BASE64 = 'iVBORw0KGgo=';
|
|
12
|
+
|
|
13
|
+
const jsonResponse = (value, status = 200) =>
|
|
14
|
+
new Response(JSON.stringify(value), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('sends the configured model and prompt and returns a PNG image part', async () => {
|
|
20
|
+
const requests = [];
|
|
21
|
+
const adapter = createOpenAIImageAdapter({
|
|
22
|
+
apiKey: 'test-key',
|
|
23
|
+
fetch: async (url, init) => {
|
|
24
|
+
requests.push({
|
|
25
|
+
url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url,
|
|
26
|
+
init,
|
|
27
|
+
});
|
|
28
|
+
return jsonResponse({ data: [{ b64_json: PNG_BASE64 }] });
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const part = await adapter.generate({ prompt: 'a lighthouse', name: 'hero.png' });
|
|
33
|
+
|
|
34
|
+
assert.equal(requests.length, 1);
|
|
35
|
+
assert.equal(requests[0].url, 'https://api.openai.com/v1/images/generations');
|
|
36
|
+
assert.equal(requests[0].init.method, 'POST');
|
|
37
|
+
assert.equal(requests[0].init.headers.authorization, 'Bearer test-key');
|
|
38
|
+
const body = JSON.parse(requests[0].init.body);
|
|
39
|
+
assert.equal(body.model, 'gpt-image-1');
|
|
40
|
+
assert.equal(body.prompt, 'a lighthouse');
|
|
41
|
+
assert.equal(body.n, 1);
|
|
42
|
+
assert.deepEqual(part, {
|
|
43
|
+
type: 'image',
|
|
44
|
+
mimeType: 'image/png',
|
|
45
|
+
data: PNG_BASE64,
|
|
46
|
+
name: 'hero.png',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('honors a model override and request size and quality', async () => {
|
|
51
|
+
let captured;
|
|
52
|
+
const adapter = createOpenAIImageAdapter({
|
|
53
|
+
apiKey: 'test-key',
|
|
54
|
+
model: 'gpt-image-1-mini',
|
|
55
|
+
fetch: async (_url, init) => {
|
|
56
|
+
captured = JSON.parse(init.body);
|
|
57
|
+
return jsonResponse({ data: [{ b64_json: PNG_BASE64 }] });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await adapter.generate({ prompt: 'x', size: '1024x1024', quality: 'high' });
|
|
62
|
+
assert.equal(captured.model, 'gpt-image-1-mini');
|
|
63
|
+
assert.equal(captured.size, '1024x1024');
|
|
64
|
+
assert.equal(captured.quality, 'high');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('rejects input reference images clearly instead of ignoring them', async () => {
|
|
68
|
+
let called = false;
|
|
69
|
+
const adapter = createOpenAIImageAdapter({
|
|
70
|
+
apiKey: 'test-key',
|
|
71
|
+
fetch: async () => {
|
|
72
|
+
called = true;
|
|
73
|
+
return jsonResponse({ data: [{ b64_json: PNG_BASE64 }] });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await assert.rejects(
|
|
78
|
+
adapter.generate({
|
|
79
|
+
prompt: 'edit this',
|
|
80
|
+
images: [{ mimeType: 'image/png', data: PNG_BASE64 }],
|
|
81
|
+
}),
|
|
82
|
+
/does not accept input reference images/,
|
|
83
|
+
);
|
|
84
|
+
assert.equal(called, false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('honors an abort signal', async () => {
|
|
88
|
+
const adapter = createOpenAIImageAdapter({
|
|
89
|
+
apiKey: 'test-key',
|
|
90
|
+
fetch: async (_url, init) =>
|
|
91
|
+
new Promise((_resolve, reject) => {
|
|
92
|
+
init.signal.addEventListener('abort', () =>
|
|
93
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
|
94
|
+
);
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const pending = adapter.generate({ prompt: 'x', signal: controller.signal });
|
|
100
|
+
controller.abort();
|
|
101
|
+
await assert.rejects(pending);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('rejects an oversized provider response before parsing it', async () => {
|
|
105
|
+
const adapter = createOpenAIImageAdapter({
|
|
106
|
+
apiKey: 'test-key',
|
|
107
|
+
fetch: async () =>
|
|
108
|
+
new Response('{}', {
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: { 'content-length': String(64 * 1024 * 1024 + 1) },
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await assert.rejects(adapter.generate({ prompt: 'x' }), /exceeds its size limit/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('rejects non-PNG and invalid provider content', async () => {
|
|
118
|
+
const notPng = createOpenAIImageAdapter({
|
|
119
|
+
apiKey: 'test-key',
|
|
120
|
+
fetch: async () => jsonResponse({ data: [{ b64_json: 'AAAA' }] }),
|
|
121
|
+
});
|
|
122
|
+
await assert.rejects(notPng.generate({ prompt: 'x' }), /not a PNG/);
|
|
123
|
+
|
|
124
|
+
const invalid = createOpenAIImageAdapter({
|
|
125
|
+
apiKey: 'test-key',
|
|
126
|
+
fetch: async () => jsonResponse({ data: [{ b64_json: 'not base64!!' }] }),
|
|
127
|
+
});
|
|
128
|
+
await assert.rejects(invalid.generate({ prompt: 'x' }), ImageAdapterError);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('surfaces provider HTTP failures with their status', async () => {
|
|
132
|
+
const adapter = createOpenAIImageAdapter({
|
|
133
|
+
apiKey: 'test-key',
|
|
134
|
+
fetch: async () => jsonResponse({ error: 'nope' }, 401),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await assert.rejects(adapter.generate({ prompt: 'x' }), (error) => {
|
|
138
|
+
assert(error instanceof ImageAdapterError);
|
|
139
|
+
assert.equal(error.status, 401);
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('selects a provider by name and rejects unknown providers', () => {
|
|
145
|
+
const adapter = createImageAdapter({ provider: 'openai', apiKey: 'test-key' });
|
|
146
|
+
assert.equal(adapter.name, 'openai');
|
|
147
|
+
assert.throws(() => createImageAdapter({ provider: 'unknown', apiKey: 'k' }), ImageAdapterError);
|
|
148
|
+
});
|