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,430 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OPENCODE_URL = 'http://127.0.0.1:4096';
|
|
4
|
+
const DEFAULT_OPENCODE_USERNAME = 'opencode';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
6
|
+
const DEFAULT_REASONING_EFFORT = 'medium';
|
|
7
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
const MAX_ERROR_BYTES = 16 * 1024;
|
|
9
|
+
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} Executor
|
|
13
|
+
* @property {string} name Executor identifier.
|
|
14
|
+
* @property {(request: object, options?: { signal?: AbortSignal, directory?: string }) =>
|
|
15
|
+
* Promise<{ output: object[], model: string, generatedAt: number, usage?: object }>} execute
|
|
16
|
+
* Run one canonical generation request.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class ExecutorError extends Error {
|
|
20
|
+
constructor(message, { status } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ExecutorError';
|
|
23
|
+
if (status !== undefined) this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const asObject = (value) => (value && typeof value === 'object' ? value : null);
|
|
28
|
+
|
|
29
|
+
const boundedString = (value, maximum, name) => {
|
|
30
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
31
|
+
throw new ExecutorError(`${name} is invalid`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readResponseBytes = async (response, maximum) => {
|
|
37
|
+
const contentLength = Number(response.headers.get('content-length'));
|
|
38
|
+
if (Number.isFinite(contentLength) && contentLength > maximum) {
|
|
39
|
+
throw new ExecutorError('OpenCode response exceeds its size limit');
|
|
40
|
+
}
|
|
41
|
+
if (!response.body) return Buffer.alloc(0);
|
|
42
|
+
const reader = response.body.getReader();
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
// eslint-disable-next-line no-await-in-loop -- response bytes must be bounded sequentially.
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done) break;
|
|
50
|
+
const chunk = Buffer.from(value);
|
|
51
|
+
total += chunk.length;
|
|
52
|
+
if (total > maximum) {
|
|
53
|
+
// eslint-disable-next-line no-await-in-loop -- cancelling the reader is part of the bounded read.
|
|
54
|
+
await reader.cancel();
|
|
55
|
+
throw new ExecutorError('OpenCode response exceeds its size limit');
|
|
56
|
+
}
|
|
57
|
+
chunks.push(chunk);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
reader.releaseLock();
|
|
61
|
+
}
|
|
62
|
+
return Buffer.concat(chunks, total);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const combineSignals = (signal, timeoutMs) => {
|
|
66
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
67
|
+
return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const normalizeBaseUrl = (value) => {
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(value);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new ExecutorError('AG_AI_OPENCODE_URL must be an absolute URL');
|
|
76
|
+
}
|
|
77
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
78
|
+
throw new ExecutorError('AG_AI_OPENCODE_URL must use http or https');
|
|
79
|
+
}
|
|
80
|
+
return url.toString().replace(/\/+$/, '');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const basicAuth = (username, password) =>
|
|
84
|
+
`Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`;
|
|
85
|
+
|
|
86
|
+
const dataUrl = (mimeType, data) => `data:${mimeType};base64,${data}`;
|
|
87
|
+
|
|
88
|
+
const modelReference = (model, providerID) => {
|
|
89
|
+
if (model === undefined) return undefined;
|
|
90
|
+
if (typeof model !== 'string' || model.trim().length === 0) {
|
|
91
|
+
throw new ExecutorError('OpenCode model is invalid');
|
|
92
|
+
}
|
|
93
|
+
const normalized = model.trim();
|
|
94
|
+
if (normalized.includes('/')) {
|
|
95
|
+
const separator = normalized.indexOf('/');
|
|
96
|
+
const provider = normalized.slice(0, separator);
|
|
97
|
+
const modelID = normalized.slice(separator + 1);
|
|
98
|
+
if (provider.length === 0 || modelID.length === 0) {
|
|
99
|
+
throw new ExecutorError('OpenCode model must be "provider/model"');
|
|
100
|
+
}
|
|
101
|
+
return { providerID: provider, modelID };
|
|
102
|
+
}
|
|
103
|
+
if (providerID === undefined) {
|
|
104
|
+
throw new ExecutorError('OpenCode model must be "provider/model"');
|
|
105
|
+
}
|
|
106
|
+
return { providerID, modelID: normalized };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const partsFromInput = (request) => {
|
|
110
|
+
const parts = [];
|
|
111
|
+
if (typeof request.instructions === 'string' && request.instructions.length > 0) {
|
|
112
|
+
parts.push({ type: 'text', text: request.instructions });
|
|
113
|
+
}
|
|
114
|
+
const input = Array.isArray(request.input) ? request.input : [];
|
|
115
|
+
for (const message of input) {
|
|
116
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
117
|
+
for (const part of content) {
|
|
118
|
+
if (part?.type === 'text') {
|
|
119
|
+
parts.push({ type: 'text', text: boundedString(part.text, MAX_TEXT_BYTES, 'input text') });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
part?.type === 'image' ||
|
|
124
|
+
part?.type === 'audio' ||
|
|
125
|
+
part?.type === 'video' ||
|
|
126
|
+
part?.type === 'file'
|
|
127
|
+
) {
|
|
128
|
+
const mimeType = boundedString(part.mimeType, 160, 'input mimeType');
|
|
129
|
+
const data = boundedString(part.data, MAX_RESPONSE_BYTES, 'input data');
|
|
130
|
+
parts.push({
|
|
131
|
+
type: 'file',
|
|
132
|
+
mime: mimeType,
|
|
133
|
+
...(typeof part.name === 'string' && part.name.length > 0 ? { filename: part.name } : {}),
|
|
134
|
+
url: dataUrl(mimeType, data),
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
throw new ExecutorError('input content part type is unsupported');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (parts.length === 0) throw new ExecutorError('OpenCode request has no input parts');
|
|
142
|
+
return parts;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const promptText = (request) => {
|
|
146
|
+
const text = [];
|
|
147
|
+
if (typeof request.instructions === 'string' && request.instructions.length > 0) {
|
|
148
|
+
text.push(request.instructions);
|
|
149
|
+
}
|
|
150
|
+
const input = Array.isArray(request.input) ? request.input : [];
|
|
151
|
+
for (const message of input) {
|
|
152
|
+
for (const part of Array.isArray(message?.content) ? message.content : []) {
|
|
153
|
+
if (part?.type === 'text' && typeof part.text === 'string') text.push(part.text);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return text.join('\n').trim();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const referenceImages = (request) => {
|
|
160
|
+
const images = [];
|
|
161
|
+
const input = Array.isArray(request.input) ? request.input : [];
|
|
162
|
+
for (const message of input) {
|
|
163
|
+
for (const part of Array.isArray(message?.content) ? message.content : []) {
|
|
164
|
+
if (part?.type === 'image') {
|
|
165
|
+
images.push({
|
|
166
|
+
mimeType: part.mimeType,
|
|
167
|
+
data: part.data,
|
|
168
|
+
...(typeof part.name === 'string' ? { name: part.name } : {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return images;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const textFromParts = (parts) => {
|
|
177
|
+
if (!Array.isArray(parts)) throw new ExecutorError('OpenCode returned invalid message parts');
|
|
178
|
+
return parts
|
|
179
|
+
.filter((part) => asObject(part)?.type === 'text' && typeof part.text === 'string')
|
|
180
|
+
.map((part) => part.text)
|
|
181
|
+
.join('');
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const usageFromTokens = (tokens) => {
|
|
185
|
+
const usage = asObject(tokens);
|
|
186
|
+
if (!usage) return undefined;
|
|
187
|
+
const inputTokens = usage.input;
|
|
188
|
+
const outputTokens = usage.output;
|
|
189
|
+
if (!Number.isInteger(inputTokens) || inputTokens < 0) return undefined;
|
|
190
|
+
if (!Number.isInteger(outputTokens) || outputTokens < 0) return undefined;
|
|
191
|
+
return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create an executor backed by an OpenCode HTTP server. The server URL comes
|
|
196
|
+
* from `AG_AI_OPENCODE_URL`; the basic-auth password comes from
|
|
197
|
+
* `AG_AI_OPENCODE_PASSWORD_FILE` (username `opencode` unless
|
|
198
|
+
* `AG_AI_OPENCODE_USERNAME` is set). No SDK dependency is used.
|
|
199
|
+
*
|
|
200
|
+
* @param {{
|
|
201
|
+
* baseUrl?: string,
|
|
202
|
+
* username?: string,
|
|
203
|
+
* password?: string,
|
|
204
|
+
* passwordFile?: string,
|
|
205
|
+
* fetch?: typeof globalThis.fetch,
|
|
206
|
+
* imageAdapter?: import('../image/adapter.mjs').ImageAdapter,
|
|
207
|
+
* providerID?: string,
|
|
208
|
+
* timeoutMs?: number,
|
|
209
|
+
* now?: () => number,
|
|
210
|
+
* }} options
|
|
211
|
+
* @returns {Promise<Executor & {
|
|
212
|
+
* listModels: (options?: object) => Promise<object[]>,
|
|
213
|
+
* health: () => Promise<{ healthy: boolean, version?: string }>,
|
|
214
|
+
* }>}
|
|
215
|
+
*/
|
|
216
|
+
const createOpenCodeExecutor = async ({
|
|
217
|
+
baseUrl = process.env.AG_AI_OPENCODE_URL ?? DEFAULT_OPENCODE_URL,
|
|
218
|
+
username = process.env.AG_AI_OPENCODE_USERNAME ?? DEFAULT_OPENCODE_USERNAME,
|
|
219
|
+
password,
|
|
220
|
+
passwordFile = process.env.AG_AI_OPENCODE_PASSWORD_FILE,
|
|
221
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
222
|
+
imageAdapter,
|
|
223
|
+
providerID = process.env.AG_AI_OPENCODE_PROVIDER,
|
|
224
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
225
|
+
now = () => Date.now(),
|
|
226
|
+
} = {}) => {
|
|
227
|
+
if (typeof fetchImpl !== 'function') {
|
|
228
|
+
throw new ExecutorError('OpenCode fetch implementation is required');
|
|
229
|
+
}
|
|
230
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
231
|
+
let resolvedPassword = typeof password === 'string' && password.length > 0 ? password : undefined;
|
|
232
|
+
if (
|
|
233
|
+
resolvedPassword === undefined &&
|
|
234
|
+
typeof passwordFile === 'string' &&
|
|
235
|
+
passwordFile.length > 0
|
|
236
|
+
) {
|
|
237
|
+
try {
|
|
238
|
+
const value = (await readFile(passwordFile, 'utf8')).trim();
|
|
239
|
+
if (value.length > 0) resolvedPassword = value;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code !== 'ENOENT') {
|
|
242
|
+
throw new ExecutorError(
|
|
243
|
+
`OpenCode password file could not be read: ${error?.code ?? 'error'}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const headers = () => ({
|
|
250
|
+
accept: 'application/json',
|
|
251
|
+
...(resolvedPassword === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: { authorization: basicAuth(username, resolvedPassword) }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const request = async (method, pathname, { body, signal, directory, timeout } = {}) => {
|
|
257
|
+
const url = new URL(`${normalizedBaseUrl}${pathname}`);
|
|
258
|
+
if (typeof directory === 'string' && directory.length > 0) {
|
|
259
|
+
url.searchParams.set('directory', directory);
|
|
260
|
+
}
|
|
261
|
+
let response;
|
|
262
|
+
try {
|
|
263
|
+
response = await fetchImpl(url, {
|
|
264
|
+
method,
|
|
265
|
+
headers: {
|
|
266
|
+
...headers(),
|
|
267
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
268
|
+
},
|
|
269
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
270
|
+
redirect: 'error',
|
|
271
|
+
signal: combineSignals(signal, timeout ?? timeoutMs),
|
|
272
|
+
});
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (signal?.aborted) throw error;
|
|
275
|
+
const message = error instanceof Error ? error.message : 'request failed';
|
|
276
|
+
throw new ExecutorError(`OpenCode request failed: ${message}`);
|
|
277
|
+
}
|
|
278
|
+
const bytes = await readResponseBytes(
|
|
279
|
+
response,
|
|
280
|
+
response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_BYTES,
|
|
281
|
+
);
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
throw new ExecutorError(`OpenCode returned HTTP ${response.status}`, {
|
|
284
|
+
status: response.status,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (bytes.length === 0) return {};
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
290
|
+
} catch {
|
|
291
|
+
throw new ExecutorError('OpenCode returned invalid JSON');
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const abortSession = async (sessionId, directory) => {
|
|
296
|
+
try {
|
|
297
|
+
await request('POST', `/session/${encodeURIComponent(sessionId)}/abort`, {
|
|
298
|
+
directory,
|
|
299
|
+
timeout: 5_000,
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
// Best-effort cancellation; the original failure is preserved by the caller.
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const execute = async (request_, { signal, directory } = {}) => {
|
|
307
|
+
const value = asObject(request_);
|
|
308
|
+
if (!value) throw new ExecutorError('generation request is invalid');
|
|
309
|
+
const output = Array.isArray(value.output) ? value.output : ['text'];
|
|
310
|
+
const wantsText = output.includes('text');
|
|
311
|
+
const wantsImage = output.includes('image');
|
|
312
|
+
if (wantsImage && !imageAdapter) {
|
|
313
|
+
throw new ExecutorError('image output was requested but no image adapter is configured');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let text;
|
|
317
|
+
let generatedAt = now();
|
|
318
|
+
let usage;
|
|
319
|
+
let model = value.model;
|
|
320
|
+
|
|
321
|
+
if (wantsText) {
|
|
322
|
+
const session = asObject(await request('POST', '/session', { signal, directory }));
|
|
323
|
+
const sessionId = boundedString(session?.id, 160, 'OpenCode session id');
|
|
324
|
+
try {
|
|
325
|
+
const reference = modelReference(value.model, providerID);
|
|
326
|
+
const body = {
|
|
327
|
+
parts: partsFromInput(value),
|
|
328
|
+
...(reference === undefined ? {} : { model: reference }),
|
|
329
|
+
...(value.outputSchema === undefined
|
|
330
|
+
? {}
|
|
331
|
+
: {
|
|
332
|
+
format: {
|
|
333
|
+
type: 'json_schema',
|
|
334
|
+
schema: value.outputSchema.schema,
|
|
335
|
+
retryCount: 2,
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
const response = asObject(
|
|
340
|
+
await request('POST', `/session/${encodeURIComponent(sessionId)}/message`, {
|
|
341
|
+
body,
|
|
342
|
+
signal,
|
|
343
|
+
directory,
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
const info = asObject(response?.info);
|
|
347
|
+
if (info?.error) {
|
|
348
|
+
const message = asObject(info.error)?.data;
|
|
349
|
+
throw new ExecutorError(
|
|
350
|
+
`OpenCode turn failed: ${asObject(message)?.message ?? info.error.name ?? 'unknown error'}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const structured = info?.structured_output;
|
|
354
|
+
text =
|
|
355
|
+
structured === undefined ? textFromParts(response?.parts) : JSON.stringify(structured);
|
|
356
|
+
model = value.model ?? info?.modelID;
|
|
357
|
+
const completed = info?.time?.completed;
|
|
358
|
+
generatedAt = Number.isInteger(completed) ? completed : generatedAt;
|
|
359
|
+
usage = usageFromTokens(info?.tokens);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
await abortSession(sessionId, directory);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const outputParts = [];
|
|
367
|
+
if (wantsText) {
|
|
368
|
+
outputParts.push({ type: 'text', text: text ?? '' });
|
|
369
|
+
}
|
|
370
|
+
if (wantsImage) {
|
|
371
|
+
const image = await imageAdapter.generate({
|
|
372
|
+
prompt: promptText(value),
|
|
373
|
+
images: referenceImages(value),
|
|
374
|
+
signal,
|
|
375
|
+
name: 'image-0.png',
|
|
376
|
+
});
|
|
377
|
+
outputParts.push(image);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
output: outputParts,
|
|
381
|
+
model: model ?? 'unknown',
|
|
382
|
+
generatedAt,
|
|
383
|
+
...(usage === undefined ? {} : { usage }),
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const listModels = async () => {
|
|
388
|
+
const payload = asObject(await request('GET', '/config/providers'));
|
|
389
|
+
const providers = Array.isArray(payload?.providers) ? payload.providers : [];
|
|
390
|
+
const defaults = asObject(payload?.default) ?? {};
|
|
391
|
+
const models = [];
|
|
392
|
+
for (const provider of providers) {
|
|
393
|
+
const providerId = provider?.id;
|
|
394
|
+
const providerModels = asObject(provider?.models);
|
|
395
|
+
if (typeof providerId !== 'string' || !providerModels) continue;
|
|
396
|
+
for (const [key, model] of Object.entries(providerModels)) {
|
|
397
|
+
const modelId = typeof model?.id === 'string' && model.id.length > 0 ? model.id : key;
|
|
398
|
+
const inputModalities = ['text'];
|
|
399
|
+
if (model?.capabilities?.input?.image === true) inputModalities.push('image');
|
|
400
|
+
models.push({
|
|
401
|
+
id: `${providerId}/${modelId}`,
|
|
402
|
+
inputModalities,
|
|
403
|
+
outputModalities: ['text'],
|
|
404
|
+
supportedReasoningEfforts: [DEFAULT_REASONING_EFFORT],
|
|
405
|
+
defaultReasoningEffort: DEFAULT_REASONING_EFFORT,
|
|
406
|
+
webSearch: false,
|
|
407
|
+
prefer: ['quality'],
|
|
408
|
+
isDefault: defaults[providerId] === modelId,
|
|
409
|
+
imageGeneration: imageAdapter !== undefined,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return models;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const health = async () => {
|
|
417
|
+
const payload = asObject(await request('GET', '/global/health', { timeout: 15_000 }));
|
|
418
|
+
if (payload?.healthy !== true) {
|
|
419
|
+
throw new ExecutorError('OpenCode server is not healthy');
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
healthy: true,
|
|
423
|
+
...(typeof payload.version === 'string' ? { version: payload.version } : {}),
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
return { name: 'opencode', execute, listModels, health };
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
export { DEFAULT_OPENCODE_URL, DEFAULT_OPENCODE_USERNAME, ExecutorError, createOpenCodeExecutor };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const DEFAULT_OPENAI_IMAGE_MODEL = 'gpt-image-1';
|
|
2
|
+
const DEFAULT_OPENAI_IMAGE_URL = 'https://api.openai.com/v1/images/generations';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
4
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
6
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
7
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} ImageGenerationRequest
|
|
11
|
+
* @property {string} prompt Prompt describing the image to generate.
|
|
12
|
+
* @property {Array<{ mimeType: string, data: string, name?: string }>} [images] Optional
|
|
13
|
+
* base64 reference images. Providers that cannot accept references reject them.
|
|
14
|
+
* @property {AbortSignal} [signal] Cancels the generation request.
|
|
15
|
+
* @property {string} [size] Provider size token.
|
|
16
|
+
* @property {string} [quality] Provider quality token.
|
|
17
|
+
* @property {string} [name] Preferred artifact name.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} ImageAdapter
|
|
22
|
+
* @property {string} name Provider identifier.
|
|
23
|
+
* @property {(request: ImageGenerationRequest) => Promise<{
|
|
24
|
+
* type: 'image',
|
|
25
|
+
* mimeType: string,
|
|
26
|
+
* data: string,
|
|
27
|
+
* name?: string,
|
|
28
|
+
* }>} generate Generate a single image as a base64 AIPart.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
class ImageAdapterError extends Error {
|
|
32
|
+
constructor(message, { status } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'ImageAdapterError';
|
|
35
|
+
if (status !== undefined) this.status = status;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readResponseBytes = async (response, maximum) => {
|
|
40
|
+
const contentLength = Number(response.headers.get('content-length'));
|
|
41
|
+
if (Number.isFinite(contentLength) && contentLength > maximum) {
|
|
42
|
+
throw new ImageAdapterError('image provider response exceeds its size limit');
|
|
43
|
+
}
|
|
44
|
+
if (!response.body) return Buffer.alloc(0);
|
|
45
|
+
const reader = response.body.getReader();
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let total = 0;
|
|
48
|
+
try {
|
|
49
|
+
for (;;) {
|
|
50
|
+
// eslint-disable-next-line no-await-in-loop -- response bytes must be bounded sequentially.
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
const chunk = Buffer.from(value);
|
|
54
|
+
total += chunk.length;
|
|
55
|
+
if (total > maximum) {
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop -- cancelling the reader is part of the bounded read.
|
|
57
|
+
await reader.cancel();
|
|
58
|
+
throw new ImageAdapterError('image provider response exceeds its size limit');
|
|
59
|
+
}
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks, total);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const combineSignals = (signal, timeoutMs) => {
|
|
69
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
70
|
+
if (signal === undefined) return timeout;
|
|
71
|
+
return AbortSignal.any([signal, timeout]);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const decodeImage = (value, name) => {
|
|
75
|
+
if (typeof value !== 'string' || value.length === 0 || !BASE64.test(value)) {
|
|
76
|
+
throw new ImageAdapterError('image provider returned invalid base64 data');
|
|
77
|
+
}
|
|
78
|
+
const bytes = Buffer.from(value, 'base64');
|
|
79
|
+
if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES) {
|
|
80
|
+
throw new ImageAdapterError('image provider returned an image outside its size limit');
|
|
81
|
+
}
|
|
82
|
+
if (bytes.toString('base64') !== value) {
|
|
83
|
+
throw new ImageAdapterError('image provider returned non-canonical base64 data');
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
bytes.length < PNG_SIGNATURE.length ||
|
|
87
|
+
!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
|
|
88
|
+
) {
|
|
89
|
+
throw new ImageAdapterError('image provider returned data that is not a PNG');
|
|
90
|
+
}
|
|
91
|
+
return { type: 'image', mimeType: 'image/png', data: value, name };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* OpenAI Images adapter. Returns base64 PNG data as an AIPart. The OpenAI Images
|
|
96
|
+
* API does not accept input reference images, so requests carrying references
|
|
97
|
+
* are rejected rather than silently ignored.
|
|
98
|
+
*
|
|
99
|
+
* @param {{
|
|
100
|
+
* apiKey: string,
|
|
101
|
+
* fetch?: typeof globalThis.fetch,
|
|
102
|
+
* model?: string,
|
|
103
|
+
* url?: string,
|
|
104
|
+
* timeoutMs?: number,
|
|
105
|
+
* size?: string,
|
|
106
|
+
* quality?: string,
|
|
107
|
+
* }} options
|
|
108
|
+
* @returns {ImageAdapter}
|
|
109
|
+
*/
|
|
110
|
+
const createOpenAIImageAdapter = ({
|
|
111
|
+
apiKey,
|
|
112
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
113
|
+
model = DEFAULT_OPENAI_IMAGE_MODEL,
|
|
114
|
+
url = DEFAULT_OPENAI_IMAGE_URL,
|
|
115
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
116
|
+
size,
|
|
117
|
+
quality,
|
|
118
|
+
} = {}) => {
|
|
119
|
+
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
|
|
120
|
+
throw new ImageAdapterError('image provider API key is required');
|
|
121
|
+
}
|
|
122
|
+
if (typeof fetchImpl !== 'function') {
|
|
123
|
+
throw new ImageAdapterError('image provider fetch implementation is required');
|
|
124
|
+
}
|
|
125
|
+
let endpoint;
|
|
126
|
+
try {
|
|
127
|
+
endpoint = new URL(url).toString();
|
|
128
|
+
} catch {
|
|
129
|
+
throw new ImageAdapterError('image provider URL is invalid');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const generate = async ({
|
|
133
|
+
prompt,
|
|
134
|
+
images = [],
|
|
135
|
+
signal,
|
|
136
|
+
size: requestSize,
|
|
137
|
+
quality: requestQuality,
|
|
138
|
+
name,
|
|
139
|
+
} = {}) => {
|
|
140
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
|
141
|
+
throw new ImageAdapterError('image prompt is required');
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(images)) {
|
|
144
|
+
throw new ImageAdapterError('image references must be an array');
|
|
145
|
+
}
|
|
146
|
+
if (images.length > 0) {
|
|
147
|
+
throw new ImageAdapterError('OpenAI image generation does not accept input reference images');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const body = {
|
|
151
|
+
model,
|
|
152
|
+
prompt,
|
|
153
|
+
n: 1,
|
|
154
|
+
...((requestSize ?? size) === undefined ? {} : { size: requestSize ?? size }),
|
|
155
|
+
...((requestQuality ?? quality) === undefined ? {} : { quality: requestQuality ?? quality }),
|
|
156
|
+
};
|
|
157
|
+
const requestSignal = combineSignals(signal, timeoutMs);
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(endpoint, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
authorization: `Bearer ${apiKey.trim()}`,
|
|
164
|
+
'content-type': 'application/json',
|
|
165
|
+
accept: 'application/json',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
redirect: 'error',
|
|
169
|
+
signal: requestSignal,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (signal?.aborted) throw error;
|
|
173
|
+
const message = error instanceof Error ? error.message : 'image request failed';
|
|
174
|
+
throw new ImageAdapterError(`image provider request failed: ${message}`);
|
|
175
|
+
}
|
|
176
|
+
const bytes = await readResponseBytes(response, response.ok ? MAX_RESPONSE_BYTES : 16 * 1024);
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
throw new ImageAdapterError(`image provider returned HTTP ${response.status}`, {
|
|
179
|
+
status: response.status,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
let payload;
|
|
183
|
+
try {
|
|
184
|
+
payload = JSON.parse(bytes.toString('utf8'));
|
|
185
|
+
} catch {
|
|
186
|
+
throw new ImageAdapterError('image provider returned invalid JSON');
|
|
187
|
+
}
|
|
188
|
+
const first = Array.isArray(payload?.data) ? payload.data[0] : undefined;
|
|
189
|
+
const encoded = first?.b64_json;
|
|
190
|
+
return decodeImage(encoded, typeof name === 'string' && name.length > 0 ? name : 'image-0.png');
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return { name: 'openai', generate };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Select an image adapter by provider name.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ provider?: string } & Parameters<typeof createOpenAIImageAdapter>[0]} options
|
|
200
|
+
* @returns {ImageAdapter}
|
|
201
|
+
*/
|
|
202
|
+
const createImageAdapter = ({ provider = 'openai', ...options } = {}) => {
|
|
203
|
+
if (provider === 'openai') return createOpenAIImageAdapter(options);
|
|
204
|
+
throw new ImageAdapterError(`unsupported image provider: ${provider}`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export {
|
|
208
|
+
DEFAULT_OPENAI_IMAGE_MODEL,
|
|
209
|
+
DEFAULT_OPENAI_IMAGE_URL,
|
|
210
|
+
ImageAdapterError,
|
|
211
|
+
createImageAdapter,
|
|
212
|
+
createOpenAIImageAdapter,
|
|
213
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
GENERATION_VERSION,
|
|
5
|
+
GenerationProtocolError,
|
|
6
|
+
idempotencyDigest,
|
|
7
|
+
normalizeGenerationRequest,
|
|
8
|
+
normalizeIdempotencyKey,
|
|
9
|
+
normalizeModelCatalog,
|
|
10
|
+
publicModelCatalog,
|
|
11
|
+
requestDigest,
|
|
12
|
+
toRunnerInput,
|
|
13
|
+
} from './protocol.mjs';
|
|
14
|
+
export { GenerationStore, GenerationStoreError } from './store.mjs';
|
|
15
|
+
export {
|
|
16
|
+
DEFAULT_GENERATION_PORT,
|
|
17
|
+
GenerationServer,
|
|
18
|
+
createLocalRunner,
|
|
19
|
+
isAllowedPeer,
|
|
20
|
+
normalizeBindHost,
|
|
21
|
+
startGenerationServer,
|
|
22
|
+
} from './server.mjs';
|
|
23
|
+
export {
|
|
24
|
+
ANALYTICA_EVENT_URL,
|
|
25
|
+
DEFAULT_ANALYTICA_EVENT_PREFIX,
|
|
26
|
+
DEFAULT_ANALYTICA_PAGE_LOCATION,
|
|
27
|
+
AnalyticaEvents,
|
|
28
|
+
analyticaEventName,
|
|
29
|
+
} from './telemetry.mjs';
|
|
30
|
+
export {
|
|
31
|
+
DEFAULT_OPENCODE_URL,
|
|
32
|
+
DEFAULT_OPENCODE_USERNAME,
|
|
33
|
+
ExecutorError,
|
|
34
|
+
createOpenCodeExecutor,
|
|
35
|
+
} from './executor/opencode.mjs';
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_OPENAI_IMAGE_MODEL,
|
|
38
|
+
DEFAULT_OPENAI_IMAGE_URL,
|
|
39
|
+
ImageAdapterError,
|
|
40
|
+
createImageAdapter,
|
|
41
|
+
createOpenAIImageAdapter,
|
|
42
|
+
} from './image/adapter.mjs';
|
|
43
|
+
export {
|
|
44
|
+
JobError,
|
|
45
|
+
RunnerClient,
|
|
46
|
+
RunnerHttpError,
|
|
47
|
+
UncertainRequestError,
|
|
48
|
+
createCancellation,
|
|
49
|
+
normalizeBaseUrl,
|
|
50
|
+
processJob,
|
|
51
|
+
readConfig,
|
|
52
|
+
validateJobEnvelope,
|
|
53
|
+
} from './bridge.mjs';
|