ag-ai 0.3.2 → 0.3.4

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 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 combinations = [];
227
- let cooldownOnly = false;
228
- let nonCooldownBlock = false;
229
- for (const [key, ai] of clients) {
230
- for (const selectedModel of models) {
231
- const service = `gemini-${selectedModel}`;
232
- const status = (0, quota_1.getModelKeyBackoffStatus)(selectedModel, key, config.dependencies?.now?.() ?? Date.now());
233
- if (status === 'expired')
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 result = await (0, retryOnError_1.retryOnError)(`generation:${request.ident ?? 'unknown'}`, async () => {
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.cooldownOnly) {
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
- const service = `gemini-${selectedModel}`;
424
- const nowMs = config.dependencies?.now?.() ?? Date.now();
425
- const keyBackoffStatus = (0, quota_1.getModelKeyBackoffStatus)(selectedModel, key, nowMs);
426
- if (keyBackoffStatus === 'active') {
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 (!(0, apikey_1.getAvailableCombinations)(service, false).some((entry) => entry.key === key)) {
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: config.dependencies?.now?.() ?? Date.now(),
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
- // Fallback only; per-model backoff below is keyed by rate limiting.
464
- if (rateLimited || overloadedModel || unavailableModel) {
465
- (0, log_1.debug)(`[${selectedModel}] generation attempt failed; trying next available combination`, {
466
- status: failureStatus,
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
- if (rateLimited || overloadedModel) {
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
- throw error;
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
- if (nonExhaustionFailure !== undefined)
488
- throw nonExhaustionFailure;
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: staggerMs,
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
- return result;
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) {
@@ -141,7 +141,11 @@ const createAIClient = (defaults = {}) => {
141
141
  let provider;
142
142
  if (merged.endpoint === undefined) {
143
143
  try {
144
- result = await (0, google_1.generateGoogle)(merged, { dependencies });
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;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.clearKeyServiceBlock = exports.blockKeyService = exports.getAvailableCombinations = void 0;
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;
@@ -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: {
@@ -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: ['AG_AI_QUEUE_URL', 'AG_AI_GENERATION_CIDR', 'AG_AI_PACKAGE'],
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) => [
@@ -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' } },
@@ -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.2",
3
+ "version": "0.3.4",
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=3000 --test-force-exit \"src/tests/**/*.test.ts\"",
57
+ "test:runtime": "node --test --test-timeout=3000 --test-force-exit \"runtime/tests/**/*.test.mjs\""
58
58
  }
59
59
  }