@elisym/cli 0.10.0 → 0.11.1

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,
@@ -288,6 +338,290 @@ var init_anthropic = __esm({
288
338
  }
289
339
  });
290
340
 
341
+ // src/llm/providers/openai-compatible.ts
342
+ function createOpenAICompatibleProvider(config) {
343
+ const billingMarkers = [...DEFAULT_BILLING_MARKERS, ...config.extraBillingMarkers ?? []];
344
+ function bodyLooksLikeBilling3(body) {
345
+ const lower = body.toLowerCase();
346
+ return billingMarkers.some((marker) => lower.includes(marker));
347
+ }
348
+ async function fetchModels4(apiKey, signal) {
349
+ try {
350
+ const response = await fetchWithTimeout(
351
+ `${config.baseUrl}/models`,
352
+ { method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
353
+ signal
354
+ );
355
+ if (!response.ok) {
356
+ return config.fallbackModels;
357
+ }
358
+ const data = await response.json();
359
+ const transformed = [];
360
+ for (const entry of data.data ?? []) {
361
+ const mapped = config.mapModelId ? config.mapModelId(entry.id) : entry.id;
362
+ if (mapped) {
363
+ transformed.push(mapped);
364
+ }
365
+ }
366
+ transformed.sort();
367
+ return transformed.length > 0 ? transformed : config.fallbackModels;
368
+ } catch {
369
+ return config.fallbackModels;
370
+ }
371
+ }
372
+ async function verifyKey3(apiKey, signal) {
373
+ try {
374
+ const response = await fetchWithTimeout(
375
+ `${config.baseUrl}/models`,
376
+ { method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
377
+ signal
378
+ );
379
+ if (response.ok) {
380
+ await response.body?.cancel().catch(() => void 0);
381
+ return { ok: true };
382
+ }
383
+ const body = (await response.text().catch(() => "")).slice(0, 500);
384
+ if (response.status === 401 || response.status === 403) {
385
+ return { ok: false, reason: "invalid", status: response.status, body };
386
+ }
387
+ return {
388
+ ok: false,
389
+ reason: "unavailable",
390
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
391
+ };
392
+ } catch (error) {
393
+ const message = error instanceof Error ? error.message : String(error);
394
+ return { ok: false, reason: "unavailable", error: message };
395
+ }
396
+ }
397
+ async function verifyKeyDeep3(apiKey, model, signal) {
398
+ try {
399
+ const response = await fetchWithTimeout(
400
+ `${config.baseUrl}/chat/completions`,
401
+ {
402
+ method: "POST",
403
+ headers: {
404
+ "Content-Type": "application/json",
405
+ Authorization: `Bearer ${apiKey}`
406
+ },
407
+ body: JSON.stringify({
408
+ model,
409
+ max_tokens: 1,
410
+ messages: [{ role: "user", content: "." }]
411
+ })
412
+ },
413
+ signal
414
+ );
415
+ if (response.ok) {
416
+ await response.body?.cancel().catch(() => void 0);
417
+ return { ok: true };
418
+ }
419
+ const body = (await response.text().catch(() => "")).slice(0, 500);
420
+ if (response.status === 401 || response.status === 403) {
421
+ return { ok: false, reason: "invalid", status: response.status, body };
422
+ }
423
+ if (response.status === 402) {
424
+ return { ok: false, reason: "billing", status: response.status, body };
425
+ }
426
+ if ((response.status === 400 || response.status === 429) && bodyLooksLikeBilling3(body)) {
427
+ return { ok: false, reason: "billing", status: response.status, body };
428
+ }
429
+ return {
430
+ ok: false,
431
+ reason: "unavailable",
432
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
433
+ };
434
+ } catch (error) {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ return { ok: false, reason: "unavailable", error: message };
437
+ }
438
+ }
439
+ function createClient(clientConfig) {
440
+ return new OpenAICompatibleClient({
441
+ apiKey: clientConfig.apiKey,
442
+ baseUrl: config.baseUrl,
443
+ model: clientConfig.model ?? config.defaultModel,
444
+ maxTokens: clientConfig.maxTokens ?? DEFAULT_MAX_TOKENS2,
445
+ providerLabel: config.displayName,
446
+ logUsage: clientConfig.logUsage
447
+ });
448
+ }
449
+ return {
450
+ id: config.id,
451
+ displayName: config.displayName,
452
+ envVar: config.envVar,
453
+ defaultModel: config.defaultModel,
454
+ fallbackModels: config.fallbackModels,
455
+ fetchModels: fetchModels4,
456
+ verifyKey: verifyKey3,
457
+ verifyKeyDeep: verifyKeyDeep3,
458
+ createClient
459
+ };
460
+ }
461
+ var DEFAULT_MAX_TOKENS2, DEFAULT_BILLING_MARKERS, OpenAICompatibleClient;
462
+ var init_openai_compatible = __esm({
463
+ "src/llm/providers/openai-compatible.ts"() {
464
+ init_http();
465
+ DEFAULT_MAX_TOKENS2 = 4096;
466
+ DEFAULT_BILLING_MARKERS = ["credit balance", "billing", "insufficient_quota", "insufficient"];
467
+ OpenAICompatibleClient = class {
468
+ constructor(config) {
469
+ this.config = config;
470
+ }
471
+ totalIn = 0;
472
+ totalOut = 0;
473
+ logTokens(usage) {
474
+ if (!this.config.logUsage || !usage) {
475
+ return;
476
+ }
477
+ const inputTokens = usage.prompt_tokens ?? 0;
478
+ const outputTokens = usage.completion_tokens ?? 0;
479
+ this.totalIn += inputTokens;
480
+ this.totalOut += outputTokens;
481
+ console.log(
482
+ ` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
483
+ );
484
+ }
485
+ async complete(systemPrompt, userInput, signal) {
486
+ const response = await fetchWithRetry(
487
+ `${this.config.baseUrl}/chat/completions`,
488
+ {
489
+ method: "POST",
490
+ headers: {
491
+ "Content-Type": "application/json",
492
+ Authorization: `Bearer ${this.config.apiKey}`
493
+ },
494
+ body: JSON.stringify({
495
+ model: this.config.model,
496
+ max_tokens: this.config.maxTokens,
497
+ messages: [
498
+ { role: "system", content: systemPrompt },
499
+ { role: "user", content: userInput }
500
+ ]
501
+ })
502
+ },
503
+ signal
504
+ );
505
+ if (!response.ok) {
506
+ throw new Error(
507
+ `${this.config.providerLabel} API error: ${response.status} ${await response.text()}`
508
+ );
509
+ }
510
+ const data = await response.json();
511
+ this.logTokens(data.usage);
512
+ return data.choices?.[0]?.message?.content ?? "";
513
+ }
514
+ async completeWithTools(systemPrompt, messages, tools, signal) {
515
+ const openaiTools = tools.map((tool) => ({
516
+ type: "function",
517
+ function: {
518
+ name: tool.name,
519
+ description: tool.description,
520
+ parameters: {
521
+ type: "object",
522
+ properties: Object.fromEntries(
523
+ tool.parameters.map((param) => [
524
+ param.name,
525
+ { type: "string", description: param.description }
526
+ ])
527
+ ),
528
+ required: tool.parameters.filter((param) => param.required).map((param) => param.name)
529
+ }
530
+ }
531
+ }));
532
+ const response = await fetchWithRetry(
533
+ `${this.config.baseUrl}/chat/completions`,
534
+ {
535
+ method: "POST",
536
+ headers: {
537
+ "Content-Type": "application/json",
538
+ Authorization: `Bearer ${this.config.apiKey}`
539
+ },
540
+ body: JSON.stringify({
541
+ model: this.config.model,
542
+ max_tokens: this.config.maxTokens,
543
+ messages: [{ role: "system", content: systemPrompt }, ...messages],
544
+ tools: openaiTools
545
+ })
546
+ },
547
+ signal
548
+ );
549
+ if (!response.ok) {
550
+ throw new Error(
551
+ `${this.config.providerLabel} API error: ${response.status} ${await response.text()}`
552
+ );
553
+ }
554
+ const data = await response.json();
555
+ this.logTokens(data.usage);
556
+ const message = data.choices?.[0]?.message;
557
+ const toolCalls = message?.tool_calls ?? [];
558
+ if (toolCalls.length > 0) {
559
+ const calls = toolCalls.map((call) => {
560
+ let args;
561
+ try {
562
+ args = JSON.parse(call.function?.arguments ?? "{}");
563
+ } catch {
564
+ args = {};
565
+ }
566
+ return { id: call.id ?? "", name: call.function?.name ?? "", arguments: args };
567
+ });
568
+ return { type: "tool_use", calls, assistantMessage: message };
569
+ }
570
+ return { type: "text", text: message?.content ?? "" };
571
+ }
572
+ formatToolResultMessages(results) {
573
+ return results.map((result) => ({
574
+ role: "tool",
575
+ tool_call_id: result.callId,
576
+ content: result.content
577
+ }));
578
+ }
579
+ };
580
+ }
581
+ });
582
+
583
+ // src/llm/providers/deepseek.ts
584
+ var DEFAULT_MODEL2, FALLBACK_MODELS2, DEEPSEEK_PROVIDER;
585
+ var init_deepseek = __esm({
586
+ "src/llm/providers/deepseek.ts"() {
587
+ init_openai_compatible();
588
+ DEFAULT_MODEL2 = "deepseek-chat";
589
+ FALLBACK_MODELS2 = ["deepseek-chat", "deepseek-reasoner"];
590
+ DEEPSEEK_PROVIDER = createOpenAICompatibleProvider({
591
+ id: "deepseek",
592
+ displayName: "DeepSeek",
593
+ envVar: "DEEPSEEK_API_KEY",
594
+ baseUrl: "https://api.deepseek.com/v1",
595
+ defaultModel: DEFAULT_MODEL2,
596
+ fallbackModels: FALLBACK_MODELS2
597
+ });
598
+ }
599
+ });
600
+
601
+ // src/llm/providers/google.ts
602
+ function mapGeminiModelId(id) {
603
+ const stripped = id.startsWith(MODELS_PREFIX) ? id.slice(MODELS_PREFIX.length) : id;
604
+ return stripped.startsWith("gemini") ? stripped : null;
605
+ }
606
+ var DEFAULT_MODEL3, FALLBACK_MODELS3, MODELS_PREFIX, GOOGLE_PROVIDER;
607
+ var init_google = __esm({
608
+ "src/llm/providers/google.ts"() {
609
+ init_openai_compatible();
610
+ DEFAULT_MODEL3 = "gemini-2.5-flash";
611
+ FALLBACK_MODELS3 = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"];
612
+ MODELS_PREFIX = "models/";
613
+ GOOGLE_PROVIDER = createOpenAICompatibleProvider({
614
+ id: "google",
615
+ displayName: "Google (Gemini)",
616
+ envVar: "GEMINI_API_KEY",
617
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
618
+ defaultModel: DEFAULT_MODEL3,
619
+ fallbackModels: FALLBACK_MODELS3,
620
+ mapModelId: mapGeminiModelId
621
+ });
622
+ }
623
+ });
624
+
291
625
  // src/llm/providers/openai.ts
292
626
  function isOpenAIReasoningModel(model) {
293
627
  return /^o\d/.test(model) || /^gpt-5(\b|[-.])/.test(model);
@@ -303,15 +637,15 @@ async function fetchModels2(apiKey, signal) {
303
637
  signal
304
638
  );
305
639
  if (!response.ok) {
306
- return FALLBACK_MODELS2;
640
+ return FALLBACK_MODELS4;
307
641
  }
308
642
  const data = await response.json();
309
643
  const models = (data.data ?? []).map((entry) => entry.id).filter(
310
644
  (id) => (id.startsWith("gpt-") || id.startsWith("o1") || id.startsWith("o3") || id.startsWith("o4") || id.startsWith("chatgpt-")) && !id.includes("instruct") && !id.includes("realtime") && !id.includes("audio") && !id.includes("tts") && !id.includes("whisper")
311
645
  ).sort();
312
- return models.length > 0 ? models : FALLBACK_MODELS2;
646
+ return models.length > 0 ? models : FALLBACK_MODELS4;
313
647
  } catch {
314
- return FALLBACK_MODELS2;
648
+ return FALLBACK_MODELS4;
315
649
  }
316
650
  }
317
651
  async function verifyKey2(apiKey, signal) {
@@ -342,13 +676,63 @@ async function verifyKey2(apiKey, signal) {
342
676
  return { ok: false, reason: "unavailable", error: message };
343
677
  }
344
678
  }
345
- var DEFAULT_MODEL2, DEFAULT_MAX_TOKENS2, FALLBACK_MODELS2, OpenAIClient, OPENAI_PROVIDER;
679
+ function bodyLooksLikeBilling2(body) {
680
+ const lower = body.toLowerCase();
681
+ return BILLING_BODY_MARKERS2.some((marker) => lower.includes(marker));
682
+ }
683
+ async function verifyKeyDeep2(apiKey, model, signal) {
684
+ try {
685
+ const reasoning = isOpenAIReasoningModel(model);
686
+ const response = await fetchWithTimeout(
687
+ "https://api.openai.com/v1/chat/completions",
688
+ {
689
+ method: "POST",
690
+ headers: {
691
+ "Content-Type": "application/json",
692
+ Authorization: `Bearer ${apiKey}`
693
+ },
694
+ body: JSON.stringify({
695
+ model,
696
+ ...reasoning ? { max_completion_tokens: 1 } : { max_tokens: 1 },
697
+ messages: [{ role: "user", content: "." }]
698
+ })
699
+ },
700
+ signal
701
+ );
702
+ if (response.ok) {
703
+ await response.body?.cancel().catch(() => void 0);
704
+ return { ok: true };
705
+ }
706
+ const body = (await response.text().catch(() => "")).slice(0, 500);
707
+ if (response.status === 401 || response.status === 403) {
708
+ return { ok: false, reason: "invalid", status: response.status, body };
709
+ }
710
+ if (response.status === 402) {
711
+ return { ok: false, reason: "billing", status: response.status, body };
712
+ }
713
+ if (response.status === 429 && bodyLooksLikeBilling2(body)) {
714
+ return { ok: false, reason: "billing", status: response.status, body };
715
+ }
716
+ if (response.status === 400 && bodyLooksLikeBilling2(body)) {
717
+ return { ok: false, reason: "billing", status: response.status, body };
718
+ }
719
+ return {
720
+ ok: false,
721
+ reason: "unavailable",
722
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
723
+ };
724
+ } catch (error) {
725
+ const message = error instanceof Error ? error.message : String(error);
726
+ return { ok: false, reason: "unavailable", error: message };
727
+ }
728
+ }
729
+ var DEFAULT_MODEL4, DEFAULT_MAX_TOKENS3, FALLBACK_MODELS4, OpenAIClient, BILLING_BODY_MARKERS2, OPENAI_PROVIDER;
346
730
  var init_openai = __esm({
347
731
  "src/llm/providers/openai.ts"() {
348
732
  init_http();
349
- DEFAULT_MODEL2 = "gpt-4o-mini";
350
- DEFAULT_MAX_TOKENS2 = 4096;
351
- FALLBACK_MODELS2 = ["gpt-4o", "gpt-4o-mini", "o3-mini"];
733
+ DEFAULT_MODEL4 = "gpt-4o-mini";
734
+ DEFAULT_MAX_TOKENS3 = 4096;
735
+ FALLBACK_MODELS4 = ["gpt-4o", "gpt-4o-mini", "o3-mini"];
352
736
  OpenAIClient = class {
353
737
  constructor(config) {
354
738
  this.config = config;
@@ -466,18 +850,20 @@ var init_openai = __esm({
466
850
  }));
467
851
  }
468
852
  };
853
+ BILLING_BODY_MARKERS2 = ["credit balance", "billing", "insufficient_quota", "insufficient"];
469
854
  OPENAI_PROVIDER = {
470
855
  id: "openai",
471
856
  displayName: "OpenAI (GPT)",
472
857
  envVar: "OPENAI_API_KEY",
473
- defaultModel: DEFAULT_MODEL2,
474
- fallbackModels: FALLBACK_MODELS2,
858
+ defaultModel: DEFAULT_MODEL4,
859
+ fallbackModels: FALLBACK_MODELS4,
475
860
  fetchModels: fetchModels2,
476
861
  verifyKey: verifyKey2,
862
+ verifyKeyDeep: verifyKeyDeep2,
477
863
  createClient: (config) => new OpenAIClient({
478
864
  apiKey: config.apiKey,
479
- model: config.model ?? DEFAULT_MODEL2,
480
- maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS2,
865
+ model: config.model ?? DEFAULT_MODEL4,
866
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS3,
481
867
  logUsage: config.logUsage
482
868
  }),
483
869
  isReasoningModel: isOpenAIReasoningModel
@@ -485,6 +871,24 @@ var init_openai = __esm({
485
871
  }
486
872
  });
487
873
 
874
+ // src/llm/providers/xai.ts
875
+ var DEFAULT_MODEL5, FALLBACK_MODELS5, XAI_PROVIDER;
876
+ var init_xai = __esm({
877
+ "src/llm/providers/xai.ts"() {
878
+ init_openai_compatible();
879
+ DEFAULT_MODEL5 = "grok-3-mini";
880
+ FALLBACK_MODELS5 = ["grok-4", "grok-3", "grok-3-mini"];
881
+ XAI_PROVIDER = createOpenAICompatibleProvider({
882
+ id: "xai",
883
+ displayName: "xAI (Grok)",
884
+ envVar: "XAI_API_KEY",
885
+ baseUrl: "https://api.x.ai/v1",
886
+ defaultModel: DEFAULT_MODEL5,
887
+ fallbackModels: FALLBACK_MODELS5
888
+ });
889
+ }
890
+ });
891
+
488
892
  // src/llm/registry.ts
489
893
  function registerLlmProvider(descriptor) {
490
894
  REGISTRY.set(descriptor.id, descriptor);
@@ -502,10 +906,16 @@ var REGISTRY;
502
906
  var init_registry = __esm({
503
907
  "src/llm/registry.ts"() {
504
908
  init_anthropic();
909
+ init_deepseek();
910
+ init_google();
505
911
  init_openai();
912
+ init_xai();
506
913
  REGISTRY = /* @__PURE__ */ new Map();
507
914
  registerLlmProvider(ANTHROPIC_PROVIDER);
508
915
  registerLlmProvider(OPENAI_PROVIDER);
916
+ registerLlmProvider(XAI_PROVIDER);
917
+ registerLlmProvider(GOOGLE_PROVIDER);
918
+ registerLlmProvider(DEEPSEEK_PROVIDER);
509
919
  }
510
920
  });
511
921
 
@@ -526,7 +936,7 @@ function createLlmClient(config) {
526
936
  logUsage: config.logUsage
527
937
  });
528
938
  }
529
- async function verifyLlmApiKey(provider, apiKey, signal) {
939
+ async function verifyLlmApiKeyDeep(provider, apiKey, model, signal) {
530
940
  const descriptor = getLlmProvider(provider);
531
941
  if (!descriptor) {
532
942
  return {
@@ -535,7 +945,7 @@ async function verifyLlmApiKey(provider, apiKey, signal) {
535
945
  error: `Unknown LLM provider "${provider}"`
536
946
  };
537
947
  }
538
- return descriptor.verifyKey(apiKey, signal);
948
+ return descriptor.verifyKeyDeep(apiKey, model, signal);
539
949
  }
540
950
  var init_llm = __esm({
541
951
  "src/llm/index.ts"() {
@@ -1408,7 +1818,7 @@ var JobLedger = class {
1408
1818
  init_llm();
1409
1819
 
1410
1820
  // src/llm/resolve.ts
1411
- var DEFAULT_MAX_TOKENS3 = 4096;
1821
+ var DEFAULT_MAX_TOKENS4 = 4096;
1412
1822
  function resolveSkillLlm(input, agentDefault) {
1413
1823
  const override = input.llmOverride;
1414
1824
  const overridePairSet = override?.provider !== void 0 && override.model !== void 0;
@@ -1426,7 +1836,7 @@ function resolveSkillLlm(input, agentDefault) {
1426
1836
  error: `Skill "${input.skillName}" at ${input.skillMdPath}: LLM model is required - declare "provider" + "model" in the SKILL.md frontmatter or set agent-level llm via 'npx @elisym/cli profile <agent>'.`
1427
1837
  };
1428
1838
  }
1429
- const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS3;
1839
+ const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS4;
1430
1840
  return { provider, model, maxTokens };
1431
1841
  }
1432
1842
 
@@ -1452,7 +1862,7 @@ function resolveTripleForOverride(override, agentDefault) {
1452
1862
  if (!provider || !model) {
1453
1863
  return void 0;
1454
1864
  }
1455
- const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS3;
1865
+ const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS4;
1456
1866
  return { provider, model, maxTokens };
1457
1867
  }
1458
1868
 
@@ -1546,13 +1956,14 @@ var GLOBAL_MAX_JOBS_PER_WINDOW = 200;
1546
1956
  var MAX_TRACKED_CUSTOMERS = 1e3;
1547
1957
  var GLOBAL_LIMITER_KEY = "__global__";
1548
1958
  var AgentRuntime = class {
1549
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}) {
1959
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor) {
1550
1960
  this.transport = transport;
1551
1961
  this.skills = skills;
1552
1962
  this.skillCtx = skillCtx;
1553
1963
  this.config = config;
1554
1964
  this.ledger = ledger;
1555
1965
  this.callbacks = callbacks;
1966
+ this.healthMonitor = healthMonitor;
1556
1967
  this.limit = pLimit(config.maxConcurrentJobs);
1557
1968
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
1558
1969
  }
@@ -1577,6 +1988,13 @@ var AgentRuntime = class {
1577
1988
  maxPerWindow: GLOBAL_MAX_JOBS_PER_WINDOW,
1578
1989
  maxKeys: 1
1579
1990
  });
1991
+ /**
1992
+ * Two-tier limiter applied only to free LLM skills (mode='llm', price=0).
1993
+ * Existing per-customer + global limiters above remain in effect for all
1994
+ * jobs; this set adds tighter caps to prevent unpaid spam from draining
1995
+ * the operator's API key.
1996
+ */
1997
+ freeLlmLimiters = createFreeLlmLimiterSet();
1580
1998
  /** Fetch on-chain protocol config (fee, treasury). Always fetches fresh to avoid stale treasury. */
1581
1999
  async fetchProtocolConfig() {
1582
2000
  if (this.config.network !== "devnet") {
@@ -1633,8 +2051,33 @@ var AgentRuntime = class {
1633
2051
  });
1634
2052
  return;
1635
2053
  }
2054
+ const matched = this.skills.route(job.tags);
2055
+ const isFreeLlm = matched?.mode === "llm" && matched.priceSubunits === 0;
2056
+ let perCustomerLimiter;
2057
+ let perSkillKey;
2058
+ if (isFreeLlm && matched) {
2059
+ if (!this.freeLlmLimiters.globalLimiter.peek(FREE_LLM_GLOBAL_KEY).allowed) {
2060
+ this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
2061
+ });
2062
+ return;
2063
+ }
2064
+ perCustomerLimiter = this.freeLlmLimiters.getPerCustomerLimiter(
2065
+ matched.name,
2066
+ matched.rateLimit
2067
+ );
2068
+ perSkillKey = freeLlmCustomerKey(job.customerId, matched.name);
2069
+ if (!perCustomerLimiter.peek(perSkillKey).allowed) {
2070
+ this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
2071
+ });
2072
+ return;
2073
+ }
2074
+ }
1636
2075
  this.customerLimiter.check(job.customerId);
1637
2076
  this.globalLimiter.check(GLOBAL_LIMITER_KEY);
2077
+ if (isFreeLlm && perCustomerLimiter && perSkillKey) {
2078
+ this.freeLlmLimiters.globalLimiter.check(FREE_LLM_GLOBAL_KEY);
2079
+ perCustomerLimiter.check(perSkillKey);
2080
+ }
1638
2081
  this.callbacks.onJobReceived?.(job);
1639
2082
  this.inFlight.add(job.jobId);
1640
2083
  this.pending++;
@@ -1662,10 +2105,12 @@ var AgentRuntime = class {
1662
2105
  process.on("SIGTERM", onSignal);
1663
2106
  });
1664
2107
  }
1665
- /** Drop expired hits from both sliding-window limiters. */
2108
+ /** Drop expired hits from every sliding-window limiter. */
1666
2109
  cleanupRateLimits() {
1667
2110
  this.customerLimiter.prune();
1668
2111
  this.globalLimiter.prune();
2112
+ this.freeLlmLimiters.globalLimiter.prune();
2113
+ this.freeLlmLimiters.prunePerCustomer();
1669
2114
  }
1670
2115
  stop() {
1671
2116
  if (this.stopped) {
@@ -1729,6 +2174,24 @@ var AgentRuntime = class {
1729
2174
  if (job.input.length > LIMITS.MAX_INPUT_LENGTH) {
1730
2175
  throw new Error(`Input too long: ${job.input.length} chars (max ${LIMITS.MAX_INPUT_LENGTH})`);
1731
2176
  }
2177
+ const matched = this.skills.route(job.tags);
2178
+ if (this.healthMonitor && matched && matched.mode === "llm" && matched.resolvedTriple) {
2179
+ try {
2180
+ await this.healthMonitor.assertReady(
2181
+ matched.resolvedTriple.provider,
2182
+ matched.resolvedTriple.model
2183
+ );
2184
+ } catch (err) {
2185
+ const detail = err instanceof Error ? err.message : String(err);
2186
+ log(`[${job.jobId.slice(0, 8)}] LLM health gate refused job: ${detail}`);
2187
+ await this.transport.sendFeedback(job, {
2188
+ type: "error",
2189
+ message: "Service temporarily unavailable, try again later"
2190
+ }).catch(() => {
2191
+ });
2192
+ return;
2193
+ }
2194
+ }
1732
2195
  const jobPrice = resolveJobPrice(job.tags, this.skills);
1733
2196
  const jobAsset = resolveJobAsset(job.tags, this.skills);
1734
2197
  let netAmount;
@@ -2325,9 +2788,10 @@ var ScriptSkill = class {
2325
2788
 
2326
2789
  // src/skill/loader.ts
2327
2790
  function buildCliSkill(parsed, entryPath) {
2791
+ let skill;
2328
2792
  switch (parsed.mode) {
2329
2793
  case "llm":
2330
- return new ScriptSkill(
2794
+ skill = new ScriptSkill(
2331
2795
  parsed.name,
2332
2796
  parsed.description,
2333
2797
  parsed.capabilities,
@@ -2341,6 +2805,7 @@ function buildCliSkill(parsed, entryPath) {
2341
2805
  parsed.maxToolRounds,
2342
2806
  parsed.llmOverride
2343
2807
  );
2808
+ break;
2344
2809
  case "static-file": {
2345
2810
  if (parsed.outputFile === void 0) {
2346
2811
  throw new Error(
@@ -2353,7 +2818,7 @@ function buildCliSkill(parsed, entryPath) {
2353
2818
  `SKILL.md "${parsed.name}": "output_file" must stay inside the skill directory`
2354
2819
  );
2355
2820
  }
2356
- return new StaticFileSkill({
2821
+ skill = new StaticFileSkill({
2357
2822
  name: parsed.name,
2358
2823
  description: parsed.description,
2359
2824
  capabilities: parsed.capabilities,
@@ -2364,6 +2829,7 @@ function buildCliSkill(parsed, entryPath) {
2364
2829
  imageFile: parsed.imageFile,
2365
2830
  dir: entryPath
2366
2831
  });
2832
+ break;
2367
2833
  }
2368
2834
  case "static-script":
2369
2835
  case "dynamic-script": {
@@ -2377,7 +2843,7 @@ function buildCliSkill(parsed, entryPath) {
2377
2843
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
2378
2844
  }
2379
2845
  const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
2380
- return new Ctor({
2846
+ skill = new Ctor({
2381
2847
  name: parsed.name,
2382
2848
  description: parsed.description,
2383
2849
  capabilities: parsed.capabilities,
@@ -2390,8 +2856,13 @@ function buildCliSkill(parsed, entryPath) {
2390
2856
  imageFile: parsed.imageFile,
2391
2857
  dir: entryPath
2392
2858
  });
2859
+ break;
2393
2860
  }
2394
2861
  }
2862
+ if (parsed.rateLimit) {
2863
+ skill.rateLimit = parsed.rateLimit;
2864
+ }
2865
+ return skill;
2395
2866
  }
2396
2867
  function loadSkillsFromDir(skillsDir) {
2397
2868
  const skills = [];
@@ -2797,6 +3268,7 @@ async function cmdStart(nameArg, options = {}) {
2797
3268
  const dependentSkillsByProvider = /* @__PURE__ */ new Map();
2798
3269
  let agentDefaultCacheKey;
2799
3270
  const llmClientCache = /* @__PURE__ */ new Map();
3271
+ const healthMonitor = new LlmHealthMonitor();
2800
3272
  if (llmSkills.length > 0) {
2801
3273
  const resolutionErrors = [];
2802
3274
  for (const skill of llmSkills) {
@@ -2811,6 +3283,11 @@ async function cmdStart(nameArg, options = {}) {
2811
3283
  }
2812
3284
  const cacheKey = cacheKeyFor(result);
2813
3285
  triplesByKey.set(cacheKey, result);
3286
+ skill.resolvedTriple = {
3287
+ provider: result.provider,
3288
+ model: result.model,
3289
+ maxTokens: result.maxTokens
3290
+ };
2814
3291
  const list = dependentSkillsByProvider.get(result.provider) ?? [];
2815
3292
  list.push(skill.name);
2816
3293
  dependentSkillsByProvider.set(result.provider, list);
@@ -2854,28 +3331,47 @@ async function cmdStart(nameArg, options = {}) {
2854
3331
  console.error("");
2855
3332
  process.exit(1);
2856
3333
  }
2857
- for (const provider of keyByProvider.keys()) {
2858
- const apiKey = keyByProvider.get(provider);
3334
+ for (const [, triple] of triplesByKey) {
3335
+ const apiKey = keyByProvider.get(triple.provider);
2859
3336
  if (!apiKey) {
2860
3337
  continue;
2861
3338
  }
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);
3339
+ const envVar = getLlmProvider(triple.provider)?.envVar ?? `${triple.provider.toUpperCase()}_API_KEY`;
3340
+ process.stdout.write(` Verifying ${triple.provider} ${triple.model}... `);
3341
+ const verification = await verifyLlmApiKeyDeep(triple.provider, apiKey, triple.model);
3342
+ const descriptor = getLlmProvider(triple.provider);
3343
+ const verifyFn = async (signal) => descriptor ? descriptor.verifyKeyDeep(apiKey, triple.model, signal) : { ok: false, reason: "unavailable", error: "no descriptor" };
3344
+ healthMonitor.register({
3345
+ provider: triple.provider,
3346
+ model: triple.model,
3347
+ verifyFn
3348
+ });
3349
+ healthMonitor.seed(triple.provider, triple.model, verification);
2865
3350
  if (verification.ok) {
2866
3351
  console.log("ok");
2867
3352
  } else if (verification.reason === "invalid") {
2868
3353
  console.log("INVALID");
2869
- console.error(` ! ${provider} rejected the API key (HTTP ${verification.status}).`);
3354
+ console.error(` ! ${triple.provider} rejected the API key (HTTP ${verification.status}).`);
2870
3355
  console.error(
2871
3356
  ` Update it via \`npx @elisym/cli profile ${agentName}\` or set ${envVar} to a valid key.
3357
+ `
3358
+ );
3359
+ process.exit(1);
3360
+ } else if (verification.reason === "billing") {
3361
+ console.log("BILLING");
3362
+ const detail = verification.body ? ` ${verification.body.slice(0, 200)}` : "";
3363
+ console.error(
3364
+ ` ! ${triple.provider} reports a billing/quota issue for ${triple.model}.${detail}`
3365
+ );
3366
+ console.error(
3367
+ ` Top up the account at the provider console, or set ${envVar} to a key on a funded org.
2872
3368
  `
2873
3369
  );
2874
3370
  process.exit(1);
2875
3371
  } else {
2876
3372
  console.log("unavailable");
2877
3373
  console.warn(
2878
- ` ! Could not verify ${provider} API key (${verification.error}). Continuing - jobs will fail if the key is invalid.
3374
+ ` ! Could not verify ${triple.provider} ${triple.model} (${verification.error}). Continuing - jobs will fail if the key is invalid.
2879
3375
  `
2880
3376
  );
2881
3377
  }
@@ -3093,28 +3589,45 @@ async function cmdStart(nameArg, options = {}) {
3093
3589
  log: diagLog,
3094
3590
  logger
3095
3591
  });
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}
3592
+ let llmHeartbeat;
3593
+ if (llmSkills.length > 0) {
3594
+ llmHeartbeat = startLlmHeartbeat({
3595
+ monitor: healthMonitor,
3596
+ log: diagLog
3597
+ });
3598
+ diagLog("LLM health monitor armed (10min TTL, 10min heartbeat).");
3599
+ }
3600
+ const runtime = new AgentRuntime(
3601
+ transport,
3602
+ registry,
3603
+ skillCtx,
3604
+ runtimeConfig,
3605
+ ledger,
3606
+ {
3607
+ onJobReceived: (job) => {
3608
+ const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
3609
+ process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
3100
3610
  `);
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
3611
+ logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
3612
+ },
3613
+ onJobCompleted: (jobId) => {
3614
+ process.stdout.write(` [job] ${jobId.slice(0, 16)} | delivered
3105
3615
  `);
3106
- logger.info({ event: "job_delivered", jobId });
3107
- },
3108
- onJobError: (jobId, error) => {
3109
- process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
3616
+ logger.info({ event: "job_delivered", jobId });
3617
+ },
3618
+ onJobError: (jobId, error) => {
3619
+ process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${error}
3110
3620
  `);
3111
- logger.error({ event: "job_error", jobId, error });
3621
+ logger.error({ event: "job_error", jobId, error });
3622
+ },
3623
+ onLog: diagLog,
3624
+ onStop: () => {
3625
+ watchdog.stop();
3626
+ llmHeartbeat?.stop();
3627
+ }
3112
3628
  },
3113
- onLog: diagLog,
3114
- onStop: () => {
3115
- watchdog.stop();
3116
- }
3117
- });
3629
+ healthMonitor
3630
+ );
3118
3631
  console.log(" * Running. Press Ctrl+C to stop.\n");
3119
3632
  await runtime.run();
3120
3633
  }