@elisym/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools
8
8
  import YAML from 'yaml';
9
9
  import { Command } from 'commander';
10
10
  import { createHash } from 'node:crypto';
11
+ import { LlmHealthMonitor, startLlmHeartbeat, createFreeLlmLimiterSet, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey } from '@elisym/sdk/llm-health';
11
12
  import { lookup } from 'node:dns/promises';
12
13
  import { Socket } from 'node:net';
13
14
  import pino from 'pino';
@@ -149,7 +150,54 @@ async function verifyKey(apiKey, signal) {
149
150
  return { ok: false, reason: "unavailable", error: message };
150
151
  }
151
152
  }
152
- var DEFAULT_MODEL, DEFAULT_MAX_TOKENS, FALLBACK_MODELS, AnthropicClient, ANTHROPIC_PROVIDER;
153
+ function bodyLooksLikeBilling(body) {
154
+ const lower = body.toLowerCase();
155
+ return BILLING_BODY_MARKERS.some((marker) => lower.includes(marker));
156
+ }
157
+ async function verifyKeyDeep(apiKey, model, signal) {
158
+ try {
159
+ const response = await fetchWithTimeout(
160
+ "https://api.anthropic.com/v1/messages",
161
+ {
162
+ method: "POST",
163
+ headers: {
164
+ "Content-Type": "application/json",
165
+ "x-api-key": apiKey,
166
+ "anthropic-version": "2023-06-01"
167
+ },
168
+ body: JSON.stringify({
169
+ model,
170
+ max_tokens: 1,
171
+ messages: [{ role: "user", content: "." }]
172
+ })
173
+ },
174
+ signal
175
+ );
176
+ if (response.ok) {
177
+ await response.body?.cancel().catch(() => void 0);
178
+ return { ok: true };
179
+ }
180
+ const body = (await response.text().catch(() => "")).slice(0, 500);
181
+ if (response.status === 401 || response.status === 403) {
182
+ return { ok: false, reason: "invalid", status: response.status, body };
183
+ }
184
+ if (response.status === 402) {
185
+ return { ok: false, reason: "billing", status: response.status, body };
186
+ }
187
+ if (response.status === 400 && bodyLooksLikeBilling(body)) {
188
+ return { ok: false, reason: "billing", status: response.status, body };
189
+ }
190
+ return {
191
+ ok: false,
192
+ reason: "unavailable",
193
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
194
+ };
195
+ } catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ return { ok: false, reason: "unavailable", error: message };
198
+ }
199
+ }
200
+ var DEFAULT_MODEL, DEFAULT_MAX_TOKENS, FALLBACK_MODELS, AnthropicClient, BILLING_BODY_MARKERS, ANTHROPIC_PROVIDER;
153
201
  var init_anthropic = __esm({
154
202
  "src/llm/providers/anthropic.ts"() {
155
203
  init_http();
@@ -270,6 +318,7 @@ var init_anthropic = __esm({
270
318
  ];
271
319
  }
272
320
  };
321
+ BILLING_BODY_MARKERS = ["credit balance", "billing", "insufficient"];
273
322
  ANTHROPIC_PROVIDER = {
274
323
  id: "anthropic",
275
324
  displayName: "Anthropic (Claude)",
@@ -278,6 +327,7 @@ var init_anthropic = __esm({
278
327
  fallbackModels: FALLBACK_MODELS,
279
328
  fetchModels,
280
329
  verifyKey,
330
+ verifyKeyDeep,
281
331
  createClient: (config) => new AnthropicClient({
282
332
  apiKey: config.apiKey,
283
333
  model: config.model ?? DEFAULT_MODEL,
@@ -342,7 +392,57 @@ async function verifyKey2(apiKey, signal) {
342
392
  return { ok: false, reason: "unavailable", error: message };
343
393
  }
344
394
  }
345
- var DEFAULT_MODEL2, DEFAULT_MAX_TOKENS2, FALLBACK_MODELS2, OpenAIClient, OPENAI_PROVIDER;
395
+ function bodyLooksLikeBilling2(body) {
396
+ const lower = body.toLowerCase();
397
+ return BILLING_BODY_MARKERS2.some((marker) => lower.includes(marker));
398
+ }
399
+ async function verifyKeyDeep2(apiKey, model, signal) {
400
+ try {
401
+ const reasoning = isOpenAIReasoningModel(model);
402
+ const response = await fetchWithTimeout(
403
+ "https://api.openai.com/v1/chat/completions",
404
+ {
405
+ method: "POST",
406
+ headers: {
407
+ "Content-Type": "application/json",
408
+ Authorization: `Bearer ${apiKey}`
409
+ },
410
+ body: JSON.stringify({
411
+ model,
412
+ ...reasoning ? { max_completion_tokens: 1 } : { max_tokens: 1 },
413
+ messages: [{ role: "user", content: "." }]
414
+ })
415
+ },
416
+ signal
417
+ );
418
+ if (response.ok) {
419
+ await response.body?.cancel().catch(() => void 0);
420
+ return { ok: true };
421
+ }
422
+ const body = (await response.text().catch(() => "")).slice(0, 500);
423
+ if (response.status === 401 || response.status === 403) {
424
+ return { ok: false, reason: "invalid", status: response.status, body };
425
+ }
426
+ if (response.status === 402) {
427
+ return { ok: false, reason: "billing", status: response.status, body };
428
+ }
429
+ if (response.status === 429 && bodyLooksLikeBilling2(body)) {
430
+ return { ok: false, reason: "billing", status: response.status, body };
431
+ }
432
+ if (response.status === 400 && bodyLooksLikeBilling2(body)) {
433
+ return { ok: false, reason: "billing", status: response.status, body };
434
+ }
435
+ return {
436
+ ok: false,
437
+ reason: "unavailable",
438
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
439
+ };
440
+ } catch (error) {
441
+ const message = error instanceof Error ? error.message : String(error);
442
+ return { ok: false, reason: "unavailable", error: message };
443
+ }
444
+ }
445
+ var DEFAULT_MODEL2, DEFAULT_MAX_TOKENS2, FALLBACK_MODELS2, OpenAIClient, BILLING_BODY_MARKERS2, OPENAI_PROVIDER;
346
446
  var init_openai = __esm({
347
447
  "src/llm/providers/openai.ts"() {
348
448
  init_http();
@@ -466,6 +566,7 @@ var init_openai = __esm({
466
566
  }));
467
567
  }
468
568
  };
569
+ BILLING_BODY_MARKERS2 = ["credit balance", "billing", "insufficient_quota", "insufficient"];
469
570
  OPENAI_PROVIDER = {
470
571
  id: "openai",
471
572
  displayName: "OpenAI (GPT)",
@@ -474,6 +575,7 @@ var init_openai = __esm({
474
575
  fallbackModels: FALLBACK_MODELS2,
475
576
  fetchModels: fetchModels2,
476
577
  verifyKey: verifyKey2,
578
+ verifyKeyDeep: verifyKeyDeep2,
477
579
  createClient: (config) => new OpenAIClient({
478
580
  apiKey: config.apiKey,
479
581
  model: config.model ?? DEFAULT_MODEL2,
@@ -526,7 +628,7 @@ function createLlmClient(config) {
526
628
  logUsage: config.logUsage
527
629
  });
528
630
  }
529
- async function verifyLlmApiKey(provider, apiKey, signal) {
631
+ async function verifyLlmApiKeyDeep(provider, apiKey, model, signal) {
530
632
  const descriptor = getLlmProvider(provider);
531
633
  if (!descriptor) {
532
634
  return {
@@ -535,7 +637,7 @@ async function verifyLlmApiKey(provider, apiKey, signal) {
535
637
  error: `Unknown LLM provider "${provider}"`
536
638
  };
537
639
  }
538
- return descriptor.verifyKey(apiKey, signal);
640
+ return descriptor.verifyKeyDeep(apiKey, model, signal);
539
641
  }
540
642
  var init_llm = __esm({
541
643
  "src/llm/index.ts"() {
@@ -1546,13 +1648,14 @@ var GLOBAL_MAX_JOBS_PER_WINDOW = 200;
1546
1648
  var MAX_TRACKED_CUSTOMERS = 1e3;
1547
1649
  var GLOBAL_LIMITER_KEY = "__global__";
1548
1650
  var AgentRuntime = class {
1549
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}) {
1651
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor) {
1550
1652
  this.transport = transport;
1551
1653
  this.skills = skills;
1552
1654
  this.skillCtx = skillCtx;
1553
1655
  this.config = config;
1554
1656
  this.ledger = ledger;
1555
1657
  this.callbacks = callbacks;
1658
+ this.healthMonitor = healthMonitor;
1556
1659
  this.limit = pLimit(config.maxConcurrentJobs);
1557
1660
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
1558
1661
  }
@@ -1577,6 +1680,13 @@ var AgentRuntime = class {
1577
1680
  maxPerWindow: GLOBAL_MAX_JOBS_PER_WINDOW,
1578
1681
  maxKeys: 1
1579
1682
  });
1683
+ /**
1684
+ * Two-tier limiter applied only to free LLM skills (mode='llm', price=0).
1685
+ * Existing per-customer + global limiters above remain in effect for all
1686
+ * jobs; this set adds tighter caps to prevent unpaid spam from draining
1687
+ * the operator's API key.
1688
+ */
1689
+ freeLlmLimiters = createFreeLlmLimiterSet();
1580
1690
  /** Fetch on-chain protocol config (fee, treasury). Always fetches fresh to avoid stale treasury. */
1581
1691
  async fetchProtocolConfig() {
1582
1692
  if (this.config.network !== "devnet") {
@@ -1633,8 +1743,33 @@ var AgentRuntime = class {
1633
1743
  });
1634
1744
  return;
1635
1745
  }
1746
+ const matched = this.skills.route(job.tags);
1747
+ const isFreeLlm = matched?.mode === "llm" && matched.priceSubunits === 0;
1748
+ let perCustomerLimiter;
1749
+ let perSkillKey;
1750
+ if (isFreeLlm && matched) {
1751
+ if (!this.freeLlmLimiters.globalLimiter.peek(FREE_LLM_GLOBAL_KEY).allowed) {
1752
+ this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
1753
+ });
1754
+ return;
1755
+ }
1756
+ perCustomerLimiter = this.freeLlmLimiters.getPerCustomerLimiter(
1757
+ matched.name,
1758
+ matched.rateLimit
1759
+ );
1760
+ perSkillKey = freeLlmCustomerKey(job.customerId, matched.name);
1761
+ if (!perCustomerLimiter.peek(perSkillKey).allowed) {
1762
+ this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
1763
+ });
1764
+ return;
1765
+ }
1766
+ }
1636
1767
  this.customerLimiter.check(job.customerId);
1637
1768
  this.globalLimiter.check(GLOBAL_LIMITER_KEY);
1769
+ if (isFreeLlm && perCustomerLimiter && perSkillKey) {
1770
+ this.freeLlmLimiters.globalLimiter.check(FREE_LLM_GLOBAL_KEY);
1771
+ perCustomerLimiter.check(perSkillKey);
1772
+ }
1638
1773
  this.callbacks.onJobReceived?.(job);
1639
1774
  this.inFlight.add(job.jobId);
1640
1775
  this.pending++;
@@ -1662,10 +1797,12 @@ var AgentRuntime = class {
1662
1797
  process.on("SIGTERM", onSignal);
1663
1798
  });
1664
1799
  }
1665
- /** Drop expired hits from both sliding-window limiters. */
1800
+ /** Drop expired hits from every sliding-window limiter. */
1666
1801
  cleanupRateLimits() {
1667
1802
  this.customerLimiter.prune();
1668
1803
  this.globalLimiter.prune();
1804
+ this.freeLlmLimiters.globalLimiter.prune();
1805
+ this.freeLlmLimiters.prunePerCustomer();
1669
1806
  }
1670
1807
  stop() {
1671
1808
  if (this.stopped) {
@@ -1729,6 +1866,24 @@ var AgentRuntime = class {
1729
1866
  if (job.input.length > LIMITS.MAX_INPUT_LENGTH) {
1730
1867
  throw new Error(`Input too long: ${job.input.length} chars (max ${LIMITS.MAX_INPUT_LENGTH})`);
1731
1868
  }
1869
+ const matched = this.skills.route(job.tags);
1870
+ if (this.healthMonitor && matched && matched.mode === "llm" && matched.resolvedTriple) {
1871
+ try {
1872
+ await this.healthMonitor.assertReady(
1873
+ matched.resolvedTriple.provider,
1874
+ matched.resolvedTriple.model
1875
+ );
1876
+ } catch (err) {
1877
+ const detail = err instanceof Error ? err.message : String(err);
1878
+ log(`[${job.jobId.slice(0, 8)}] LLM health gate refused job: ${detail}`);
1879
+ await this.transport.sendFeedback(job, {
1880
+ type: "error",
1881
+ message: "Service temporarily unavailable, try again later"
1882
+ }).catch(() => {
1883
+ });
1884
+ return;
1885
+ }
1886
+ }
1732
1887
  const jobPrice = resolveJobPrice(job.tags, this.skills);
1733
1888
  const jobAsset = resolveJobAsset(job.tags, this.skills);
1734
1889
  let netAmount;
@@ -2325,9 +2480,10 @@ var ScriptSkill = class {
2325
2480
 
2326
2481
  // src/skill/loader.ts
2327
2482
  function buildCliSkill(parsed, entryPath) {
2483
+ let skill;
2328
2484
  switch (parsed.mode) {
2329
2485
  case "llm":
2330
- return new ScriptSkill(
2486
+ skill = new ScriptSkill(
2331
2487
  parsed.name,
2332
2488
  parsed.description,
2333
2489
  parsed.capabilities,
@@ -2341,6 +2497,7 @@ function buildCliSkill(parsed, entryPath) {
2341
2497
  parsed.maxToolRounds,
2342
2498
  parsed.llmOverride
2343
2499
  );
2500
+ break;
2344
2501
  case "static-file": {
2345
2502
  if (parsed.outputFile === void 0) {
2346
2503
  throw new Error(
@@ -2353,7 +2510,7 @@ function buildCliSkill(parsed, entryPath) {
2353
2510
  `SKILL.md "${parsed.name}": "output_file" must stay inside the skill directory`
2354
2511
  );
2355
2512
  }
2356
- return new StaticFileSkill({
2513
+ skill = new StaticFileSkill({
2357
2514
  name: parsed.name,
2358
2515
  description: parsed.description,
2359
2516
  capabilities: parsed.capabilities,
@@ -2364,6 +2521,7 @@ function buildCliSkill(parsed, entryPath) {
2364
2521
  imageFile: parsed.imageFile,
2365
2522
  dir: entryPath
2366
2523
  });
2524
+ break;
2367
2525
  }
2368
2526
  case "static-script":
2369
2527
  case "dynamic-script": {
@@ -2377,7 +2535,7 @@ function buildCliSkill(parsed, entryPath) {
2377
2535
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
2378
2536
  }
2379
2537
  const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
2380
- return new Ctor({
2538
+ skill = new Ctor({
2381
2539
  name: parsed.name,
2382
2540
  description: parsed.description,
2383
2541
  capabilities: parsed.capabilities,
@@ -2390,8 +2548,13 @@ function buildCliSkill(parsed, entryPath) {
2390
2548
  imageFile: parsed.imageFile,
2391
2549
  dir: entryPath
2392
2550
  });
2551
+ break;
2393
2552
  }
2394
2553
  }
2554
+ if (parsed.rateLimit) {
2555
+ skill.rateLimit = parsed.rateLimit;
2556
+ }
2557
+ return skill;
2395
2558
  }
2396
2559
  function loadSkillsFromDir(skillsDir) {
2397
2560
  const skills = [];
@@ -2797,6 +2960,7 @@ async function cmdStart(nameArg, options = {}) {
2797
2960
  const dependentSkillsByProvider = /* @__PURE__ */ new Map();
2798
2961
  let agentDefaultCacheKey;
2799
2962
  const llmClientCache = /* @__PURE__ */ new Map();
2963
+ const healthMonitor = new LlmHealthMonitor();
2800
2964
  if (llmSkills.length > 0) {
2801
2965
  const resolutionErrors = [];
2802
2966
  for (const skill of llmSkills) {
@@ -2811,6 +2975,11 @@ async function cmdStart(nameArg, options = {}) {
2811
2975
  }
2812
2976
  const cacheKey = cacheKeyFor(result);
2813
2977
  triplesByKey.set(cacheKey, result);
2978
+ skill.resolvedTriple = {
2979
+ provider: result.provider,
2980
+ model: result.model,
2981
+ maxTokens: result.maxTokens
2982
+ };
2814
2983
  const list = dependentSkillsByProvider.get(result.provider) ?? [];
2815
2984
  list.push(skill.name);
2816
2985
  dependentSkillsByProvider.set(result.provider, list);
@@ -2854,28 +3023,47 @@ async function cmdStart(nameArg, options = {}) {
2854
3023
  console.error("");
2855
3024
  process.exit(1);
2856
3025
  }
2857
- for (const provider of keyByProvider.keys()) {
2858
- const apiKey = keyByProvider.get(provider);
3026
+ for (const [, triple] of triplesByKey) {
3027
+ const apiKey = keyByProvider.get(triple.provider);
2859
3028
  if (!apiKey) {
2860
3029
  continue;
2861
3030
  }
2862
- const envVar = getLlmProvider(provider)?.envVar ?? `${provider.toUpperCase()}_API_KEY`;
2863
- process.stdout.write(` Verifying ${provider} API key... `);
2864
- const verification = await verifyLlmApiKey(provider, apiKey);
3031
+ const envVar = getLlmProvider(triple.provider)?.envVar ?? `${triple.provider.toUpperCase()}_API_KEY`;
3032
+ process.stdout.write(` Verifying ${triple.provider} ${triple.model}... `);
3033
+ const verification = await verifyLlmApiKeyDeep(triple.provider, apiKey, triple.model);
3034
+ const descriptor = getLlmProvider(triple.provider);
3035
+ const verifyFn = async (signal) => descriptor ? descriptor.verifyKeyDeep(apiKey, triple.model, signal) : { ok: false, reason: "unavailable", error: "no descriptor" };
3036
+ healthMonitor.register({
3037
+ provider: triple.provider,
3038
+ model: triple.model,
3039
+ verifyFn
3040
+ });
3041
+ healthMonitor.seed(triple.provider, triple.model, verification);
2865
3042
  if (verification.ok) {
2866
3043
  console.log("ok");
2867
3044
  } else if (verification.reason === "invalid") {
2868
3045
  console.log("INVALID");
2869
- console.error(` ! ${provider} rejected the API key (HTTP ${verification.status}).`);
3046
+ console.error(` ! ${triple.provider} rejected the API key (HTTP ${verification.status}).`);
2870
3047
  console.error(
2871
3048
  ` Update it via \`npx @elisym/cli profile ${agentName}\` or set ${envVar} to a valid key.
3049
+ `
3050
+ );
3051
+ process.exit(1);
3052
+ } else if (verification.reason === "billing") {
3053
+ console.log("BILLING");
3054
+ const detail = verification.body ? ` ${verification.body.slice(0, 200)}` : "";
3055
+ console.error(
3056
+ ` ! ${triple.provider} reports a billing/quota issue for ${triple.model}.${detail}`
3057
+ );
3058
+ console.error(
3059
+ ` Top up the account at the provider console, or set ${envVar} to a key on a funded org.
2872
3060
  `
2873
3061
  );
2874
3062
  process.exit(1);
2875
3063
  } else {
2876
3064
  console.log("unavailable");
2877
3065
  console.warn(
2878
- ` ! Could not verify ${provider} API key (${verification.error}). Continuing - jobs will fail if the key is invalid.
3066
+ ` ! Could not verify ${triple.provider} ${triple.model} (${verification.error}). Continuing - jobs will fail if the key is invalid.
2879
3067
  `
2880
3068
  );
2881
3069
  }
@@ -3093,28 +3281,45 @@ async function cmdStart(nameArg, options = {}) {
3093
3281
  log: diagLog,
3094
3282
  logger
3095
3283
  });
3096
- const runtime = new AgentRuntime(transport, registry, skillCtx, runtimeConfig, ledger, {
3097
- onJobReceived: (job) => {
3098
- const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
3099
- process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
3284
+ let llmHeartbeat;
3285
+ if (llmSkills.length > 0) {
3286
+ llmHeartbeat = startLlmHeartbeat({
3287
+ monitor: healthMonitor,
3288
+ log: diagLog
3289
+ });
3290
+ diagLog("LLM health monitor armed (10min TTL, 10min heartbeat).");
3291
+ }
3292
+ const runtime = new AgentRuntime(
3293
+ transport,
3294
+ registry,
3295
+ skillCtx,
3296
+ runtimeConfig,
3297
+ ledger,
3298
+ {
3299
+ onJobReceived: (job) => {
3300
+ const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
3301
+ process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
3100
3302
  `);
3101
- logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
3102
- },
3103
- onJobCompleted: (jobId) => {
3104
- process.stdout.write(` [job] ${jobId.slice(0, 16)} | delivered
3303
+ logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
3304
+ },
3305
+ onJobCompleted: (jobId) => {
3306
+ process.stdout.write(` [job] ${jobId.slice(0, 16)} | delivered
3105
3307
  `);
3106
- logger.info({ event: "job_delivered", jobId });
3107
- },
3108
- onJobError: (jobId, error) => {
3109
- process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
3308
+ logger.info({ event: "job_delivered", jobId });
3309
+ },
3310
+ onJobError: (jobId, error) => {
3311
+ process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
3110
3312
  `);
3111
- logger.error({ event: "job_error", jobId, error });
3313
+ logger.error({ event: "job_error", jobId, error });
3314
+ },
3315
+ onLog: diagLog,
3316
+ onStop: () => {
3317
+ watchdog.stop();
3318
+ llmHeartbeat?.stop();
3319
+ }
3112
3320
  },
3113
- onLog: diagLog,
3114
- onStop: () => {
3115
- watchdog.stop();
3116
- }
3117
- });
3321
+ healthMonitor
3322
+ );
3118
3323
  console.log(" * Running. Press Ctrl+C to stop.\n");
3119
3324
  await runtime.run();
3120
3325
  }