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.
Files changed (55) hide show
  1. package/README.md +31 -0
  2. package/dist/client/adapters/cluster.d.ts +12 -0
  3. package/dist/client/adapters/cluster.js +567 -0
  4. package/dist/client/adapters/google.d.ts +12 -0
  5. package/dist/client/adapters/google.js +478 -0
  6. package/dist/client/client.d.ts +11 -0
  7. package/dist/client/client.js +192 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +18 -0
  10. package/dist/client/quota.d.ts +55 -0
  11. package/dist/client/quota.js +148 -0
  12. package/dist/client/types.d.ts +124 -0
  13. package/dist/client/types.js +2 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +18 -0
  16. package/dist/internal/apikey.d.ts +5 -0
  17. package/dist/internal/apikey.js +51 -0
  18. package/dist/internal/array.d.ts +2 -0
  19. package/dist/internal/array.js +6 -0
  20. package/dist/internal/log.d.ts +9 -0
  21. package/dist/internal/log.js +92 -0
  22. package/dist/internal/node-cache.d.ts +71 -0
  23. package/dist/internal/node-cache.js +104 -0
  24. package/dist/internal/retryOnError.d.ts +26 -0
  25. package/dist/internal/retryOnError.js +123 -0
  26. package/dist/internal/string/redact.d.ts +2 -0
  27. package/dist/internal/string/redact.js +94 -0
  28. package/dist/internal/string/truncate.d.ts +8 -0
  29. package/dist/internal/string/truncate.js +16 -0
  30. package/dist/k8s/assets/network-init.sh +52 -0
  31. package/dist/k8s/common.d.ts +35 -0
  32. package/dist/k8s/common.js +56 -0
  33. package/dist/k8s/index.d.ts +9 -0
  34. package/dist/k8s/index.js +33 -0
  35. package/dist/k8s/opencode.d.ts +4 -0
  36. package/dist/k8s/opencode.js +55 -0
  37. package/dist/k8s/runner.d.ts +4 -0
  38. package/dist/k8s/runner.js +247 -0
  39. package/dist/k8s/t3code.d.ts +4 -0
  40. package/dist/k8s/t3code.js +60 -0
  41. package/package.json +56 -0
  42. package/runtime/bridge.mjs +1093 -0
  43. package/runtime/executor/opencode.mjs +430 -0
  44. package/runtime/image/adapter.mjs +213 -0
  45. package/runtime/index.mjs +53 -0
  46. package/runtime/protocol.mjs +384 -0
  47. package/runtime/server.mjs +621 -0
  48. package/runtime/store.mjs +568 -0
  49. package/runtime/telemetry.mjs +137 -0
  50. package/runtime/tests/bridge.test.mjs +554 -0
  51. package/runtime/tests/image-adapter.test.mjs +148 -0
  52. package/runtime/tests/protocol.test.mjs +156 -0
  53. package/runtime/tests/server.test.mjs +231 -0
  54. package/runtime/tests/store.test.mjs +114 -0
  55. package/runtime/tests/telemetry.test.mjs +138 -0
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # ag-ai
2
+
3
+ Provider-neutral AI client and cluster runtime for the
4
+ [rpi-mega](https://github.com/andreigec/rpi-mega) cluster.
5
+
6
+ ## Target layout (subpath exports)
7
+
8
+ | Subpath | Contents |
9
+ | --------------- | --------------------------------------------------------------------- |
10
+ | `ag-ai` | Client SDK: generate, generateText, promptDirect, promptImage, models |
11
+ | `ag-ai/client` | Client SDK entry point |
12
+ | `ag-ai/runtime` | Cluster runtime: generation protocol, store, server, bridge (ESM) |
13
+ | `ag-ai/k8s` | Cluster plugin descriptor (`ClusterPlugin`) |
14
+ | `ag-ai/image` | Neutral image-output adapter (provider per implementation) |
15
+
16
+ The client compiles to CommonJS. The runtime is hand-authored ESM and shipped
17
+ as-is, because it runs inside the pod with no build step.
18
+
19
+ ## Boundaries
20
+
21
+ - No prompts. Prompt templates belong to consumers (gec.dev, bolt.garden, …).
22
+ - No secrets, secret names, SSM paths, or cluster addresses.
23
+ - Provider keys are declared as logical names and resolved by `rpi-mega`.
24
+ - Codex is not used. The executor is OpenCode; the image adapter is separate
25
+ because OpenCode has no image-generation output.
26
+
27
+ ## Tooling
28
+
29
+ Formatting and linting use `e7-oxfmt` / `e7-oxlint` from
30
+ `eslint-config-e7npm`. Dependency updates run through Renovate via
31
+ `github>andreigec/renovate`.
@@ -0,0 +1,12 @@
1
+ import type { AIClientDependencies, AIEndpoint, AIModel, AIRequest, GenerationResult } from '../types';
2
+ export type ClusterAdapterConfig = {
3
+ endpoint: AIEndpoint;
4
+ dependencies?: AIClientDependencies;
5
+ timeoutMs?: number;
6
+ pollIntervalMs?: number;
7
+ maxPollIntervalMs?: number;
8
+ };
9
+ /** Validate and normalize the direct private LAN endpoint used by the ag-ai cluster. */
10
+ export declare const normalizeClusterEndpoint: (endpoint: AIEndpoint) => AIEndpoint;
11
+ export declare const generateCluster: (request: AIRequest, config: ClusterAdapterConfig) => Promise<GenerationResult>;
12
+ export declare const listClusterModels: (endpoint: AIEndpoint, dependencies?: AIClientDependencies) => Promise<AIModel[]>;
@@ -0,0 +1,567 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listClusterModels = exports.generateCluster = exports.normalizeClusterEndpoint = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const DEFAULT_POLL_INTERVAL_MS = 250;
7
+ const DEFAULT_MAX_POLL_INTERVAL_MS = 2000;
8
+ const CREATE_RETRY_DELAY_MS = 1000;
9
+ const CANCEL_TIMEOUT_MS = 1000;
10
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
11
+ const TERMINAL_SUCCESS_STATUSES = new Set(['completed']);
12
+ const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'unknown']);
13
+ const PENDING_STATUSES = new Set(['queued', 'running', 'cancelling']);
14
+ class ClusterHTTPError extends Error {
15
+ status;
16
+ constructor(status, message) {
17
+ super(message);
18
+ this.name = 'ClusterHTTPError';
19
+ this.status = status;
20
+ }
21
+ }
22
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ const getString = (record, key) => {
24
+ const value = record[key];
25
+ return typeof value === 'string' ? value : undefined;
26
+ };
27
+ const getRecord = (record, key) => {
28
+ const value = record[key];
29
+ return isRecord(value) ? value : undefined;
30
+ };
31
+ const getErrorMessage = (body, fallback) => {
32
+ if (!isRecord(body))
33
+ return fallback;
34
+ const error = body.error;
35
+ if (typeof error === 'string' && error.trim().length > 0)
36
+ return error;
37
+ if (isRecord(error)) {
38
+ const message = getString(error, 'message');
39
+ if (message && message.trim().length > 0)
40
+ return message;
41
+ }
42
+ const message = getString(body, 'message');
43
+ return message && message.trim().length > 0 ? message : fallback;
44
+ };
45
+ const parseIPv4 = (hostname) => {
46
+ const octets = hostname.split('.');
47
+ if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet)))
48
+ return undefined;
49
+ const numbers = octets.map((octet) => Number(octet));
50
+ return numbers.every((octet) => octet >= 0 && octet <= 255) ? numbers : undefined;
51
+ };
52
+ const isPrivateIPv4 = (hostname) => {
53
+ const octets = parseIPv4(hostname);
54
+ if (!octets)
55
+ return false;
56
+ const [first, second] = octets;
57
+ return (first === 10 ||
58
+ (first === 172 && second >= 16 && second <= 31) ||
59
+ (first === 192 && second === 168));
60
+ };
61
+ /** Validate and normalize the direct private LAN endpoint used by the ag-ai cluster. */
62
+ const normalizeClusterEndpoint = (endpoint) => {
63
+ const candidate = endpoint;
64
+ if (!isRecord(candidate) || candidate.protocol !== 'ag-ai') {
65
+ throw new Error('Unsupported AI endpoint protocol');
66
+ }
67
+ const rawURL = candidate.url;
68
+ const rawToken = candidate.token;
69
+ if (typeof rawURL !== 'string' || rawURL.trim().length === 0) {
70
+ throw new Error('AI endpoint URL is required');
71
+ }
72
+ if (typeof rawToken !== 'string' || rawToken.trim().length === 0) {
73
+ throw new Error('AI endpoint token is required');
74
+ }
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(rawURL);
78
+ }
79
+ catch {
80
+ throw new Error('AI endpoint URL is invalid');
81
+ }
82
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
83
+ throw new Error('AI endpoint URL must use HTTP or HTTPS');
84
+ }
85
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
86
+ throw new Error('AI endpoint URL must not contain credentials, query, or fragment');
87
+ }
88
+ if (!isPrivateIPv4(parsed.hostname)) {
89
+ throw new Error('AI endpoint must use a private LAN IPv4 address');
90
+ }
91
+ const path = parsed.pathname.replace(/\/+$/, '');
92
+ if (path !== '' && path !== '/v1') {
93
+ throw new Error('AI endpoint URL path must be empty or /v1');
94
+ }
95
+ parsed.pathname = '/v1';
96
+ parsed.search = '';
97
+ parsed.hash = '';
98
+ return { protocol: 'ag-ai', url: parsed.toString().replace(/\/$/, ''), token: rawToken.trim() };
99
+ };
100
+ exports.normalizeClusterEndpoint = normalizeClusterEndpoint;
101
+ const defaultSleep = (milliseconds) => new Promise((resolve) => {
102
+ setTimeout(resolve, milliseconds);
103
+ });
104
+ const getDependencies = (dependencies) => ({
105
+ fetch: dependencies?.fetch ?? globalThis.fetch,
106
+ sleep: dependencies?.sleep ?? defaultSleep,
107
+ now: dependencies?.now ?? (() => Date.now()),
108
+ createIdempotencyKey: dependencies?.createIdempotencyKey ?? node_crypto_1.randomUUID,
109
+ });
110
+ const throwIfAborted = (signal) => {
111
+ if (signal?.aborted) {
112
+ throw new Error('generation aborted');
113
+ }
114
+ };
115
+ const createAbortError = () => {
116
+ const error = new Error('AI request aborted');
117
+ error.name = 'AbortError';
118
+ return error;
119
+ };
120
+ const awaitWithSignal = async (promise, signal) => {
121
+ if (signal === undefined)
122
+ return promise;
123
+ if (signal.aborted)
124
+ throw createAbortError();
125
+ const abort = new Promise((_, reject) => {
126
+ const onAbort = () => reject(createAbortError());
127
+ signal.addEventListener('abort', onAbort, { once: true });
128
+ void promise.then(() => signal.removeEventListener('abort', onAbort), () => signal.removeEventListener('abort', onAbort));
129
+ });
130
+ return Promise.race([promise, abort]);
131
+ };
132
+ const responseJSON = async (response, signal) => {
133
+ const contentLength = response.headers.get('content-length');
134
+ if (contentLength !== null && Number(contentLength) > MAX_RESPONSE_BYTES) {
135
+ throw new Error('AI response body is too large');
136
+ }
137
+ const body = response.body;
138
+ if (body === null)
139
+ return undefined;
140
+ const reader = body.getReader();
141
+ const chunks = [];
142
+ let total = 0;
143
+ try {
144
+ for (;;) {
145
+ // oxlint-disable-next-line no-await-in-loop -- response bytes must be bounded sequentially
146
+ const chunk = await awaitWithSignal(reader.read(), signal);
147
+ if (chunk.done)
148
+ break;
149
+ total += chunk.value.byteLength;
150
+ if (total > MAX_RESPONSE_BYTES)
151
+ throw new Error('AI response body is too large');
152
+ chunks.push(chunk.value);
153
+ }
154
+ }
155
+ finally {
156
+ reader.releaseLock();
157
+ }
158
+ const bytes = new Uint8Array(total);
159
+ let offset = 0;
160
+ for (const chunk of chunks) {
161
+ bytes.set(chunk, offset);
162
+ offset += chunk.byteLength;
163
+ }
164
+ try {
165
+ return JSON.parse(new TextDecoder().decode(bytes));
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ };
171
+ const fetchJSON = async ({ fetch, url, headers, method, body, signal, }) => {
172
+ const response = await awaitWithSignal(fetch(url, {
173
+ method,
174
+ headers,
175
+ ...(body === undefined ? {} : { body }),
176
+ redirect: 'manual',
177
+ ...(signal === undefined ? {} : { signal }),
178
+ }), signal);
179
+ const payload = await responseJSON(response, signal);
180
+ if (!response.ok) {
181
+ throw new ClusterHTTPError(response.status, getErrorMessage(payload, `AI endpoint returned HTTP ${response.status}`));
182
+ }
183
+ return payload;
184
+ };
185
+ const isRetryable = (error) => {
186
+ if (error instanceof ClusterHTTPError)
187
+ return error.status === 429 || error.status >= 500;
188
+ return error instanceof Error && error.name !== 'AbortError';
189
+ };
190
+ const withCreateRetry = async (operation, sleep, signal) => {
191
+ try {
192
+ const result = await operation();
193
+ return result;
194
+ }
195
+ catch (error) {
196
+ if (!isRetryable(error))
197
+ throw error;
198
+ // oxlint-disable-next-line no-await-in-loop -- retry delay precedes the one allowed replay
199
+ await awaitWithSignal(sleep(CREATE_RETRY_DELAY_MS), signal);
200
+ throwIfAborted(signal);
201
+ return operation();
202
+ }
203
+ };
204
+ const isBase64 = (value) => value.length > 0 &&
205
+ value.length % 4 === 0 &&
206
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
207
+ const parseOutputPart = (value) => {
208
+ if (!isRecord(value))
209
+ throw new Error('AI result contains an invalid output part');
210
+ const type = getString(value, 'type');
211
+ if (type === 'text') {
212
+ const text = getString(value, 'text');
213
+ if (text === undefined)
214
+ throw new Error('AI text output is missing text');
215
+ return { type, text };
216
+ }
217
+ if (type !== 'image' && type !== 'audio' && type !== 'video' && type !== 'file') {
218
+ throw new Error('AI result contains an unsupported output part');
219
+ }
220
+ const mimeType = getString(value, 'mimeType');
221
+ const data = getString(value, 'data');
222
+ if (!mimeType || data === undefined || !isBase64(data)) {
223
+ throw new Error('AI media output is missing MIME type or data');
224
+ }
225
+ if (type !== 'file' && !mimeType.toLowerCase().startsWith(`${type}/`)) {
226
+ throw new Error('AI media output type does not match its MIME type');
227
+ }
228
+ const name = getString(value, 'name');
229
+ return name === undefined ? { type, mimeType, data } : { type, mimeType, data, name };
230
+ };
231
+ const parseJob = (value) => {
232
+ if (!isRecord(value))
233
+ throw new Error('AI endpoint returned an invalid job');
234
+ const id = getString(value, 'id');
235
+ const status = getString(value, 'status');
236
+ if (!id || !status)
237
+ throw new Error('AI job is missing id or status');
238
+ return value;
239
+ };
240
+ const parseResult = (job) => {
241
+ const result = getRecord(job, 'result');
242
+ if (!result)
243
+ throw new Error('AI successful job is missing its result');
244
+ const rawOutput = result.output;
245
+ if (!Array.isArray(rawOutput))
246
+ throw new Error('AI result is missing typed output parts');
247
+ const model = getString(result, 'model');
248
+ if (!model || model.trim().length === 0)
249
+ throw new Error('AI result is missing its actual model');
250
+ const generatedAtValue = result.generatedAt;
251
+ if (typeof generatedAtValue !== 'number' ||
252
+ !Number.isSafeInteger(generatedAtValue) ||
253
+ generatedAtValue < 0) {
254
+ throw new Error('AI result is missing a valid generatedAt timestamp');
255
+ }
256
+ const rawUsage = result.usage;
257
+ let usage;
258
+ if (rawUsage !== undefined) {
259
+ if (!isRecord(rawUsage))
260
+ throw new Error('AI result usage is invalid');
261
+ const inputTokens = rawUsage.inputTokens;
262
+ const outputTokens = rawUsage.outputTokens;
263
+ const totalTokens = rawUsage.totalTokens;
264
+ if (typeof inputTokens !== 'number' ||
265
+ !Number.isSafeInteger(inputTokens) ||
266
+ inputTokens < 0 ||
267
+ typeof outputTokens !== 'number' ||
268
+ !Number.isSafeInteger(outputTokens) ||
269
+ outputTokens < 0 ||
270
+ typeof totalTokens !== 'number' ||
271
+ !Number.isSafeInteger(totalTokens) ||
272
+ totalTokens < 0) {
273
+ throw new Error('AI result usage is invalid');
274
+ }
275
+ usage = { inputTokens, outputTokens, totalTokens };
276
+ }
277
+ return {
278
+ output: rawOutput.map(parseOutputPart),
279
+ model,
280
+ generatedAt: generatedAtValue,
281
+ ...(usage === undefined ? {} : { usage }),
282
+ };
283
+ };
284
+ const getFailureMessage = (job) => {
285
+ const error = job.error;
286
+ const status = getString(job, 'status');
287
+ if (status === 'unknown') {
288
+ const detail = getErrorMessage(error, 'execution outcome is unknown');
289
+ return `AI generation outcome is uncertain; do not retry automatically: ${detail}`;
290
+ }
291
+ return getErrorMessage(error, 'AI generation failed');
292
+ };
293
+ const endpointHeaders = (endpoint, idempotencyKey) => ({
294
+ Accept: 'application/json',
295
+ 'Content-Type': 'application/json',
296
+ Authorization: `Bearer ${endpoint.token}`,
297
+ ...(idempotencyKey === undefined ? {} : { 'Idempotency-Key': idempotencyKey }),
298
+ });
299
+ const jobURL = (endpoint, id) => `${endpoint.url}/jobs${id === undefined ? '' : `/${encodeURIComponent(id)}`}`;
300
+ const cancelJob = async (endpoint, id, fetch) => {
301
+ const controller = new AbortController();
302
+ const timeout = setTimeout(() => controller.abort(), CANCEL_TIMEOUT_MS);
303
+ try {
304
+ const response = await fetch(`${jobURL(endpoint, id)}/cancel`, {
305
+ method: 'POST',
306
+ headers: endpointHeaders(endpoint),
307
+ redirect: 'manual',
308
+ signal: controller.signal,
309
+ });
310
+ if (!response.ok) {
311
+ throw new ClusterHTTPError(response.status, `AI cancellation returned HTTP ${response.status}`);
312
+ }
313
+ }
314
+ finally {
315
+ clearTimeout(timeout);
316
+ }
317
+ };
318
+ const abortWithCancellation = async (endpoint, id, fetch, reason) => {
319
+ try {
320
+ await Promise.race([
321
+ cancelJob(endpoint, id, fetch),
322
+ new Promise((resolve) => setTimeout(resolve, CANCEL_TIMEOUT_MS)),
323
+ ]);
324
+ }
325
+ catch {
326
+ // Preserve the original abort or timeout reason when best-effort cancellation fails.
327
+ }
328
+ throw reason;
329
+ };
330
+ const createOperationAbort = (request, timeoutMs) => {
331
+ const controller = new AbortController();
332
+ const timeout = setTimeout(() => {
333
+ controller.abort();
334
+ }, timeoutMs);
335
+ const onAbort = () => controller.abort();
336
+ request.signal?.addEventListener('abort', onAbort, { once: true });
337
+ return {
338
+ signal: controller.signal,
339
+ cleanup: () => {
340
+ clearTimeout(timeout);
341
+ request.signal?.removeEventListener('abort', onAbort);
342
+ },
343
+ };
344
+ };
345
+ const generateCluster = async (request, config) => {
346
+ const endpoint = (0, exports.normalizeClusterEndpoint)(config.endpoint);
347
+ const dependencies = getDependencies(config.dependencies);
348
+ const timeoutMs = request.timeoutMs ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
349
+ const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
350
+ const maxPollIntervalMs = config.maxPollIntervalMs ?? DEFAULT_MAX_POLL_INTERVAL_MS;
351
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
352
+ throw new Error('generation timeout must be positive');
353
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
354
+ throw new Error('poll interval must be positive');
355
+ }
356
+ if (!Number.isFinite(maxPollIntervalMs) || maxPollIntervalMs < pollIntervalMs) {
357
+ throw new Error('maximum poll interval is invalid');
358
+ }
359
+ throwIfAborted(request.signal);
360
+ const operationAbort = createOperationAbort(request, timeoutMs);
361
+ let jobId;
362
+ const deadline = dependencies.now() + timeoutMs;
363
+ const abortError = () => request.signal?.aborted ? new Error('generation aborted') : new Error('generation timed out');
364
+ try {
365
+ const idempotencyKey = dependencies.createIdempotencyKey();
366
+ if (idempotencyKey.trim().length === 0)
367
+ throw new Error('idempotency key is empty');
368
+ const body = {
369
+ version: 1,
370
+ input: request.input,
371
+ output: request.output ?? ['text'],
372
+ ...(request.instructions === undefined ? {} : { instructions: request.instructions }),
373
+ ...(request.model === undefined ? {} : { model: request.model }),
374
+ ...(request.model === undefined && request.prefer !== undefined
375
+ ? { prefer: request.prefer }
376
+ : {}),
377
+ ...(request.reasoningEffort === undefined
378
+ ? {}
379
+ : { reasoningEffort: request.reasoningEffort }),
380
+ ...(request.maxOutputTokens === undefined
381
+ ? {}
382
+ : { maxOutputTokens: request.maxOutputTokens }),
383
+ ...(request.outputSchema === undefined ? {} : { outputSchema: request.outputSchema }),
384
+ ...(request.webSearch === undefined ? {} : { webSearch: request.webSearch }),
385
+ };
386
+ const created = parseJob(await withCreateRetry(() => fetchJSON({
387
+ fetch: dependencies.fetch,
388
+ url: jobURL(endpoint),
389
+ headers: endpointHeaders(endpoint, idempotencyKey),
390
+ method: 'POST',
391
+ body: JSON.stringify(body),
392
+ signal: operationAbort.signal,
393
+ }), dependencies.sleep, operationAbort.signal));
394
+ jobId = getString(created, 'id');
395
+ if (!jobId)
396
+ throw new Error('AI job is missing its id');
397
+ const status = getString(created, 'status');
398
+ if (!status)
399
+ throw new Error('AI job is missing its status');
400
+ if (TERMINAL_FAILURE_STATUSES.has(status))
401
+ throw new Error(getFailureMessage(created));
402
+ if (!PENDING_STATUSES.has(status) && !TERMINAL_SUCCESS_STATUSES.has(status)) {
403
+ throw new Error(`Unknown AI job status: ${status}`);
404
+ }
405
+ let delay = TERMINAL_SUCCESS_STATUSES.has(status) ? 0 : pollIntervalMs;
406
+ const maxPollAttempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs)) + 2;
407
+ let pollAttempts = 0;
408
+ while (pollAttempts < maxPollAttempts) {
409
+ if (dependencies.now() >= deadline) {
410
+ return abortWithCancellation(endpoint, jobId, dependencies.fetch, new Error('generation timed out'));
411
+ }
412
+ if (delay > 0) {
413
+ // oxlint-disable-next-line no-await-in-loop -- polling must remain sequential
414
+ await awaitWithSignal(dependencies.sleep(Math.min(delay, Math.max(0, deadline - dependencies.now()))), operationAbort.signal);
415
+ }
416
+ if (operationAbort.signal.aborted) {
417
+ return abortWithCancellation(endpoint, jobId, dependencies.fetch, abortError());
418
+ }
419
+ if (dependencies.now() >= deadline) {
420
+ return abortWithCancellation(endpoint, jobId, dependencies.fetch, new Error('generation timed out'));
421
+ }
422
+ let current;
423
+ pollAttempts += 1;
424
+ try {
425
+ current = parseJob(
426
+ // oxlint-disable-next-line no-await-in-loop -- each poll depends on the prior status
427
+ await fetchJSON({
428
+ fetch: dependencies.fetch,
429
+ url: jobURL(endpoint, jobId),
430
+ headers: endpointHeaders(endpoint),
431
+ method: 'GET',
432
+ signal: operationAbort.signal,
433
+ }));
434
+ }
435
+ catch (error) {
436
+ if (!isRetryable(error))
437
+ throw error;
438
+ const retryDelay = Math.max(pollIntervalMs, delay);
439
+ // oxlint-disable-next-line no-await-in-loop -- backoff precedes the next status check
440
+ await awaitWithSignal(dependencies.sleep(retryDelay), operationAbort.signal);
441
+ delay = Math.min(maxPollIntervalMs, retryDelay * 2);
442
+ continue;
443
+ }
444
+ const currentStatus = getString(current, 'status');
445
+ if (!currentStatus)
446
+ throw new Error('AI job is missing its status');
447
+ if (TERMINAL_SUCCESS_STATUSES.has(currentStatus)) {
448
+ return parseResult(current);
449
+ }
450
+ if (TERMINAL_FAILURE_STATUSES.has(currentStatus))
451
+ throw new Error(getFailureMessage(current));
452
+ if (!PENDING_STATUSES.has(currentStatus)) {
453
+ throw new Error(`Unknown AI job status: ${currentStatus}`);
454
+ }
455
+ delay = Math.min(maxPollIntervalMs, Math.max(pollIntervalMs, delay * 2));
456
+ }
457
+ return abortWithCancellation(endpoint, jobId, dependencies.fetch, new Error('generation timed out'));
458
+ }
459
+ catch (error) {
460
+ if (operationAbort.signal.aborted && jobId !== undefined) {
461
+ return abortWithCancellation(endpoint, jobId, dependencies.fetch, abortError());
462
+ }
463
+ if (operationAbort.signal.aborted)
464
+ throw abortError();
465
+ throw error;
466
+ }
467
+ finally {
468
+ operationAbort.cleanup();
469
+ }
470
+ };
471
+ exports.generateCluster = generateCluster;
472
+ const MODEL_MODALITIES = new Set(['text', 'image', 'audio', 'video', 'file']);
473
+ const getModelId = (record) => {
474
+ for (const key of ['id', 'name', 'model', 'modelId']) {
475
+ const value = getString(record, key)?.trim();
476
+ if (value !== undefined && value.length > 0)
477
+ return value;
478
+ }
479
+ return undefined;
480
+ };
481
+ const parseStringList = (value) => {
482
+ if (!Array.isArray(value))
483
+ return undefined;
484
+ return [
485
+ ...new Set(value.flatMap((item) => {
486
+ if (typeof item === 'string') {
487
+ const normalized = item.trim();
488
+ return normalized.length > 0 ? [normalized] : [];
489
+ }
490
+ if (isRecord(item)) {
491
+ const normalized = getString(item, 'reasoningEffort')?.trim();
492
+ return normalized === undefined || normalized.length === 0 ? [] : [normalized];
493
+ }
494
+ return [];
495
+ })),
496
+ ];
497
+ };
498
+ const parseModalities = (value) => {
499
+ if (!Array.isArray(value))
500
+ return undefined;
501
+ return [
502
+ ...new Set(value.flatMap((item) => {
503
+ if (typeof item !== 'string')
504
+ return [];
505
+ const normalized = item.trim();
506
+ return MODEL_MODALITIES.has(normalized) ? [normalized] : [];
507
+ })),
508
+ ];
509
+ };
510
+ const parsePreferences = (value) => {
511
+ if (!Array.isArray(value))
512
+ return undefined;
513
+ return [
514
+ ...new Set(value.filter((item) => item === 'fast' || item === 'quality')),
515
+ ];
516
+ };
517
+ const parseModel = (value) => {
518
+ if (typeof value === 'string') {
519
+ const id = value.trim();
520
+ return id.length === 0 ? undefined : { id };
521
+ }
522
+ if (!isRecord(value))
523
+ return undefined;
524
+ const id = getModelId(value);
525
+ if (id === undefined)
526
+ return undefined;
527
+ const capabilities = getRecord(value, 'capabilities') ?? value;
528
+ const model = { id };
529
+ const inputModalities = parseModalities(capabilities.inputModalities);
530
+ const outputModalities = parseModalities(capabilities.outputModalities);
531
+ const supportedReasoningEfforts = parseStringList(capabilities.supportedReasoningEfforts ?? capabilities.reasoningEfforts);
532
+ const defaultReasoningEffort = getString(capabilities, 'defaultReasoningEffort')?.trim();
533
+ const prefer = parsePreferences(capabilities.prefer ?? capabilities.preferences);
534
+ if (inputModalities !== undefined)
535
+ model.inputModalities = inputModalities;
536
+ if (outputModalities !== undefined)
537
+ model.outputModalities = outputModalities;
538
+ if (supportedReasoningEfforts !== undefined) {
539
+ model.supportedReasoningEfforts = supportedReasoningEfforts;
540
+ }
541
+ if (defaultReasoningEffort !== undefined && defaultReasoningEffort.length > 0) {
542
+ model.defaultReasoningEffort = defaultReasoningEffort;
543
+ }
544
+ if (prefer !== undefined)
545
+ model.prefer = prefer;
546
+ for (const key of ['webSearch', 'isDefault', 'ready']) {
547
+ const flag = capabilities[key];
548
+ if (typeof flag === 'boolean')
549
+ model[key] = flag;
550
+ }
551
+ return model;
552
+ };
553
+ const listClusterModels = async (endpoint, dependencies) => {
554
+ const normalizedEndpoint = (0, exports.normalizeClusterEndpoint)(endpoint);
555
+ const { fetch } = getDependencies(dependencies);
556
+ const payload = await fetchJSON({
557
+ fetch,
558
+ url: `${normalizedEndpoint.url}/models`,
559
+ headers: endpointHeaders(normalizedEndpoint),
560
+ method: 'GET',
561
+ });
562
+ const models = isRecord(payload) ? payload.models : payload;
563
+ if (!Array.isArray(models))
564
+ throw new Error('AI models response is invalid');
565
+ return models.map(parseModel).filter((model) => model !== undefined);
566
+ };
567
+ exports.listClusterModels = listClusterModels;
@@ -0,0 +1,12 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { resetModelBackoff } from '../quota';
3
+ import type { AIClientDependencies, AIModel, AIRequest, GenerationResult } from '../types';
4
+ export type GoogleAdapterConfig = {
5
+ dependencies?: AIClientDependencies;
6
+ createAI?: (apiKey: string) => GoogleGenAI;
7
+ };
8
+ export declare const generateGoogle: (request: AIRequest, config?: GoogleAdapterConfig) => Promise<GenerationResult>;
9
+ export declare const listGoogleModels: (dependencies?: AIClientDependencies) => Promise<AIModel[]>;
10
+ export { resetModelBackoff };
11
+ /** Test seam: reset adapter clients, model cache, and per-model rate-limit backoff. */
12
+ export declare const resetGoogleAdapterForTests: () => void;