ag-ai 0.3.1 → 0.3.3
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 +7 -0
- package/dist/client/adapters/google.d.ts +10 -0
- package/dist/client/adapters/google.js +80 -65
- package/dist/client/client.js +5 -1
- package/dist/internal/apikey.d.ts +0 -2
- package/dist/internal/apikey.js +1 -6
- package/dist/k8s/common.d.ts +33 -1
- package/dist/k8s/common.js +52 -1
- package/dist/k8s/index.js +10 -1
- package/dist/k8s/opencode.js +19 -0
- package/dist/k8s/t3code.js +19 -0
- package/package.json +3 -3
- package/runtime/executor/opencode.mjs +21 -1
package/README.md
CHANGED
|
@@ -55,6 +55,13 @@ the actual selected model and provider (`google` or `cluster`). Cluster
|
|
|
55
55
|
network operations and retry waits for this fallback are individually capped
|
|
56
56
|
at 3000 ms; model generation time is not included in that cap.
|
|
57
57
|
|
|
58
|
+
Quota (429) and capacity (5xx) failures only put a model/key on a short
|
|
59
|
+
cooldown (60 s doubling to 15 min, or the provider's retry delay). Models that
|
|
60
|
+
return 404 are hidden for an hour. Without a fallback, a request whose whole
|
|
61
|
+
pool is cooling down waits for the soonest model to recover and retries, at
|
|
62
|
+
most `MAX_COOLDOWN_WAITS` times and never past the request timeout; with a
|
|
63
|
+
fallback it falls back immediately instead.
|
|
64
|
+
|
|
58
65
|
## Tooling
|
|
59
66
|
|
|
60
67
|
Formatting and linting use `e7-oxfmt` / `e7-oxlint` from
|
|
@@ -4,14 +4,24 @@ import type { AIClientDependencies, AIModel, AIRequest, GenerationResult } from
|
|
|
4
4
|
export type GoogleAdapterConfig = {
|
|
5
5
|
dependencies?: AIClientDependencies;
|
|
6
6
|
createAI?: (apiKey: string) => GoogleGenAI;
|
|
7
|
+
/**
|
|
8
|
+
* Wait out the soonest model/key cooldown and retry when the whole pool is
|
|
9
|
+
* cooling down, instead of failing fast. Bounded by the request timeout.
|
|
10
|
+
*/
|
|
11
|
+
waitForCooldown?: boolean;
|
|
7
12
|
};
|
|
13
|
+
/** Upper bound on cooldown waits per request, so a pool that never recovers still fails. */
|
|
14
|
+
export declare const MAX_COOLDOWN_WAITS = 5;
|
|
8
15
|
/** Every eligible Gemini combination was exhausted by quota/capacity or cooldown. */
|
|
9
16
|
export declare class GeminiPoolExhaustedError extends Error {
|
|
10
17
|
readonly causes: readonly unknown[];
|
|
11
18
|
readonly models: readonly string[];
|
|
19
|
+
/** Time until the soonest cooling model/key becomes eligible again, when known. */
|
|
20
|
+
readonly retryAfterMs: number | undefined;
|
|
12
21
|
constructor(options?: {
|
|
13
22
|
causes?: readonly unknown[];
|
|
14
23
|
models?: readonly string[];
|
|
24
|
+
retryAfterMs?: number;
|
|
15
25
|
});
|
|
16
26
|
}
|
|
17
27
|
export declare const generateGoogle: (request: AIRequest, config?: GoogleAdapterConfig) => Promise<GenerationResult>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resetGoogleAdapterForTests = exports.resetModelBackoff = exports.listGoogleModels = exports.generateGoogle = exports.GeminiPoolExhaustedError = void 0;
|
|
3
|
+
exports.resetGoogleAdapterForTests = exports.resetModelBackoff = exports.listGoogleModels = exports.generateGoogle = exports.GeminiPoolExhaustedError = exports.MAX_COOLDOWN_WAITS = void 0;
|
|
4
4
|
const genai_1 = require("@google/genai");
|
|
5
5
|
const apikey_1 = require("../../internal/apikey");
|
|
6
6
|
const log_1 = require("../../internal/log");
|
|
@@ -25,15 +25,20 @@ const NON_TEXT_MODEL_CAPABILITIES = [
|
|
|
25
25
|
'robotics',
|
|
26
26
|
'tts',
|
|
27
27
|
];
|
|
28
|
+
/** Upper bound on cooldown waits per request, so a pool that never recovers still fails. */
|
|
29
|
+
exports.MAX_COOLDOWN_WAITS = 5;
|
|
28
30
|
/** Every eligible Gemini combination was exhausted by quota/capacity or cooldown. */
|
|
29
31
|
class GeminiPoolExhaustedError extends Error {
|
|
30
32
|
causes;
|
|
31
33
|
models;
|
|
34
|
+
/** Time until the soonest cooling model/key becomes eligible again, when known. */
|
|
35
|
+
retryAfterMs;
|
|
32
36
|
constructor(options = {}) {
|
|
33
37
|
super('Gemini model pool exhausted; all eligible combinations are unavailable');
|
|
34
38
|
this.name = 'GeminiPoolExhaustedError';
|
|
35
39
|
this.causes = options.causes ?? [];
|
|
36
40
|
this.models = options.models ?? [];
|
|
41
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
exports.GeminiPoolExhaustedError = GeminiPoolExhaustedError;
|
|
@@ -212,6 +217,17 @@ const getClients = (createAI = (apiKey) => new genai_1.GoogleGenAI({ apiKey }))
|
|
|
212
217
|
return ai === undefined ? [] : [[key, ai]];
|
|
213
218
|
});
|
|
214
219
|
};
|
|
220
|
+
const isKeyServiceAvailable = (key, model) => (0, apikey_1.getAvailableCombinations)(`gemini-${model}`, false).some((entry) => entry.key === key);
|
|
221
|
+
/** Soonest remaining model/key cooldown across combinations, or undefined when none cool. */
|
|
222
|
+
const soonestCooldownMs = (combinations, nowMs) => {
|
|
223
|
+
let soonest;
|
|
224
|
+
for (const [key, , model] of combinations) {
|
|
225
|
+
const remainingMs = (0, quota_1.modelKeyBackoffRemainingMs)(model, key, nowMs);
|
|
226
|
+
if (remainingMs > 0 && (soonest === undefined || remainingMs < soonest))
|
|
227
|
+
soonest = remainingMs;
|
|
228
|
+
}
|
|
229
|
+
return soonest;
|
|
230
|
+
};
|
|
215
231
|
const getCombinations = async (model, prefer, output, signal, config) => {
|
|
216
232
|
const clients = getClients(config.createAI);
|
|
217
233
|
const requiresNonTextModel = (output ?? ['text']).some((modality) => modality !== 'text');
|
|
@@ -223,28 +239,14 @@ const getCombinations = async (model, prefer, output, signal, config) => {
|
|
|
223
239
|
: sortModelsByPreference((await getAvailableGeminiModels(config.dependencies, signal)).filter(isTextGenerationModel), prefer);
|
|
224
240
|
if (models.length === 0)
|
|
225
241
|
throw new Error('No text generation models are available');
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
(0, apikey_1.clearKeyServiceBlock)(key, service);
|
|
235
|
-
const available = (0, apikey_1.getAvailableCombinations)(service, false);
|
|
236
|
-
if (available.some((entry) => entry.key === key)) {
|
|
237
|
-
combinations.push([key, ai, selectedModel]);
|
|
238
|
-
}
|
|
239
|
-
else if (status === 'active') {
|
|
240
|
-
cooldownOnly = true;
|
|
241
|
-
}
|
|
242
|
-
else {
|
|
243
|
-
nonCooldownBlock = true;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return { combinations, models, cooldownOnly: cooldownOnly && !nonCooldownBlock, nonCooldownBlock };
|
|
242
|
+
const nowMs = config.dependencies?.now?.() ?? Date.now();
|
|
243
|
+
// Unavailable (404) models stay blocklisted; quota/capacity failures only
|
|
244
|
+
// cool down briefly, so they are tracked separately and never hidden longer.
|
|
245
|
+
const candidates = clients.flatMap(([key, ai]) => models
|
|
246
|
+
.filter((selectedModel) => isKeyServiceAvailable(key, selectedModel))
|
|
247
|
+
.map((selectedModel) => [key, ai, selectedModel]));
|
|
248
|
+
const combinations = candidates.filter(([key, , selectedModel]) => !(0, quota_1.isModelKeyBackedOff)(selectedModel, key, nowMs));
|
|
249
|
+
return { combinations, models, cooldownRemainingMs: soonestCooldownMs(candidates, nowMs) };
|
|
248
250
|
};
|
|
249
251
|
const mediaType = (mimeType) => {
|
|
250
252
|
const normalized = mimeType.toLowerCase();
|
|
@@ -402,38 +404,36 @@ const generateGoogle = async (request, config = {}) => {
|
|
|
402
404
|
((ms) => new Promise((resolve) => {
|
|
403
405
|
setTimeout(resolve, ms);
|
|
404
406
|
}));
|
|
405
|
-
const
|
|
407
|
+
const now = () => config.dependencies?.now?.() ?? Date.now();
|
|
408
|
+
const startedAtMs = now();
|
|
409
|
+
const attemptPool = (initialDelayMs) => (0, retryOnError_1.retryOnError)(`generation:${request.ident ?? 'unknown'}`, async () => {
|
|
406
410
|
const combinationSet = await getCombinations(request.model, request.prefer, request.output, controller.signal, config);
|
|
407
411
|
const combinations = combinationSet.combinations;
|
|
408
412
|
if (combinations.length === 0) {
|
|
409
|
-
if (combinationSet.
|
|
413
|
+
if (combinationSet.cooldownRemainingMs !== undefined) {
|
|
410
414
|
throw new GeminiPoolExhaustedError({
|
|
411
415
|
models: combinationSet.models,
|
|
416
|
+
retryAfterMs: combinationSet.cooldownRemainingMs,
|
|
412
417
|
});
|
|
413
418
|
}
|
|
414
419
|
throw new Error('No available model for this request');
|
|
415
420
|
}
|
|
416
421
|
let lastFailure;
|
|
422
|
+
let unavailableFailure;
|
|
417
423
|
const exhaustionCauses = [];
|
|
418
424
|
const exhaustedModels = new Set();
|
|
419
|
-
let attemptedCombination = false;
|
|
420
|
-
let nonExhaustionFailure;
|
|
421
|
-
let skippedForNonCooldown = false;
|
|
422
425
|
for (const [key, ai, selectedModel] of combinations) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
if (
|
|
426
|
+
// Concurrent requests may have cooled or retired this combination
|
|
427
|
+
// since the pool snapshot was taken.
|
|
428
|
+
const nowMs = now();
|
|
429
|
+
if ((0, quota_1.isModelKeyBackedOff)(selectedModel, key, nowMs)) {
|
|
427
430
|
(0, log_1.debug)(`[${selectedModel}] skipping rate-limited model/key`, {
|
|
428
431
|
remainingMs: (0, quota_1.modelKeyBackoffRemainingMs)(selectedModel, key, nowMs),
|
|
429
432
|
});
|
|
430
433
|
continue;
|
|
431
434
|
}
|
|
432
|
-
if (!(
|
|
433
|
-
skippedForNonCooldown = true;
|
|
435
|
+
if (!isKeyServiceAvailable(key, selectedModel))
|
|
434
436
|
continue;
|
|
435
|
-
}
|
|
436
|
-
attemptedCombination = true;
|
|
437
437
|
const selectedRequest = {
|
|
438
438
|
...requestConfig,
|
|
439
439
|
model: selectedModel,
|
|
@@ -446,7 +446,7 @@ const generateGoogle = async (request, config = {}) => {
|
|
|
446
446
|
const result = {
|
|
447
447
|
output: responseOutput(response),
|
|
448
448
|
model: response.modelVersion || selectedModel,
|
|
449
|
-
generatedAt:
|
|
449
|
+
generatedAt: now(),
|
|
450
450
|
...(usage === undefined ? {} : { usage }),
|
|
451
451
|
};
|
|
452
452
|
(0, log_1.debug)(`[${result.model}] generation completed`, request.ident);
|
|
@@ -460,53 +460,68 @@ const generateGoogle = async (request, config = {}) => {
|
|
|
460
460
|
const rateLimited = failureStatus === 429 || (0, retryOnError_1.isOverloadedApiKeyError)(error);
|
|
461
461
|
const overloadedModel = failureStatus === 503 || failureStatus === 500 || failureStatus === 502;
|
|
462
462
|
const unavailableModel = failureStatus === 404 && message.includes(selectedModel);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
463
|
+
if (!rateLimited && !overloadedModel && !unavailableModel)
|
|
464
|
+
throw error;
|
|
465
|
+
(0, log_1.debug)(`[${selectedModel}] generation attempt failed; trying next available combination`, { status: failureStatus });
|
|
466
|
+
lastFailure = error;
|
|
467
|
+
if (unavailableModel) {
|
|
468
|
+
// Retired/unknown models will not recover soon: hide them for the blocklist window.
|
|
468
469
|
(0, apikey_1.blockKeyService)(key, `gemini-${selectedModel}`);
|
|
469
|
-
|
|
470
|
-
(0, quota_1.recordModelKeyRateLimit)(selectedModel, key, {
|
|
471
|
-
cause: error,
|
|
472
|
-
nowMs: config.dependencies?.now?.(),
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
if (unavailableModel)
|
|
476
|
-
nonExhaustionFailure = error;
|
|
477
|
-
if (rateLimited || overloadedModel)
|
|
478
|
-
exhaustionCauses.push(error);
|
|
479
|
-
if (!unavailableModel)
|
|
480
|
-
exhaustedModels.add(selectedModel);
|
|
481
|
-
lastFailure = error;
|
|
470
|
+
unavailableFailure = error;
|
|
482
471
|
continue;
|
|
483
472
|
}
|
|
484
|
-
|
|
473
|
+
// Quota and capacity failures are transient: cool down briefly, never blocklist.
|
|
474
|
+
(0, quota_1.recordModelKeyRateLimit)(selectedModel, key, { cause: error, nowMs: now() });
|
|
475
|
+
exhaustionCauses.push(error);
|
|
476
|
+
exhaustedModels.add(selectedModel);
|
|
485
477
|
}
|
|
486
478
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (skippedForNonCooldown || combinationSet.nonCooldownBlock) {
|
|
490
|
-
throw new Error('No available model for this request');
|
|
491
|
-
}
|
|
492
|
-
if (!attemptedCombination || exhaustionCauses.length > 0) {
|
|
479
|
+
const retryAfterMs = soonestCooldownMs(combinations, now());
|
|
480
|
+
if (exhaustionCauses.length > 0 || retryAfterMs !== undefined) {
|
|
493
481
|
throw new GeminiPoolExhaustedError({
|
|
494
482
|
causes: exhaustionCauses,
|
|
495
483
|
models: [...exhaustedModels],
|
|
484
|
+
retryAfterMs,
|
|
496
485
|
});
|
|
497
486
|
}
|
|
498
|
-
throw lastFailure ?? new Error('No available model for this request');
|
|
487
|
+
throw (unavailableFailure ?? lastFailure ?? new Error('No available model for this request'));
|
|
499
488
|
}, {
|
|
500
489
|
retries: 1,
|
|
501
490
|
errorDelay: 5000,
|
|
502
|
-
initialDelayMs
|
|
491
|
+
initialDelayMs,
|
|
503
492
|
signal: controller.signal,
|
|
504
493
|
sleepFn: async (ms) => {
|
|
505
494
|
await sleep(ms);
|
|
506
495
|
},
|
|
507
496
|
errorCheck: (error) => !(error instanceof GeminiPoolExhaustedError) && (0, retryOnError_1.isRetryableApiError)(error),
|
|
508
497
|
});
|
|
509
|
-
|
|
498
|
+
for (let cooldownWaits = 0;; cooldownWaits++) {
|
|
499
|
+
// Awaited into a binding (not `return await`) so the lint autofix cannot
|
|
500
|
+
// strip the await and let the rejection escape this cooldown handler.
|
|
501
|
+
let result;
|
|
502
|
+
try {
|
|
503
|
+
// oxlint-disable-next-line no-await-in-loop -- cooldown waits are sequential by design
|
|
504
|
+
result = await attemptPool(cooldownWaits === 0 ? staggerMs : 0);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
const retryAfterMs = error instanceof GeminiPoolExhaustedError ? error.retryAfterMs : undefined;
|
|
508
|
+
const remainingBudgetMs = timeoutMs - (now() - startedAtMs);
|
|
509
|
+
if (config.waitForCooldown !== true ||
|
|
510
|
+
retryAfterMs === undefined ||
|
|
511
|
+
cooldownWaits >= exports.MAX_COOLDOWN_WAITS ||
|
|
512
|
+
retryAfterMs >= remainingBudgetMs) {
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
(0, log_1.info)('Gemini pool cooling down; waiting for the soonest model to recover', {
|
|
516
|
+
ident: request.ident,
|
|
517
|
+
retryAfterMs,
|
|
518
|
+
});
|
|
519
|
+
// oxlint-disable-next-line no-await-in-loop -- cooldown waits are sequential by design
|
|
520
|
+
await awaitWithSignal(sleep(retryAfterMs), controller.signal);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
510
525
|
}
|
|
511
526
|
catch (error) {
|
|
512
527
|
if (controller.signal.aborted) {
|
package/dist/client/client.js
CHANGED
|
@@ -141,7 +141,11 @@ const createAIClient = (defaults = {}) => {
|
|
|
141
141
|
let provider;
|
|
142
142
|
if (merged.endpoint === undefined) {
|
|
143
143
|
try {
|
|
144
|
-
|
|
144
|
+
// Without a fallback, waiting out a short Gemini cooldown beats failing the request.
|
|
145
|
+
result = await (0, google_1.generateGoogle)(merged, {
|
|
146
|
+
dependencies,
|
|
147
|
+
waitForCooldown: defaults.fallback === undefined,
|
|
148
|
+
});
|
|
145
149
|
provider = 'google';
|
|
146
150
|
}
|
|
147
151
|
catch (error) {
|
|
@@ -3,5 +3,3 @@ export declare const getAvailableCombinations: (service: string, resetIfExhauste
|
|
|
3
3
|
key: string;
|
|
4
4
|
}[];
|
|
5
5
|
export declare const blockKeyService: (apiKey: string, service: string) => void;
|
|
6
|
-
/** Remove only one key+service block, preserving every unrelated blocklist entry. */
|
|
7
|
-
export declare const clearKeyServiceBlock: (apiKey: string, service: string) => void;
|
package/dist/internal/apikey.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.blockKeyService = exports.getAvailableCombinations = void 0;
|
|
4
4
|
const array_1 = require("./array");
|
|
5
5
|
const log_1 = require("./log");
|
|
6
6
|
const node_cache_1 = require("./node-cache");
|
|
@@ -49,8 +49,3 @@ const blockKeyService = (apiKey, service) => {
|
|
|
49
49
|
addToBlocklist(apiKey, service);
|
|
50
50
|
};
|
|
51
51
|
exports.blockKeyService = blockKeyService;
|
|
52
|
-
/** Remove only one key+service block, preserving every unrelated blocklist entry. */
|
|
53
|
-
const clearKeyServiceBlock = (apiKey, service) => {
|
|
54
|
-
blocklist.del(getBlocklistKey(apiKey, service));
|
|
55
|
-
};
|
|
56
|
-
exports.clearKeyServiceBlock = clearKeyServiceBlock;
|
package/dist/k8s/common.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { V1EnvVar, V1Volume, V1VolumeMount } from '@kubernetes/client-node';
|
|
2
|
-
import type { ClusterFacilities, IStack, SecretReference } from 'ag-rpi-mega';
|
|
2
|
+
import type { ClusterFacilities, ClusterPluginContext, IStack, SecretReference } from 'ag-rpi-mega';
|
|
3
3
|
/** The plugin owns its identity; addresses, hosts, and secrets come from the host. */
|
|
4
4
|
export declare const NAMESPACE = "ag-ai";
|
|
5
5
|
export declare const OPENCODE_APP = "ag-ai-opencode";
|
|
@@ -30,6 +30,38 @@ export declare const secretMount: (name: string, reference: SecretReference) =>
|
|
|
30
30
|
*/
|
|
31
31
|
export declare const secretFileMount: (name: string, reference: SecretReference, file: string) => SecretMount;
|
|
32
32
|
export declare const env: (name: string, value: string) => V1EnvVar;
|
|
33
|
+
/** A GitHub owner whose repositories authenticate with a dedicated token. */
|
|
34
|
+
export interface GitToken {
|
|
35
|
+
owner: string;
|
|
36
|
+
tokenFile: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Git credentials and identity for the agent pods, expressed entirely as
|
|
40
|
+
* `GIT_CONFIG_*` environment variables so no writable `$HOME`/`.gitconfig` is
|
|
41
|
+
* needed: the pods run as uid 65534 with a read-only root filesystem and, for
|
|
42
|
+
* t3code, an unwritable `$HOME`.
|
|
43
|
+
*
|
|
44
|
+
* A single credential helper dispatches on the requested repository path.
|
|
45
|
+
* Multiple accounts on github.com cannot be separated by `credential.<url>`
|
|
46
|
+
* subsections, because git accumulates helpers across URL scopes and a
|
|
47
|
+
* host-level helper wins before an owner-scoped one; instead the helper reads
|
|
48
|
+
* the `path=` line git sends (which requires `credential.useHttpPath=true`) and
|
|
49
|
+
* picks the token whose `owner` matches. `defaultTokenFile` backs every other
|
|
50
|
+
* github.com path. The `url.*.insteadOf` rewrites make existing
|
|
51
|
+
* `git@github.com:` and `ssh://git@github.com/` remotes resolve over HTTPS, so
|
|
52
|
+
* the owner selection applies regardless of the scheme a repo was cloned with.
|
|
53
|
+
*/
|
|
54
|
+
export declare const gitConfigEnv: ({ name, email, defaultTokenFile, ownerTokens, }: {
|
|
55
|
+
name: string;
|
|
56
|
+
email: string;
|
|
57
|
+
defaultTokenFile: string;
|
|
58
|
+
ownerTokens?: readonly GitToken[];
|
|
59
|
+
}) => V1EnvVar[];
|
|
60
|
+
/** The commit identity the host injects; declared in `requiredEnv`. */
|
|
61
|
+
export declare const gitIdentity: (context: ClusterPluginContext) => {
|
|
62
|
+
name: string;
|
|
63
|
+
email: string;
|
|
64
|
+
};
|
|
33
65
|
/** Readiness must consume the model response, not just see a listening HTTP server. */
|
|
34
66
|
export declare const openCodeHealthProbe: (port: number, target?: 'health' | 'models') => {
|
|
35
67
|
exec: {
|
package/dist/k8s/common.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.openCodeHealthProbe = exports.env = exports.secretFileMount = exports.secretMount = exports.stripClusterScopedNamespaces = exports.serviceUrl = exports.resourceName = exports.METAL_LB_KEY = exports.T3_SERVICE = exports.RUNNER_SERVICE = exports.OPENCODE_SERVICE = exports.T3_PORT = exports.GENERATION_PORT = exports.OPENCODE_PORT = exports.T3CODE_APP = exports.RUNNER_APP = exports.OPENCODE_APP = exports.NAMESPACE = void 0;
|
|
3
|
+
exports.openCodeHealthProbe = exports.gitIdentity = exports.gitConfigEnv = exports.env = exports.secretFileMount = exports.secretMount = exports.stripClusterScopedNamespaces = exports.serviceUrl = exports.resourceName = exports.METAL_LB_KEY = exports.T3_SERVICE = exports.RUNNER_SERVICE = exports.OPENCODE_SERVICE = exports.T3_PORT = exports.GENERATION_PORT = exports.OPENCODE_PORT = exports.T3CODE_APP = exports.RUNNER_APP = exports.OPENCODE_APP = exports.NAMESPACE = void 0;
|
|
4
4
|
const ag_rpi_mega_1 = require("ag-rpi-mega");
|
|
5
5
|
/** The plugin owns its identity; addresses, hosts, and secrets come from the host. */
|
|
6
6
|
exports.NAMESPACE = 'ag-ai';
|
|
@@ -63,6 +63,57 @@ const secretFileMount = (name, reference, file) => {
|
|
|
63
63
|
exports.secretFileMount = secretFileMount;
|
|
64
64
|
const env = (name, value) => ({ name, value });
|
|
65
65
|
exports.env = env;
|
|
66
|
+
/**
|
|
67
|
+
* Git credentials and identity for the agent pods, expressed entirely as
|
|
68
|
+
* `GIT_CONFIG_*` environment variables so no writable `$HOME`/`.gitconfig` is
|
|
69
|
+
* needed: the pods run as uid 65534 with a read-only root filesystem and, for
|
|
70
|
+
* t3code, an unwritable `$HOME`.
|
|
71
|
+
*
|
|
72
|
+
* A single credential helper dispatches on the requested repository path.
|
|
73
|
+
* Multiple accounts on github.com cannot be separated by `credential.<url>`
|
|
74
|
+
* subsections, because git accumulates helpers across URL scopes and a
|
|
75
|
+
* host-level helper wins before an owner-scoped one; instead the helper reads
|
|
76
|
+
* the `path=` line git sends (which requires `credential.useHttpPath=true`) and
|
|
77
|
+
* picks the token whose `owner` matches. `defaultTokenFile` backs every other
|
|
78
|
+
* github.com path. The `url.*.insteadOf` rewrites make existing
|
|
79
|
+
* `git@github.com:` and `ssh://git@github.com/` remotes resolve over HTTPS, so
|
|
80
|
+
* the owner selection applies regardless of the scheme a repo was cloned with.
|
|
81
|
+
*/
|
|
82
|
+
const gitConfigEnv = ({ name, email, defaultTokenFile, ownerTokens = [], }) => {
|
|
83
|
+
const arms = ownerTokens
|
|
84
|
+
.map((token) => `${token.owner}/*) tokenFile=${token.tokenFile} ;;`)
|
|
85
|
+
.join(' ');
|
|
86
|
+
const helper = '!f() { path=; while IFS= read -r line; do case "$line" in path=*) path=${line#path=} ;; esac; done; ' +
|
|
87
|
+
`case "$path" in ${arms} *) tokenFile=${defaultTokenFile} ;; esac; ` +
|
|
88
|
+
'echo username=x-access-token; echo "password=$(cat "$tokenFile")"; }; f';
|
|
89
|
+
const entries = [
|
|
90
|
+
['credential.https://github.com.helper', helper],
|
|
91
|
+
['credential.useHttpPath', 'true'],
|
|
92
|
+
['url.https://github.com/.insteadOf', 'git@github.com:'],
|
|
93
|
+
['url.https://github.com/.insteadOf', 'ssh://git@github.com/'],
|
|
94
|
+
['user.name', name],
|
|
95
|
+
['user.email', email],
|
|
96
|
+
];
|
|
97
|
+
return [
|
|
98
|
+
(0, exports.env)('GIT_CONFIG_COUNT', String(entries.length)),
|
|
99
|
+
...entries.flatMap(([key, value], index) => [
|
|
100
|
+
(0, exports.env)(`GIT_CONFIG_KEY_${index}`, key),
|
|
101
|
+
(0, exports.env)(`GIT_CONFIG_VALUE_${index}`, value),
|
|
102
|
+
]),
|
|
103
|
+
];
|
|
104
|
+
};
|
|
105
|
+
exports.gitConfigEnv = gitConfigEnv;
|
|
106
|
+
/** The commit identity the host injects; declared in `requiredEnv`. */
|
|
107
|
+
const gitIdentity = (context) => {
|
|
108
|
+
const name = context.env.AG_AI_GIT_NAME;
|
|
109
|
+
if (!name)
|
|
110
|
+
throw new Error('AG_AI_GIT_NAME is required');
|
|
111
|
+
const email = context.env.AG_AI_GIT_EMAIL;
|
|
112
|
+
if (!email)
|
|
113
|
+
throw new Error('AG_AI_GIT_EMAIL is required');
|
|
114
|
+
return { name, email };
|
|
115
|
+
};
|
|
116
|
+
exports.gitIdentity = gitIdentity;
|
|
66
117
|
/** Readiness must consume the model response, not just see a listening HTTP server. */
|
|
67
118
|
const openCodeHealthProbe = (port, target = 'health') => ({
|
|
68
119
|
exec: {
|
package/dist/k8s/index.js
CHANGED
|
@@ -14,7 +14,14 @@ const t3code_1 = require("./t3code");
|
|
|
14
14
|
exports.plugin = {
|
|
15
15
|
name: 'ag-ai',
|
|
16
16
|
namespace: common_1.NAMESPACE,
|
|
17
|
-
requiredEnv: [
|
|
17
|
+
requiredEnv: [
|
|
18
|
+
'AG_AI_QUEUE_URL',
|
|
19
|
+
'AG_AI_GENERATION_CIDR',
|
|
20
|
+
'AG_AI_PACKAGE',
|
|
21
|
+
'AG_AI_GIT_NAME',
|
|
22
|
+
'AG_AI_GIT_EMAIL',
|
|
23
|
+
'AG_AI_GIT_OWNER_2',
|
|
24
|
+
],
|
|
18
25
|
requiredSecrets: [
|
|
19
26
|
'runner-token',
|
|
20
27
|
'generation-token',
|
|
@@ -22,6 +29,8 @@ exports.plugin = {
|
|
|
22
29
|
'opencode-password',
|
|
23
30
|
'opencode-auth',
|
|
24
31
|
'image-api-key',
|
|
32
|
+
'git-token',
|
|
33
|
+
'git-token-2',
|
|
25
34
|
],
|
|
26
35
|
requiredImages: ['ag-ai-runner', 'ag-ai-opencode', 'ag-ai-t3code', 'ag-ai-firewall'],
|
|
27
36
|
stacks: (context) => [
|
package/dist/k8s/opencode.js
CHANGED
|
@@ -25,6 +25,15 @@ const opencodeStack = (context) => {
|
|
|
25
25
|
const credentials = (0, common_1.secretMount)('opencode-password', password);
|
|
26
26
|
// Seed only: OAuth credentials must remain writable for login and token refresh.
|
|
27
27
|
const auth = (0, common_1.secretFileMount)('opencode-auth', context.secret('opencode-auth'), OPENCODE_AUTH_SEED);
|
|
28
|
+
// GitHub read/write for the agent's git and `gh` operations. `git-token` is
|
|
29
|
+
// the fallback for any github.com owner; `git-token-2` is scoped to the owner
|
|
30
|
+
// in AG_AI_GIT_OWNER_2 (a separate GitHub account).
|
|
31
|
+
const gitToken = (0, common_1.secretMount)('git-token', context.secret('git-token'));
|
|
32
|
+
const gitToken2 = (0, common_1.secretMount)('git-token-2', context.secret('git-token-2'));
|
|
33
|
+
const gitUser = (0, common_1.gitIdentity)(context);
|
|
34
|
+
const gitOwner2 = context.env.AG_AI_GIT_OWNER_2;
|
|
35
|
+
if (!gitOwner2)
|
|
36
|
+
throw new Error('AG_AI_GIT_OWNER_2 is required');
|
|
28
37
|
const probe = (0, common_1.openCodeHealthProbe)(common_1.OPENCODE_PORT);
|
|
29
38
|
const container = {
|
|
30
39
|
name: 'opencode',
|
|
@@ -56,6 +65,12 @@ const opencodeStack = (context) => {
|
|
|
56
65
|
},
|
|
57
66
|
},
|
|
58
67
|
})),
|
|
68
|
+
...(0, common_1.gitConfigEnv)({
|
|
69
|
+
name: gitUser.name,
|
|
70
|
+
email: gitUser.email,
|
|
71
|
+
defaultTokenFile: gitToken.file,
|
|
72
|
+
ownerTokens: [{ owner: gitOwner2, tokenFile: gitToken2.file }],
|
|
73
|
+
}),
|
|
59
74
|
],
|
|
60
75
|
startupProbe: { ...probe, failureThreshold: 30 },
|
|
61
76
|
readinessProbe: { ...(0, common_1.openCodeHealthProbe)(common_1.OPENCODE_PORT, 'models'), initialDelaySeconds: 5 },
|
|
@@ -67,6 +82,8 @@ const opencodeStack = (context) => {
|
|
|
67
82
|
volumeMounts: [
|
|
68
83
|
...dataMounts,
|
|
69
84
|
credentials.mount,
|
|
85
|
+
gitToken.mount,
|
|
86
|
+
gitToken2.mount,
|
|
70
87
|
{ name: 'opencode', mountPath: OPENCODE_PREFIX, readOnly: true },
|
|
71
88
|
{ name: 'tmp', mountPath: '/tmp' },
|
|
72
89
|
],
|
|
@@ -120,6 +137,8 @@ const opencodeStack = (context) => {
|
|
|
120
137
|
volumes: [
|
|
121
138
|
dataVolume,
|
|
122
139
|
credentials.volume,
|
|
140
|
+
gitToken.volume,
|
|
141
|
+
gitToken2.volume,
|
|
123
142
|
auth.volume,
|
|
124
143
|
{ name: 'opencode', emptyDir: { sizeLimit: '1Gi' } },
|
|
125
144
|
{ name: 'tmp', emptyDir: { sizeLimit: '1Gi' } },
|
package/dist/k8s/t3code.js
CHANGED
|
@@ -22,6 +22,15 @@ const t3codeStack = (context) => {
|
|
|
22
22
|
const opencodePassword = context.secret('opencode-password');
|
|
23
23
|
const credentials = (0, common_1.secretMount)('opencode-password', opencodePassword);
|
|
24
24
|
const opencodeUrl = (0, common_1.serviceUrl)(facilities, common_1.OPENCODE_APP, common_1.OPENCODE_SERVICE, common_1.OPENCODE_PORT);
|
|
25
|
+
// GitHub read/write for T3's own git operations (and any agent it drives).
|
|
26
|
+
// `git-token` is the fallback for any github.com owner; `git-token-2` is
|
|
27
|
+
// scoped to the owner in AG_AI_GIT_OWNER_2 (a separate GitHub account).
|
|
28
|
+
const gitToken = (0, common_1.secretMount)('git-token', context.secret('git-token'));
|
|
29
|
+
const gitToken2 = (0, common_1.secretMount)('git-token-2', context.secret('git-token-2'));
|
|
30
|
+
const gitUser = (0, common_1.gitIdentity)(context);
|
|
31
|
+
const gitOwner2 = context.env.AG_AI_GIT_OWNER_2;
|
|
32
|
+
if (!gitOwner2)
|
|
33
|
+
throw new Error('AG_AI_GIT_OWNER_2 is required');
|
|
25
34
|
const container = {
|
|
26
35
|
name: 't3code',
|
|
27
36
|
image: facilities.image('ag-ai-t3code'),
|
|
@@ -44,6 +53,12 @@ const t3codeStack = (context) => {
|
|
|
44
53
|
(0, ag_rpi_mega_1.secretEnv)('OPENCODE_SERVER_PASSWORD', opencodePassword),
|
|
45
54
|
// T3 Connect is outbound-only; the server dials the relay, never the reverse.
|
|
46
55
|
(0, common_1.env)('T3CODE_CONNECT', '1'),
|
|
56
|
+
...(0, common_1.gitConfigEnv)({
|
|
57
|
+
name: gitUser.name,
|
|
58
|
+
email: gitUser.email,
|
|
59
|
+
defaultTokenFile: gitToken.file,
|
|
60
|
+
ownerTokens: [{ owner: gitOwner2, tokenFile: gitToken2.file }],
|
|
61
|
+
}),
|
|
47
62
|
],
|
|
48
63
|
startupProbe: { tcpSocket: { port: common_1.T3_PORT }, failureThreshold: 30, periodSeconds: 10 },
|
|
49
64
|
readinessProbe: { tcpSocket: { port: common_1.T3_PORT }, initialDelaySeconds: 5, periodSeconds: 10 },
|
|
@@ -59,6 +74,8 @@ const t3codeStack = (context) => {
|
|
|
59
74
|
volumeMounts: [
|
|
60
75
|
...dataMounts,
|
|
61
76
|
credentials.mount,
|
|
77
|
+
gitToken.mount,
|
|
78
|
+
gitToken2.mount,
|
|
62
79
|
{ name: 't3', mountPath: T3_PREFIX, readOnly: true },
|
|
63
80
|
{ name: 'tmp', mountPath: '/tmp' },
|
|
64
81
|
],
|
|
@@ -91,6 +108,8 @@ const t3codeStack = (context) => {
|
|
|
91
108
|
volumes: [
|
|
92
109
|
dataVolume,
|
|
93
110
|
credentials.volume,
|
|
111
|
+
gitToken.volume,
|
|
112
|
+
gitToken2.volume,
|
|
94
113
|
{ name: 't3', emptyDir: { sizeLimit: '1Gi' } },
|
|
95
114
|
{ name: 'tmp', emptyDir: { sizeLimit: '256Mi' } },
|
|
96
115
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ag-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Provider-neutral AI client and cluster runtime for the rpi-mega cluster.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "admin@gec.dev",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"typecheck": "tsc --noEmit",
|
|
54
54
|
"build": "rimraf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json && node scripts/copy-k8s-assets.mjs",
|
|
55
55
|
"test": "pnpm run test:client && pnpm run test:runtime",
|
|
56
|
-
"test:client": "tsx --test \"src/tests/**/*.test.ts\"",
|
|
57
|
-
"test:runtime": "node --test \"runtime/tests/**/*.test.mjs\""
|
|
56
|
+
"test:client": "tsx --test --test-timeout=30000 --test-force-exit \"src/tests/**/*.test.ts\"",
|
|
57
|
+
"test:runtime": "node --test --test-timeout=30000 --test-force-exit \"runtime/tests/**/*.test.mjs\""
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -181,6 +181,26 @@ const textFromParts = (parts) => {
|
|
|
181
181
|
.join('');
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Read the JSON-schema result of a turn. OpenCode exposes it on the message
|
|
186
|
+
* info (`structured`, or `structured_output` on older builds) and, when the
|
|
187
|
+
* model answers through its synthetic tool, as a completed `StructuredOutput`
|
|
188
|
+
* tool part. Missing structured data stays undefined so the caller can fall
|
|
189
|
+
* back to plain text.
|
|
190
|
+
*/
|
|
191
|
+
const structuredFromResponse = (info, parts) => {
|
|
192
|
+
const direct = asObject(info)?.structured ?? asObject(info)?.structured_output;
|
|
193
|
+
if (direct !== undefined) return direct;
|
|
194
|
+
if (!Array.isArray(parts)) return undefined;
|
|
195
|
+
for (const part of parts) {
|
|
196
|
+
const value = asObject(part);
|
|
197
|
+
if (value?.type !== 'tool' || value.tool !== 'StructuredOutput') continue;
|
|
198
|
+
const input = asObject(value.state)?.input;
|
|
199
|
+
if (input !== undefined) return input;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
};
|
|
203
|
+
|
|
184
204
|
const usageFromTokens = (tokens) => {
|
|
185
205
|
const usage = asObject(tokens);
|
|
186
206
|
if (!usage) return undefined;
|
|
@@ -370,7 +390,7 @@ const createOpenCodeExecutor = async ({
|
|
|
370
390
|
`OpenCode turn failed: ${asObject(message)?.message ?? info.error.name ?? 'unknown error'}`,
|
|
371
391
|
);
|
|
372
392
|
}
|
|
373
|
-
const structured = info?.
|
|
393
|
+
const structured = structuredFromResponse(info, response?.parts);
|
|
374
394
|
text =
|
|
375
395
|
structured === undefined ? textFromParts(response?.parts) : JSON.stringify(structured);
|
|
376
396
|
model = value.model ?? info?.modelID;
|