ag-ai 0.4.1 → 0.4.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.
@@ -1,8 +1,15 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.opencodeStack = void 0;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = __importDefault(require("node:path"));
4
9
  const ag_rpi_mega_1 = require("ag-rpi-mega");
5
10
  const common_1 = require("./common");
11
+ /** The runtime's tier table is the single source of truth for which model and effort each tier runs. */
12
+ const MODEL_TIERS = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(__dirname, '..', '..', 'runtime', 'model-tiers.json'), 'utf8'));
6
13
  /** The published OpenCode CLI is installed into a writable emptyDir at pod start. */
7
14
  const OPENCODE_PACKAGE = 'opencode-ai@1.18.31';
8
15
  const OPENCODE_PREFIX = '/opt/opencode';
@@ -49,19 +56,17 @@ const opencodeStack = (context) => {
49
56
  (0, common_1.env)('XDG_CACHE_HOME', '/data/opencode/cache'),
50
57
  // The unrestricted /provider response processes thousands of models from
51
58
  // unconfigured providers on every request, exceeding the runner's budget.
52
- // Each advertised model carries the reasoning effort its consumer asks
53
- // for: sol at high for draft copy, 5.5 at high for draft images, and luna
54
- // at max for the client's cluster fallback. OpenCode merges these entries
55
- // with its model catalogue; it does not replace it.
59
+ // Each advertised model carries the reasoning effort of the tier that
60
+ // selects it. OpenCode merges these entries with its model catalogue; it
61
+ // does not replace it.
56
62
  (0, common_1.env)('OPENCODE_CONFIG_CONTENT', JSON.stringify({
57
63
  enabled_providers: ['openai', 'google'],
58
64
  provider: {
59
65
  openai: {
60
- models: {
61
- 'gpt-5.6-sol': { options: { reasoningEffort: 'high' } },
62
- 'gpt-5.6-luna': { options: { reasoningEffort: 'max' } },
63
- 'gpt-5.5': { options: { reasoningEffort: 'high' } },
64
- },
66
+ models: Object.fromEntries(Object.values(MODEL_TIERS).map(({ model, reasoningEffort }) => [
67
+ model,
68
+ { options: { reasoningEffort } },
69
+ ])),
65
70
  },
66
71
  },
67
72
  })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ag-ai",
3
- "version": "0.4.1",
3
+ "version": "0.4.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",
@@ -25,7 +25,7 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "dynamodb-reactive": "^0.3.0"
28
+ "dynamodb-reactive": "^0.5.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@kubernetes/client-node": "2.0.0",
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import { createOpenCodeExecutor, DEFAULT_OPENCODE_URL } from './executor/opencode.mjs';
11
11
  import { createImageAdapter } from './image/adapter.mjs';
12
12
  import { normalizeGenerationRequest } from './protocol.mjs';
13
- import { createWorkSignal, startPushChannel } from './push.mjs';
13
+ import { createStatusReporter, createWorkSignal, startPushChannel } from './push.mjs';
14
14
  import { createLocalRunner, startGenerationServer } from './server.mjs';
15
15
  import { GenerationStore } from './store.mjs';
16
16
  import { AnalyticaEvents } from './telemetry.mjs';
@@ -25,7 +25,7 @@ const DEFAULT_GENERATION_STORE = '/generation-jobs';
25
25
  const DEFAULT_GENERATION_TOKEN_FILE = '/var/run/secrets/ag-ai-generation/token';
26
26
  const DEFAULT_ANALYTICA_TOKEN_FILE = '/var/run/secrets/ag-ai-analytica/token';
27
27
  const DEFAULT_IMAGE_PROVIDER = 'openai';
28
- const HEARTBEAT_INTERVAL_MS = 10_000;
28
+ const STATUS_INTERVAL_MS = 10_000;
29
29
  const LEASE_HEARTBEAT_INTERVAL_MS = 5_000;
30
30
  const HEALTH_INTERVAL_MS = 30_000;
31
31
  const CLAIM_INTERVAL_MS = 5_000;
@@ -921,21 +921,29 @@ const main = async () => {
921
921
  return state.healthPromise;
922
922
  };
923
923
 
924
- let heartbeatPromise;
925
- const sendHeartbeat = async () => {
926
- if (heartbeatPromise) {
927
- return heartbeatPromise;
924
+ // The queue learns the runner is alive from the push socket's pings, so status is
925
+ // sent only when it changes or the socket reconnects, never on a timer.
926
+ const statusReporter = createStatusReporter(({ activeJobs, serverReady, modelReady }) =>
927
+ runner.heartbeat(activeJobs, serverReady, modelReady),
928
+ );
929
+ let statusPromise;
930
+ const reportStatus = async () => {
931
+ if (statusPromise) {
932
+ return statusPromise;
928
933
  }
929
- heartbeatPromise = runner
930
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
931
- .heartbeat(state.job ? 1 : 0, state.health.serverReady, state.health.modelReady)
932
- .then(() => true)
934
+ statusPromise = statusReporter
935
+ .update({
936
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
937
+ activeJobs: state.job ? 1 : 0,
938
+ serverReady: state.health.serverReady,
939
+ modelReady: state.health.modelReady,
940
+ })
933
941
  .catch((error) => {
934
- console.error(`[runner] runner heartbeat failed: ${redact(error, [config.token])}`);
935
- return false;
942
+ console.error(`[runner] runner status report failed: ${redact(error, [config.token])}`);
936
943
  })
937
- .then(async (workerReachable) => {
944
+ .then(async () => {
938
945
  // Probe data is private to this sidecar and contains no credentials.
946
+ const workerReachable = push.connected();
939
947
  const backendReady = state.health.serverReady && state.health.modelReady;
940
948
  const lanReady = generationServer !== undefined && backendReady;
941
949
  await writeFile(
@@ -950,9 +958,9 @@ const main = async () => {
950
958
  })
951
959
  .catch(() => console.error('[runner] could not update local health probe'))
952
960
  .finally(() => {
953
- heartbeatPromise = undefined;
961
+ statusPromise = undefined;
954
962
  });
955
- return heartbeatPromise;
963
+ return statusPromise;
956
964
  };
957
965
 
958
966
  let shutdownPromise;
@@ -981,9 +989,15 @@ const main = async () => {
981
989
  baseUrl: new URL(config.queueUrl).origin,
982
990
  token: config.token,
983
991
  onWork: () => workSignal.wake(),
992
+ // A fresh connection may follow a queue restart, so the status is sent again.
993
+ onConnect: () => {
994
+ statusReporter.reset();
995
+ void reportStatus();
996
+ },
984
997
  log: (message) => console.log(`[runner] ${redact(message, [config.token])}`),
985
998
  });
986
- const heartbeatTimer = setInterval(() => void sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
999
+ // Local only: refreshes the health probe and sends status if it changed.
1000
+ const statusTimer = setInterval(() => void reportStatus(), STATUS_INTERVAL_MS);
987
1001
  const healthTimer = setInterval(() => void refreshHealth(), HEALTH_INTERVAL_MS);
988
1002
  const claimLocal = async () => {
989
1003
  if (!generationStore) return undefined;
@@ -1026,7 +1040,7 @@ const main = async () => {
1026
1040
  let preferLocal = false;
1027
1041
  try {
1028
1042
  await refreshHealth();
1029
- await sendHeartbeat();
1043
+ await reportStatus();
1030
1044
  while (!state.stopping) {
1031
1045
  if (!state.health.serverReady || !state.health.modelReady) {
1032
1046
  await refreshHealth();
@@ -1067,7 +1081,7 @@ const main = async () => {
1067
1081
  await jobPromise;
1068
1082
  }
1069
1083
  } finally {
1070
- clearInterval(heartbeatTimer);
1084
+ clearInterval(statusTimer);
1071
1085
  clearInterval(healthTimer);
1072
1086
  push.stop();
1073
1087
  await stop('shutdown');
@@ -0,0 +1,4 @@
1
+ {
2
+ "fast": { "model": "gpt-6-luna", "reasoningEffort": "medium" },
3
+ "quality": { "model": "gpt-6.1-sol", "reasoningEffort": "medium" }
4
+ }
@@ -3,6 +3,15 @@
3
3
  // differ.
4
4
 
5
5
  import { createHash } from 'node:crypto';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ /**
9
+ * The only place that names a model: each `prefer` tier maps to one model and
10
+ * effort. Callers send a tier; the model is resolved when the call is made.
11
+ */
12
+ const MODEL_TIERS = JSON.parse(
13
+ readFileSync(new URL('./model-tiers.json', import.meta.url), 'utf8'),
14
+ );
6
15
 
7
16
  const GENERATION_VERSION = 1;
8
17
  const MAX_TEXT_BYTES = 2 * 1024 * 1024;
@@ -257,21 +266,9 @@ const normalizeGenerationRequest = (value, models) => {
257
266
  if (prefer !== undefined && prefer !== 'fast' && prefer !== 'quality') {
258
267
  throw new GenerationProtocolError(400, 'invalid_request', 'prefer is invalid');
259
268
  }
260
- let requestedModel;
261
- if (request.model !== undefined) {
262
- requestedModel = boundedString(request.model, 160, 'model');
263
- } else if (prefer === 'fast') {
264
- requestedModel = catalog.find((candidate) => candidate.prefer.includes('fast'))?.id;
265
- requestedModel ??= catalog.find((candidate) => /(?:mini|luna)$/i.test(candidate.id))?.id;
266
- if (!requestedModel) throw unsupported('fast model preference');
267
- } else {
268
- requestedModel =
269
- catalog.find((candidate) => candidate.isDefault && candidate.prefer.includes('quality'))
270
- ?.id ??
271
- catalog.find((candidate) => candidate.isDefault)?.id ??
272
- catalog.find((candidate) => candidate.prefer.includes('quality'))?.id ??
273
- catalog[0].id;
274
- }
269
+ const tier = MODEL_TIERS[prefer ?? 'quality'];
270
+ const requestedModel =
271
+ request.model === undefined ? tier.model : boundedString(request.model, 160, 'model');
275
272
  const model =
276
273
  catalog.find((candidate) => candidate.id === requestedModel) ??
277
274
  (requestedModel.includes('/') ? undefined : uniqueSuffixModel(catalog, requestedModel));
@@ -290,7 +287,9 @@ const normalizeGenerationRequest = (value, models) => {
290
287
  const output = normalizeOutput(request.output, capabilities);
291
288
  const reasoningEffort =
292
289
  request.reasoningEffort === undefined
293
- ? (capabilities.defaultReasoningEffort ?? capabilities.supportedReasoningEfforts[0])
290
+ ? request.model === undefined
291
+ ? tier.reasoningEffort
292
+ : (capabilities.defaultReasoningEffort ?? capabilities.supportedReasoningEfforts[0])
294
293
  : boundedString(request.reasoningEffort, 32, 'reasoningEffort');
295
294
  if (!reasoningEffort) {
296
295
  throw new GenerationProtocolError(
@@ -358,6 +357,7 @@ const publicModelCatalog = (models, { ready = true } = {}) => ({
358
357
 
359
358
  export {
360
359
  GENERATION_VERSION,
360
+ MODEL_TIERS,
361
361
  MAX_INPUT_ATTACHMENT_BYTES,
362
362
  MAX_INPUT_TOTAL_BYTES,
363
363
  GenerationProtocolError,
package/runtime/push.mjs CHANGED
@@ -43,10 +43,13 @@ export const createWorkSignal = () => {
43
43
  * lists queued work. Never throws: failures are logged and the connection keeps
44
44
  * retrying, and the reconnect snapshot catches up on anything queued meanwhile.
45
45
  */
46
- export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
46
+ export const startPushChannel = ({ baseUrl, token, onWork, onConnect, log, connect }) => {
47
47
  const controller = new AbortController();
48
48
  let client;
49
49
  let subscription;
50
+ // The socket is the runner's liveness: the hub sees its pings, and the client
51
+ // replaces it when a ping goes unanswered, so this flag tracks real reachability.
52
+ let isConnected = false;
50
53
  const ready = (async () => {
51
54
  const connectClusterWorker =
52
55
  connect ?? (await import('dynamodb-reactive/cluster')).connectClusterWorker;
@@ -57,7 +60,16 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
57
60
  httpPath: HTTP_PATH,
58
61
  signal: controller.signal,
59
62
  onRetry: (error) => log(`push connect failed; retrying: ${error.message}`),
60
- client: { onError: (error) => log(`push socket error: ${error.message}`) },
63
+ client: {
64
+ onError: (error) => log(`push socket error: ${error.message}`),
65
+ onConnect: () => {
66
+ isConnected = true;
67
+ onConnect?.();
68
+ },
69
+ onDisconnect: () => {
70
+ isConnected = false;
71
+ },
72
+ },
61
73
  });
62
74
  if (controller.signal.aborted) {
63
75
  client.disconnect();
@@ -75,6 +87,7 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
75
87
  });
76
88
  return {
77
89
  ready,
90
+ connected: () => isConnected,
78
91
  stop: () => {
79
92
  controller.abort();
80
93
  subscription?.unsubscribe();
@@ -82,3 +95,31 @@ export const startPushChannel = ({ baseUrl, token, onWork, log, connect }) => {
82
95
  },
83
96
  };
84
97
  };
98
+
99
+ /**
100
+ * Sends the runner's status only when it changes, or after `reset` (each push
101
+ * reconnect), instead of on a timer: the push socket already proves liveness.
102
+ * A failed send is retried by the next `update`.
103
+ */
104
+ export const createStatusReporter = (send) => {
105
+ let reported;
106
+ let inFlight;
107
+ return {
108
+ update: (status) => {
109
+ const key = JSON.stringify(status);
110
+ if (inFlight) return inFlight;
111
+ if (key === reported) return Promise.resolve();
112
+ inFlight = send(status)
113
+ .then(() => {
114
+ reported = key;
115
+ })
116
+ .finally(() => {
117
+ inFlight = undefined;
118
+ });
119
+ return inFlight;
120
+ },
121
+ reset: () => {
122
+ reported = undefined;
123
+ },
124
+ };
125
+ };