@elisym/cli 0.9.1 → 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';
@@ -25,42 +26,643 @@ var __export = (target, all) => {
25
26
  __defProp(target, name, { get: all[name], enumerable: true });
26
27
  };
27
28
 
28
- // src/commands/init.ts
29
- var init_exports = {};
30
- __export(init_exports, {
31
- cmdInit: () => cmdInit,
32
- fetchModels: () => fetchModels
29
+ // src/llm/providers/http.ts
30
+ function createAbortError() {
31
+ const err = new Error("The operation was aborted");
32
+ err.name = "AbortError";
33
+ return err;
34
+ }
35
+ function sleepWithSignal(ms, signal) {
36
+ if (signal?.aborted) {
37
+ return Promise.reject(createAbortError());
38
+ }
39
+ if (!signal) {
40
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
41
+ }
42
+ return new Promise((resolve3, reject) => {
43
+ const cleanup = () => {
44
+ clearTimeout(timer);
45
+ signal.removeEventListener("abort", onAbort);
46
+ };
47
+ const onAbort = () => {
48
+ cleanup();
49
+ reject(createAbortError());
50
+ };
51
+ const timer = setTimeout(() => {
52
+ cleanup();
53
+ resolve3();
54
+ }, ms);
55
+ signal.addEventListener("abort", onAbort, { once: true });
56
+ });
57
+ }
58
+ async function fetchWithTimeout(url, init, signal) {
59
+ if (signal?.aborted) {
60
+ throw createAbortError();
61
+ }
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
64
+ const onAbort = () => controller.abort();
65
+ signal?.addEventListener("abort", onAbort, { once: true });
66
+ try {
67
+ return await fetch(url, { ...init, signal: controller.signal });
68
+ } finally {
69
+ clearTimeout(timer);
70
+ signal?.removeEventListener("abort", onAbort);
71
+ }
72
+ }
73
+ async function fetchWithRetry(url, init, signal) {
74
+ for (let attempt = 0; ; attempt++) {
75
+ let response;
76
+ try {
77
+ response = await fetchWithTimeout(url, init, signal);
78
+ } catch (error) {
79
+ const name = error instanceof Error ? error.name : "";
80
+ if (attempt >= MAX_RETRIES || name === "AbortError") {
81
+ throw error;
82
+ }
83
+ await sleepWithSignal(Math.min(1e3 * 2 ** attempt, 8e3), signal);
84
+ continue;
85
+ }
86
+ if (response.ok || attempt >= MAX_RETRIES || !RETRYABLE_STATUSES.has(response.status)) {
87
+ return response;
88
+ }
89
+ const retryAfter = response.headers.get("retry-after");
90
+ const delay = retryAfter ? Math.min(parseInt(retryAfter, 10) * 1e3 || 1e3 * 2 ** attempt, 3e4) : Math.min(1e3 * 2 ** attempt, 8e3);
91
+ await response.body?.cancel().catch(() => void 0);
92
+ await sleepWithSignal(delay, signal);
93
+ }
94
+ }
95
+ var LLM_TIMEOUT_MS, MAX_RETRIES, RETRYABLE_STATUSES;
96
+ var init_http = __esm({
97
+ "src/llm/providers/http.ts"() {
98
+ LLM_TIMEOUT_MS = 12e4;
99
+ MAX_RETRIES = 2;
100
+ RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
101
+ }
33
102
  });
34
- async function fetchModels(provider, apiKey) {
103
+
104
+ // src/llm/providers/anthropic.ts
105
+ async function fetchModels(apiKey, signal) {
35
106
  try {
36
- if (provider === "anthropic") {
37
- const res = await fetch("https://api.anthropic.com/v1/models?limit=1000", {
107
+ const response = await fetchWithTimeout(
108
+ "https://api.anthropic.com/v1/models?limit=1000",
109
+ {
110
+ method: "GET",
38
111
  headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
39
- });
40
- if (!res.ok) {
41
- throw new Error(`${res.status}`);
42
- }
43
- const data = await res.json();
44
- const models = (data.data ?? []).map((m) => m.id).sort();
45
- return models.length > 0 ? models : FALLBACK_MODELS.anthropic;
112
+ },
113
+ signal
114
+ );
115
+ if (!response.ok) {
116
+ return FALLBACK_MODELS;
46
117
  }
47
- if (provider === "openai") {
48
- const res = await fetch("https://api.openai.com/v1/models", {
49
- headers: { Authorization: `Bearer ${apiKey}` }
50
- });
51
- if (!res.ok) {
52
- throw new Error(`${res.status}`);
118
+ const data = await response.json();
119
+ const models = (data.data ?? []).map((entry) => entry.id).sort();
120
+ return models.length > 0 ? models : FALLBACK_MODELS;
121
+ } catch {
122
+ return FALLBACK_MODELS;
123
+ }
124
+ }
125
+ async function verifyKey(apiKey, signal) {
126
+ try {
127
+ const response = await fetchWithTimeout(
128
+ "https://api.anthropic.com/v1/models?limit=1",
129
+ {
130
+ method: "GET",
131
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
132
+ },
133
+ signal
134
+ );
135
+ if (response.ok) {
136
+ await response.body?.cancel().catch(() => void 0);
137
+ return { ok: true };
138
+ }
139
+ const body = (await response.text().catch(() => "")).slice(0, 500);
140
+ if (response.status === 401 || response.status === 403) {
141
+ return { ok: false, reason: "invalid", status: response.status, body };
142
+ }
143
+ return {
144
+ ok: false,
145
+ reason: "unavailable",
146
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
147
+ };
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ return { ok: false, reason: "unavailable", error: message };
151
+ }
152
+ }
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;
201
+ var init_anthropic = __esm({
202
+ "src/llm/providers/anthropic.ts"() {
203
+ init_http();
204
+ DEFAULT_MODEL = "claude-haiku-4-5-20251001";
205
+ DEFAULT_MAX_TOKENS = 4096;
206
+ FALLBACK_MODELS = ["claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-6"];
207
+ AnthropicClient = class {
208
+ constructor(config) {
209
+ this.config = config;
210
+ }
211
+ totalIn = 0;
212
+ totalOut = 0;
213
+ logTokens(usage) {
214
+ if (!this.config.logUsage || !usage) {
215
+ return;
216
+ }
217
+ const inputTokens = usage.input_tokens ?? 0;
218
+ const outputTokens = usage.output_tokens ?? 0;
219
+ this.totalIn += inputTokens;
220
+ this.totalOut += outputTokens;
221
+ console.log(
222
+ ` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
223
+ );
53
224
  }
54
- const data = await res.json();
55
- const models = (data.data ?? []).map((m) => m.id).filter(
56
- (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")
57
- ).sort();
58
- return models.length > 0 ? models : FALLBACK_MODELS.openai;
225
+ async complete(systemPrompt, userInput, signal) {
226
+ const response = await fetchWithRetry(
227
+ "https://api.anthropic.com/v1/messages",
228
+ {
229
+ method: "POST",
230
+ headers: {
231
+ "Content-Type": "application/json",
232
+ "x-api-key": this.config.apiKey,
233
+ "anthropic-version": "2023-06-01"
234
+ },
235
+ body: JSON.stringify({
236
+ model: this.config.model,
237
+ max_tokens: this.config.maxTokens,
238
+ system: systemPrompt,
239
+ messages: [{ role: "user", content: userInput }]
240
+ })
241
+ },
242
+ signal
243
+ );
244
+ if (!response.ok) {
245
+ throw new Error(`Anthropic API error: ${response.status} ${await response.text()}`);
246
+ }
247
+ const data = await response.json();
248
+ this.logTokens(data.usage);
249
+ const textBlock = data.content?.find((block) => block.type === "text");
250
+ return textBlock?.text ?? "";
251
+ }
252
+ async completeWithTools(systemPrompt, messages, tools, signal) {
253
+ const anthropicTools = tools.map((tool) => ({
254
+ name: tool.name,
255
+ description: tool.description,
256
+ input_schema: {
257
+ type: "object",
258
+ properties: Object.fromEntries(
259
+ tool.parameters.map((param) => [
260
+ param.name,
261
+ { type: "string", description: param.description }
262
+ ])
263
+ ),
264
+ required: tool.parameters.filter((param) => param.required).map((param) => param.name)
265
+ }
266
+ }));
267
+ const response = await fetchWithRetry(
268
+ "https://api.anthropic.com/v1/messages",
269
+ {
270
+ method: "POST",
271
+ headers: {
272
+ "Content-Type": "application/json",
273
+ "x-api-key": this.config.apiKey,
274
+ "anthropic-version": "2023-06-01"
275
+ },
276
+ body: JSON.stringify({
277
+ model: this.config.model,
278
+ max_tokens: this.config.maxTokens,
279
+ system: systemPrompt,
280
+ messages,
281
+ tools: anthropicTools
282
+ })
283
+ },
284
+ signal
285
+ );
286
+ if (!response.ok) {
287
+ throw new Error(`Anthropic API error: ${response.status} ${await response.text()}`);
288
+ }
289
+ const data = await response.json();
290
+ this.logTokens(data.usage);
291
+ const content = data.content ?? [];
292
+ const toolUses = content.filter((block) => block.type === "tool_use");
293
+ if (toolUses.length > 0) {
294
+ const calls = toolUses.map((block) => ({
295
+ id: block.id ?? "",
296
+ name: block.name ?? "",
297
+ arguments: block.input ?? {}
298
+ }));
299
+ return {
300
+ type: "tool_use",
301
+ calls,
302
+ assistantMessage: { role: "assistant", content }
303
+ };
304
+ }
305
+ const textBlock = content.find((block) => block.type === "text");
306
+ return { type: "text", text: textBlock?.text ?? "" };
307
+ }
308
+ formatToolResultMessages(results) {
309
+ return [
310
+ {
311
+ role: "user",
312
+ content: results.map((result) => ({
313
+ type: "tool_result",
314
+ tool_use_id: result.callId,
315
+ content: result.content
316
+ }))
317
+ }
318
+ ];
319
+ }
320
+ };
321
+ BILLING_BODY_MARKERS = ["credit balance", "billing", "insufficient"];
322
+ ANTHROPIC_PROVIDER = {
323
+ id: "anthropic",
324
+ displayName: "Anthropic (Claude)",
325
+ envVar: "ANTHROPIC_API_KEY",
326
+ defaultModel: DEFAULT_MODEL,
327
+ fallbackModels: FALLBACK_MODELS,
328
+ fetchModels,
329
+ verifyKey,
330
+ verifyKeyDeep,
331
+ createClient: (config) => new AnthropicClient({
332
+ apiKey: config.apiKey,
333
+ model: config.model ?? DEFAULT_MODEL,
334
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
335
+ logUsage: config.logUsage
336
+ })
337
+ };
338
+ }
339
+ });
340
+
341
+ // src/llm/providers/openai.ts
342
+ function isOpenAIReasoningModel(model) {
343
+ return /^o\d/.test(model) || /^gpt-5(\b|[-.])/.test(model);
344
+ }
345
+ async function fetchModels2(apiKey, signal) {
346
+ try {
347
+ const response = await fetchWithTimeout(
348
+ "https://api.openai.com/v1/models",
349
+ {
350
+ method: "GET",
351
+ headers: { Authorization: `Bearer ${apiKey}` }
352
+ },
353
+ signal
354
+ );
355
+ if (!response.ok) {
356
+ return FALLBACK_MODELS2;
357
+ }
358
+ const data = await response.json();
359
+ const models = (data.data ?? []).map((entry) => entry.id).filter(
360
+ (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")
361
+ ).sort();
362
+ return models.length > 0 ? models : FALLBACK_MODELS2;
363
+ } catch {
364
+ return FALLBACK_MODELS2;
365
+ }
366
+ }
367
+ async function verifyKey2(apiKey, signal) {
368
+ try {
369
+ const response = await fetchWithTimeout(
370
+ "https://api.openai.com/v1/models",
371
+ {
372
+ method: "GET",
373
+ headers: { Authorization: `Bearer ${apiKey}` }
374
+ },
375
+ signal
376
+ );
377
+ if (response.ok) {
378
+ await response.body?.cancel().catch(() => void 0);
379
+ return { ok: true };
380
+ }
381
+ const body = (await response.text().catch(() => "")).slice(0, 500);
382
+ if (response.status === 401 || response.status === 403) {
383
+ return { ok: false, reason: "invalid", status: response.status, body };
384
+ }
385
+ return {
386
+ ok: false,
387
+ reason: "unavailable",
388
+ error: `HTTP ${response.status}: ${body.slice(0, 200)}`
389
+ };
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ return { ok: false, reason: "unavailable", error: message };
393
+ }
394
+ }
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 };
59
434
  }
60
- return ["gpt-4o"];
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;
446
+ var init_openai = __esm({
447
+ "src/llm/providers/openai.ts"() {
448
+ init_http();
449
+ DEFAULT_MODEL2 = "gpt-4o-mini";
450
+ DEFAULT_MAX_TOKENS2 = 4096;
451
+ FALLBACK_MODELS2 = ["gpt-4o", "gpt-4o-mini", "o3-mini"];
452
+ OpenAIClient = class {
453
+ constructor(config) {
454
+ this.config = config;
455
+ }
456
+ totalIn = 0;
457
+ totalOut = 0;
458
+ isReasoningModel() {
459
+ return isOpenAIReasoningModel(this.config.model);
460
+ }
461
+ logTokens(usage) {
462
+ if (!this.config.logUsage || !usage) {
463
+ return;
464
+ }
465
+ const inputTokens = usage.prompt_tokens ?? 0;
466
+ const outputTokens = usage.completion_tokens ?? 0;
467
+ this.totalIn += inputTokens;
468
+ this.totalOut += outputTokens;
469
+ console.log(
470
+ ` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
471
+ );
472
+ }
473
+ async complete(systemPrompt, userInput, signal) {
474
+ const reasoning = this.isReasoningModel();
475
+ const response = await fetchWithRetry(
476
+ "https://api.openai.com/v1/chat/completions",
477
+ {
478
+ method: "POST",
479
+ headers: {
480
+ "Content-Type": "application/json",
481
+ Authorization: `Bearer ${this.config.apiKey}`
482
+ },
483
+ body: JSON.stringify({
484
+ model: this.config.model,
485
+ ...reasoning ? { max_completion_tokens: this.config.maxTokens } : { max_tokens: this.config.maxTokens },
486
+ messages: [
487
+ { role: reasoning ? "developer" : "system", content: systemPrompt },
488
+ { role: "user", content: userInput }
489
+ ]
490
+ })
491
+ },
492
+ signal
493
+ );
494
+ if (!response.ok) {
495
+ throw new Error(`OpenAI API error: ${response.status} ${await response.text()}`);
496
+ }
497
+ const data = await response.json();
498
+ this.logTokens(data.usage);
499
+ return data.choices?.[0]?.message?.content ?? "";
500
+ }
501
+ async completeWithTools(systemPrompt, messages, tools, signal) {
502
+ const openaiTools = tools.map((tool) => ({
503
+ type: "function",
504
+ function: {
505
+ name: tool.name,
506
+ description: tool.description,
507
+ parameters: {
508
+ type: "object",
509
+ properties: Object.fromEntries(
510
+ tool.parameters.map((param) => [
511
+ param.name,
512
+ { type: "string", description: param.description }
513
+ ])
514
+ ),
515
+ required: tool.parameters.filter((param) => param.required).map((param) => param.name)
516
+ }
517
+ }
518
+ }));
519
+ const reasoning = this.isReasoningModel();
520
+ const response = await fetchWithRetry(
521
+ "https://api.openai.com/v1/chat/completions",
522
+ {
523
+ method: "POST",
524
+ headers: {
525
+ "Content-Type": "application/json",
526
+ Authorization: `Bearer ${this.config.apiKey}`
527
+ },
528
+ body: JSON.stringify({
529
+ model: this.config.model,
530
+ ...reasoning ? { max_completion_tokens: this.config.maxTokens } : { max_tokens: this.config.maxTokens },
531
+ messages: [
532
+ { role: reasoning ? "developer" : "system", content: systemPrompt },
533
+ ...messages
534
+ ],
535
+ tools: openaiTools
536
+ })
537
+ },
538
+ signal
539
+ );
540
+ if (!response.ok) {
541
+ throw new Error(`OpenAI API error: ${response.status} ${await response.text()}`);
542
+ }
543
+ const data = await response.json();
544
+ this.logTokens(data.usage);
545
+ const message = data.choices?.[0]?.message;
546
+ const toolCalls = message?.tool_calls ?? [];
547
+ if (toolCalls.length > 0) {
548
+ const calls = toolCalls.map((call) => {
549
+ let args;
550
+ try {
551
+ args = JSON.parse(call.function?.arguments ?? "{}");
552
+ } catch {
553
+ args = {};
554
+ }
555
+ return { id: call.id ?? "", name: call.function?.name ?? "", arguments: args };
556
+ });
557
+ return { type: "tool_use", calls, assistantMessage: message };
558
+ }
559
+ return { type: "text", text: message?.content ?? "" };
560
+ }
561
+ formatToolResultMessages(results) {
562
+ return results.map((result) => ({
563
+ role: "tool",
564
+ tool_call_id: result.callId,
565
+ content: result.content
566
+ }));
567
+ }
568
+ };
569
+ BILLING_BODY_MARKERS2 = ["credit balance", "billing", "insufficient_quota", "insufficient"];
570
+ OPENAI_PROVIDER = {
571
+ id: "openai",
572
+ displayName: "OpenAI (GPT)",
573
+ envVar: "OPENAI_API_KEY",
574
+ defaultModel: DEFAULT_MODEL2,
575
+ fallbackModels: FALLBACK_MODELS2,
576
+ fetchModels: fetchModels2,
577
+ verifyKey: verifyKey2,
578
+ verifyKeyDeep: verifyKeyDeep2,
579
+ createClient: (config) => new OpenAIClient({
580
+ apiKey: config.apiKey,
581
+ model: config.model ?? DEFAULT_MODEL2,
582
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS2,
583
+ logUsage: config.logUsage
584
+ }),
585
+ isReasoningModel: isOpenAIReasoningModel
586
+ };
587
+ }
588
+ });
589
+
590
+ // src/llm/registry.ts
591
+ function registerLlmProvider(descriptor) {
592
+ REGISTRY.set(descriptor.id, descriptor);
593
+ }
594
+ function getLlmProvider(id) {
595
+ return REGISTRY.get(id);
596
+ }
597
+ function listLlmProviders() {
598
+ return Array.from(REGISTRY.values());
599
+ }
600
+ function getRegisteredProviderIds() {
601
+ return Array.from(REGISTRY.keys());
602
+ }
603
+ var REGISTRY;
604
+ var init_registry = __esm({
605
+ "src/llm/registry.ts"() {
606
+ init_anthropic();
607
+ init_openai();
608
+ REGISTRY = /* @__PURE__ */ new Map();
609
+ registerLlmProvider(ANTHROPIC_PROVIDER);
610
+ registerLlmProvider(OPENAI_PROVIDER);
611
+ }
612
+ });
613
+
614
+ // src/llm/index.ts
615
+ function createLlmClient(config) {
616
+ const descriptor = getLlmProvider(config.provider);
617
+ if (!descriptor) {
618
+ const known = getRegisteredProviderIds().join(", ") || "<none>";
619
+ throw new Error(`Unknown LLM provider "${config.provider}". Registered: ${known}.`);
620
+ }
621
+ if (!config.apiKey) {
622
+ throw new Error(`${descriptor.envVar} is required for skill runtime`);
623
+ }
624
+ return descriptor.createClient({
625
+ apiKey: config.apiKey,
626
+ model: config.model,
627
+ maxTokens: config.maxTokens,
628
+ logUsage: config.logUsage
629
+ });
630
+ }
631
+ async function verifyLlmApiKeyDeep(provider, apiKey, model, signal) {
632
+ const descriptor = getLlmProvider(provider);
633
+ if (!descriptor) {
634
+ return {
635
+ ok: false,
636
+ reason: "unavailable",
637
+ error: `Unknown LLM provider "${provider}"`
638
+ };
639
+ }
640
+ return descriptor.verifyKeyDeep(apiKey, model, signal);
641
+ }
642
+ var init_llm = __esm({
643
+ "src/llm/index.ts"() {
644
+ init_registry();
645
+ init_registry();
646
+ }
647
+ });
648
+
649
+ // src/commands/init.ts
650
+ var init_exports = {};
651
+ __export(init_exports, {
652
+ cmdInit: () => cmdInit,
653
+ fetchModels: () => fetchModels3
654
+ });
655
+ async function fetchModels3(provider, apiKey) {
656
+ const descriptor = getLlmProvider(provider);
657
+ if (!descriptor) {
658
+ console.warn(` ! Unknown provider "${provider}". No models available.`);
659
+ return [];
660
+ }
661
+ try {
662
+ return await descriptor.fetchModels(apiKey);
61
663
  } catch (e) {
62
664
  console.warn(` ! Could not fetch models: ${e.message}. Using defaults.`);
63
- return FALLBACK_MODELS[provider] ?? ["gpt-4o"];
665
+ return descriptor.fallbackModels;
64
666
  }
65
667
  }
66
668
  function pickTarget(options) {
@@ -140,55 +742,61 @@ async function cmdInit(nameArg, options = {}) {
140
742
  promptedApiKey = result.apiKey;
141
743
  }
142
744
  let defaultProviderKey;
143
- if (yaml.llm) {
144
- const envKey = yaml.llm.provider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
145
- if (envKey) {
745
+ const defaultProviderId = yaml.llm?.provider;
746
+ if (yaml.llm && defaultProviderId) {
747
+ const descriptor = getLlmProvider(defaultProviderId);
748
+ const envKey = descriptor ? process.env[descriptor.envVar] : void 0;
749
+ if (envKey && descriptor) {
146
750
  defaultProviderKey = envKey;
147
- console.log(
148
- ` Using ${yaml.llm.provider === "anthropic" ? "ANTHROPIC" : "OPENAI"}_API_KEY from environment.`
149
- );
751
+ console.log(` Using ${descriptor.envVar} from environment.`);
150
752
  } else if (promptedApiKey) {
151
753
  defaultProviderKey = promptedApiKey;
152
754
  } else {
755
+ const label = descriptor?.displayName ?? defaultProviderId;
153
756
  const { apiKey } = await inquirer.prompt([
154
757
  {
155
758
  type: "password",
156
759
  name: "apiKey",
157
- message: `${yaml.llm.provider === "anthropic" ? "Anthropic" : "OpenAI"} API key:`,
760
+ message: `${label} API key:`,
158
761
  mask: "*"
159
762
  }
160
763
  ]);
161
764
  defaultProviderKey = apiKey || void 0;
162
765
  }
163
766
  }
164
- let otherProviderKey;
165
- if (yaml.llm && !template) {
166
- const otherProvider = yaml.llm.provider === "anthropic" ? "openai" : "anthropic";
167
- const otherProviderLabel = otherProvider === "anthropic" ? "Anthropic" : "OpenAI";
168
- const otherEnvVar = otherProvider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
169
- const otherEnvKey = process.env[otherEnvVar];
170
- const { configureOther } = await inquirer.prompt([
171
- {
172
- type: "confirm",
173
- name: "configureOther",
174
- message: `Configure ${otherProviderLabel} API key too (for skills that override the default provider)?`,
175
- default: Boolean(otherEnvKey)
767
+ const otherProviderKeys = /* @__PURE__ */ new Map();
768
+ if (yaml.llm && !template && defaultProviderId) {
769
+ const otherDescriptors = listLlmProviders().filter(
770
+ (descriptor) => descriptor.id !== defaultProviderId
771
+ );
772
+ for (const descriptor of otherDescriptors) {
773
+ const otherEnvKey = process.env[descriptor.envVar];
774
+ const { configureOther } = await inquirer.prompt([
775
+ {
776
+ type: "confirm",
777
+ name: "configureOther",
778
+ message: `Configure ${descriptor.displayName} API key too (for skills that override the default provider)?`,
779
+ default: Boolean(otherEnvKey)
780
+ }
781
+ ]);
782
+ if (!configureOther) {
783
+ continue;
176
784
  }
177
- ]);
178
- if (configureOther) {
179
785
  if (otherEnvKey) {
180
- otherProviderKey = otherEnvKey;
181
- console.log(` Using ${otherEnvVar} from environment.`);
786
+ otherProviderKeys.set(descriptor.id, otherEnvKey);
787
+ console.log(` Using ${descriptor.envVar} from environment.`);
182
788
  } else {
183
789
  const { promptedOther } = await inquirer.prompt([
184
790
  {
185
791
  type: "password",
186
792
  name: "promptedOther",
187
- message: `${otherProviderLabel} API key:`,
793
+ message: `${descriptor.displayName} API key:`,
188
794
  mask: "*"
189
795
  }
190
796
  ]);
191
- otherProviderKey = promptedOther || void 0;
797
+ if (promptedOther) {
798
+ otherProviderKeys.set(descriptor.id, promptedOther);
799
+ }
192
800
  }
193
801
  }
194
802
  }
@@ -224,15 +832,17 @@ async function cmdInit(nameArg, options = {}) {
224
832
  const nostrPubkey = getPublicKey(nostrSecretBytes);
225
833
  const nostrSecretHex = Buffer.from(nostrSecretBytes).toString("hex");
226
834
  const created = await createAgentDir({ target, name: agentName, cwd });
227
- const anthropicKey = yaml.llm?.provider === "anthropic" ? defaultProviderKey : otherProviderKey;
228
- const openaiKey = yaml.llm?.provider === "openai" ? defaultProviderKey : otherProviderKey;
835
+ const collectedKeys = new Map(otherProviderKeys);
836
+ if (yaml.llm && defaultProviderKey) {
837
+ collectedKeys.set(yaml.llm.provider, defaultProviderKey);
838
+ }
839
+ const llmApiKeys = Object.fromEntries(collectedKeys);
229
840
  await writeYaml(created.dir, yaml);
230
841
  await writeSecrets(
231
842
  created.dir,
232
843
  {
233
844
  nostr_secret_key: nostrSecretHex,
234
- anthropic_api_key: anthropicKey,
235
- openai_api_key: openaiKey
845
+ llm_api_keys: Object.keys(llmApiKeys).length > 0 ? llmApiKeys : void 0
236
846
  },
237
847
  passphrase || void 0
238
848
  );
@@ -334,8 +944,10 @@ async function promptYaml(inquirer) {
334
944
  name: "None (non-LLM agent - static-file / static-script / dynamic-script only)",
335
945
  value: "none"
336
946
  },
337
- { name: "Anthropic (Claude)", value: "anthropic" },
338
- { name: "OpenAI (GPT)", value: "openai" }
947
+ ...listLlmProviders().map((descriptor2) => ({
948
+ name: descriptor2.displayName,
949
+ value: descriptor2.id
950
+ }))
339
951
  ]
340
952
  }
341
953
  ]);
@@ -352,21 +964,25 @@ async function promptYaml(inquirer) {
352
964
  const yaml2 = ElisymYamlSchema.parse(baseYaml);
353
965
  return { yaml: yaml2 };
354
966
  }
355
- const envKey = llmProvider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
967
+ const descriptor = getLlmProvider(llmProvider);
968
+ if (!descriptor) {
969
+ throw new Error(`Internal: provider "${llmProvider}" not registered.`);
970
+ }
971
+ const envKey = process.env[descriptor.envVar];
356
972
  let apiKey = envKey;
357
973
  if (!apiKey) {
358
974
  const { promptedKey } = await inquirer.prompt([
359
975
  {
360
976
  type: "password",
361
977
  name: "promptedKey",
362
- message: `${llmProvider === "anthropic" ? "Anthropic" : "OpenAI"} API key:`,
978
+ message: `${descriptor.displayName} API key:`,
363
979
  mask: "*"
364
980
  }
365
981
  ]);
366
982
  apiKey = promptedKey || void 0;
367
983
  }
368
984
  console.log(" Fetching available models...");
369
- const models = await fetchModels(llmProvider, apiKey ?? "");
985
+ const models = await fetchModels3(llmProvider, apiKey ?? "");
370
986
  const { model } = await inquirer.prompt([
371
987
  {
372
988
  type: "list",
@@ -389,18 +1005,17 @@ async function promptYaml(inquirer) {
389
1005
  });
390
1006
  return { yaml, apiKey: envKey ? void 0 : apiKey };
391
1007
  }
392
- var FALLBACK_MODELS;
393
1008
  var init_init = __esm({
394
1009
  "src/commands/init.ts"() {
395
- FALLBACK_MODELS = {
396
- anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-6"],
397
- openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"]
398
- };
1010
+ init_llm();
399
1011
  }
400
1012
  });
401
1013
 
402
1014
  // src/index.ts
403
1015
  init_init();
1016
+
1017
+ // src/commands/profile.ts
1018
+ init_llm();
404
1019
  async function cmdProfile(name) {
405
1020
  const { default: inquirer } = await import('inquirer');
406
1021
  const cwd = process.cwd();
@@ -519,32 +1134,45 @@ async function cmdProfile(name) {
519
1134
  console.log(" Wallet updated.\n");
520
1135
  }
521
1136
  if (section === "llm") {
1137
+ const providerChoices = listLlmProviders().map((descriptor) => ({
1138
+ name: descriptor.displayName,
1139
+ value: descriptor.id
1140
+ }));
1141
+ if (providerChoices.length === 0) {
1142
+ console.error(" ! No LLM providers registered.");
1143
+ continue;
1144
+ }
1145
+ const firstChoice = providerChoices[0];
1146
+ if (!firstChoice) {
1147
+ console.error(" ! No LLM providers registered.");
1148
+ continue;
1149
+ }
522
1150
  const { llmProvider } = await inquirer.prompt([
523
1151
  {
524
1152
  type: "list",
525
1153
  name: "llmProvider",
526
1154
  message: "Default LLM provider (used by skills without a `provider:` override):",
527
- choices: [
528
- { name: "Anthropic (Claude)", value: "anthropic" },
529
- { name: "OpenAI (GPT)", value: "openai" }
530
- ],
531
- default: loaded.yaml.llm?.provider ?? "anthropic"
1155
+ choices: providerChoices,
1156
+ default: loaded.yaml.llm?.provider ?? firstChoice.value
532
1157
  }
533
1158
  ]);
534
- const otherProvider = llmProvider === "anthropic" ? "openai" : "anthropic";
1159
+ const defaultDescriptor = getLlmProvider(llmProvider);
1160
+ if (!defaultDescriptor) {
1161
+ throw new Error(`Internal: provider "${llmProvider}" not registered.`);
1162
+ }
535
1163
  const defaultKeyStatus = describeKeyStatus(loaded.secrets, llmProvider);
536
1164
  const { apiKey } = await inquirer.prompt([
537
1165
  {
538
1166
  type: "password",
539
1167
  name: "apiKey",
540
- message: `${labelFor(llmProvider)} API key [${defaultKeyStatus}] (leave empty to keep current):`,
1168
+ message: `${defaultDescriptor.displayName} API key [${defaultKeyStatus}] (leave empty to keep current):`,
541
1169
  mask: "*"
542
1170
  }
543
1171
  ]);
544
1172
  const probeKey = apiKey || pickPlainKey(loaded.secrets, llmProvider);
545
1173
  console.log(" Fetching available models...");
546
- const { fetchModels: fetchModels2 } = await Promise.resolve().then(() => (init_init(), init_exports));
547
- const models = await fetchModels2(llmProvider, probeKey);
1174
+ const { fetchModels: fetchModels4 } = await Promise.resolve().then(() => (init_init(), init_exports));
1175
+ const models = await fetchModels4(llmProvider, probeKey);
548
1176
  const { model } = await inquirer.prompt([
549
1177
  {
550
1178
  type: "list",
@@ -562,35 +1190,42 @@ async function cmdProfile(name) {
562
1190
  default: loaded.yaml.llm?.max_tokens ?? 4096
563
1191
  }
564
1192
  ]);
565
- const otherKeyStatus = describeKeyStatus(loaded.secrets, otherProvider);
566
- const { otherApiKey } = await inquirer.prompt([
567
- {
568
- type: "password",
569
- name: "otherApiKey",
570
- message: `${labelFor(otherProvider)} API key for skill overrides [${otherKeyStatus}] (leave empty to keep current):`,
571
- mask: "*"
1193
+ const otherDescriptors = listLlmProviders().filter(
1194
+ (descriptor) => descriptor.id !== llmProvider
1195
+ );
1196
+ const otherKeys = /* @__PURE__ */ new Map();
1197
+ for (const descriptor of otherDescriptors) {
1198
+ const status = describeKeyStatus(loaded.secrets, descriptor.id);
1199
+ const { otherApiKey } = await inquirer.prompt([
1200
+ {
1201
+ type: "password",
1202
+ name: "otherApiKey",
1203
+ message: `${descriptor.displayName} API key for skill overrides [${status}] (leave empty to keep current):`,
1204
+ mask: "*"
1205
+ }
1206
+ ]);
1207
+ if (otherApiKey) {
1208
+ otherKeys.set(descriptor.id, otherApiKey);
572
1209
  }
573
- ]);
1210
+ }
574
1211
  const nextLlm = { provider: llmProvider, model, max_tokens: maxTokens };
575
1212
  const nextYaml = { ...loaded.yaml, llm: nextLlm };
576
1213
  await writeYaml(loaded.dir, nextYaml);
577
1214
  loaded.yaml = nextYaml;
578
- if (apiKey || otherApiKey) {
579
- const nextSecrets = { ...loaded.secrets };
1215
+ if (apiKey || otherKeys.size > 0) {
1216
+ const nextLlmApiKeys = {
1217
+ ...loaded.secrets.llm_api_keys ?? {}
1218
+ };
580
1219
  if (apiKey) {
581
- if (llmProvider === "anthropic") {
582
- nextSecrets.anthropic_api_key = apiKey;
583
- } else {
584
- nextSecrets.openai_api_key = apiKey;
585
- }
1220
+ nextLlmApiKeys[llmProvider] = apiKey;
586
1221
  }
587
- if (otherApiKey) {
588
- if (otherProvider === "anthropic") {
589
- nextSecrets.anthropic_api_key = otherApiKey;
590
- } else {
591
- nextSecrets.openai_api_key = otherApiKey;
592
- }
1222
+ for (const [providerId, key] of otherKeys) {
1223
+ nextLlmApiKeys[providerId] = key;
593
1224
  }
1225
+ const nextSecrets = {
1226
+ ...loaded.secrets,
1227
+ llm_api_keys: nextLlmApiKeys
1228
+ };
594
1229
  await writeSecrets(loaded.dir, nextSecrets, passphrase);
595
1230
  loaded.secrets = nextSecrets;
596
1231
  }
@@ -606,16 +1241,12 @@ function truncate(value, max = 40) {
606
1241
  }
607
1242
  return value.slice(0, max - 1) + "\u2026";
608
1243
  }
609
- function labelFor(provider) {
610
- return provider === "anthropic" ? "Anthropic" : "OpenAI";
611
- }
612
- function describeKeyStatus(secrets, provider) {
613
- const perProviderField = provider === "anthropic" ? "anthropic_api_key" : "openai_api_key";
614
- return secrets[perProviderField] ? "set" : "not set";
1244
+ function describeKeyStatus(secrets, providerId) {
1245
+ return secrets.llm_api_keys?.[providerId] ? "set" : "not set";
615
1246
  }
616
- function pickPlainKey(secrets, provider) {
617
- const perProviderField = provider === "anthropic" ? "anthropic_api_key" : "openai_api_key";
618
- return secrets[perProviderField] ?? "";
1247
+ function pickPlainKey(secrets, providerId) {
1248
+ const value = secrets.llm_api_keys?.[providerId];
1249
+ return typeof value === "string" ? value : "";
619
1250
  }
620
1251
  var TCP_PROBE_TIMEOUT_MS = 3e3;
621
1252
  function parseRelayUrl(url) {
@@ -875,322 +1506,11 @@ var JobLedger = class {
875
1506
  }
876
1507
  };
877
1508
 
878
- // src/llm/index.ts
879
- var LLM_TIMEOUT_MS = 12e4;
880
- var MAX_RETRIES = 2;
881
- var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
882
- function createAbortError() {
883
- const err = new Error("The operation was aborted");
884
- err.name = "AbortError";
885
- return err;
886
- }
887
- function sleepWithSignal(ms, signal) {
888
- if (signal?.aborted) {
889
- throw createAbortError();
890
- }
891
- if (!signal) {
892
- return new Promise((r) => setTimeout(r, ms));
893
- }
894
- return new Promise((resolve3, reject) => {
895
- const cleanup = () => {
896
- clearTimeout(timer);
897
- signal.removeEventListener("abort", onAbort);
898
- };
899
- const onAbort = () => {
900
- cleanup();
901
- reject(createAbortError());
902
- };
903
- const timer = setTimeout(() => {
904
- cleanup();
905
- resolve3();
906
- }, ms);
907
- signal.addEventListener("abort", onAbort, { once: true });
908
- });
909
- }
910
- async function fetchWithTimeout(url, init, signal) {
911
- if (signal?.aborted) {
912
- throw createAbortError();
913
- }
914
- const controller = new AbortController();
915
- const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
916
- const onAbort = () => controller.abort();
917
- signal?.addEventListener("abort", onAbort, { once: true });
918
- try {
919
- return await fetch(url, { ...init, signal: controller.signal });
920
- } finally {
921
- clearTimeout(timer);
922
- signal?.removeEventListener("abort", onAbort);
923
- }
924
- }
925
- async function fetchWithRetry(url, init, signal) {
926
- for (let attempt = 0; ; attempt++) {
927
- let res;
928
- try {
929
- res = await fetchWithTimeout(url, init, signal);
930
- } catch (e) {
931
- if (attempt >= MAX_RETRIES || e.name === "AbortError") {
932
- throw e;
933
- }
934
- await sleepWithSignal(Math.min(1e3 * 2 ** attempt, 8e3), signal);
935
- continue;
936
- }
937
- if (res.ok || attempt >= MAX_RETRIES || !RETRYABLE_STATUSES.has(res.status)) {
938
- return res;
939
- }
940
- const retryAfter = res.headers.get("retry-after");
941
- const delay = retryAfter ? Math.min(parseInt(retryAfter, 10) * 1e3 || 1e3 * 2 ** attempt, 3e4) : Math.min(1e3 * 2 ** attempt, 8e3);
942
- await sleepWithSignal(delay, signal);
943
- }
944
- }
945
- function isReasoningModel(model) {
946
- return /^o\d/.test(model) || /^gpt-5(\b|[-.])/.test(model);
947
- }
948
- function createLlmClient(config) {
949
- if (config.provider === "anthropic") {
950
- return new AnthropicClient(config);
951
- }
952
- return new OpenAIClient(config);
953
- }
954
- async function verifyLlmApiKey(provider, apiKey, signal) {
955
- const url = provider === "anthropic" ? "https://api.anthropic.com/v1/models?limit=1" : "https://api.openai.com/v1/models";
956
- const headers = provider === "anthropic" ? { "x-api-key": apiKey, "anthropic-version": "2023-06-01" } : { Authorization: `Bearer ${apiKey}` };
957
- try {
958
- const res = await fetchWithTimeout(url, { method: "GET", headers }, signal);
959
- if (res.ok) {
960
- await res.body?.cancel().catch(() => void 0);
961
- return { ok: true };
962
- }
963
- const body = (await res.text().catch(() => "")).slice(0, 500);
964
- if (res.status === 401 || res.status === 403) {
965
- return { ok: false, reason: "invalid", status: res.status, body };
966
- }
967
- return { ok: false, reason: "unavailable", error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
968
- } catch (e) {
969
- return { ok: false, reason: "unavailable", error: e?.message ?? String(e) };
970
- }
971
- }
972
- var AnthropicClient = class {
973
- constructor(config) {
974
- this.config = config;
975
- }
976
- totalIn = 0;
977
- totalOut = 0;
978
- logTokens(usage) {
979
- if (this.config.logUsage && usage) {
980
- this.totalIn += usage.input_tokens ?? 0;
981
- this.totalOut += usage.output_tokens ?? 0;
982
- console.log(
983
- ` [LLM] ${this.config.model} tokens: in=${usage.input_tokens ?? 0} out=${usage.output_tokens ?? 0} (total: in=${this.totalIn} out=${this.totalOut})`
984
- );
985
- }
986
- }
987
- async complete(systemPrompt, userInput, signal) {
988
- const res = await fetchWithRetry(
989
- "https://api.anthropic.com/v1/messages",
990
- {
991
- method: "POST",
992
- headers: {
993
- "Content-Type": "application/json",
994
- "x-api-key": this.config.apiKey,
995
- "anthropic-version": "2023-06-01"
996
- },
997
- body: JSON.stringify({
998
- model: this.config.model,
999
- max_tokens: this.config.maxTokens,
1000
- system: systemPrompt,
1001
- messages: [{ role: "user", content: userInput }]
1002
- })
1003
- },
1004
- signal
1005
- );
1006
- if (!res.ok) {
1007
- throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
1008
- }
1009
- const data = await res.json();
1010
- this.logTokens(data.usage);
1011
- const textBlock = data.content?.find((b) => b.type === "text");
1012
- return textBlock?.text ?? "";
1013
- }
1014
- async completeWithTools(systemPrompt, messages, tools, signal) {
1015
- const anthropicTools = tools.map((t) => ({
1016
- name: t.name,
1017
- description: t.description,
1018
- input_schema: {
1019
- type: "object",
1020
- properties: Object.fromEntries(
1021
- t.parameters.map((p) => [p.name, { type: "string", description: p.description }])
1022
- ),
1023
- required: t.parameters.filter((p) => p.required).map((p) => p.name)
1024
- }
1025
- }));
1026
- const res = await fetchWithRetry(
1027
- "https://api.anthropic.com/v1/messages",
1028
- {
1029
- method: "POST",
1030
- headers: {
1031
- "Content-Type": "application/json",
1032
- "x-api-key": this.config.apiKey,
1033
- "anthropic-version": "2023-06-01"
1034
- },
1035
- body: JSON.stringify({
1036
- model: this.config.model,
1037
- max_tokens: this.config.maxTokens,
1038
- system: systemPrompt,
1039
- messages,
1040
- tools: anthropicTools
1041
- })
1042
- },
1043
- signal
1044
- );
1045
- if (!res.ok) {
1046
- throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
1047
- }
1048
- const data = await res.json();
1049
- this.logTokens(data.usage);
1050
- const content = data.content ?? [];
1051
- const toolUses = content.filter((b) => b.type === "tool_use");
1052
- if (toolUses.length > 0) {
1053
- const calls = toolUses.map((t) => ({
1054
- id: t.id,
1055
- name: t.name,
1056
- arguments: t.input ?? {}
1057
- }));
1058
- return {
1059
- type: "tool_use",
1060
- calls,
1061
- assistantMessage: { role: "assistant", content }
1062
- };
1063
- }
1064
- const textBlock = content.find((b) => b.type === "text");
1065
- return { type: "text", text: textBlock?.text ?? "" };
1066
- }
1067
- formatToolResultMessages(results) {
1068
- return [
1069
- {
1070
- role: "user",
1071
- content: results.map((r) => ({
1072
- type: "tool_result",
1073
- tool_use_id: r.callId,
1074
- content: r.content
1075
- }))
1076
- }
1077
- ];
1078
- }
1079
- };
1080
- var OpenAIClient = class {
1081
- constructor(config) {
1082
- this.config = config;
1083
- }
1084
- totalIn = 0;
1085
- totalOut = 0;
1086
- logTokens(usage) {
1087
- if (this.config.logUsage && usage) {
1088
- this.totalIn += usage.prompt_tokens ?? 0;
1089
- this.totalOut += usage.completion_tokens ?? 0;
1090
- console.log(
1091
- ` [LLM] ${this.config.model} tokens: in=${usage.prompt_tokens ?? 0} out=${usage.completion_tokens ?? 0} (total: in=${this.totalIn} out=${this.totalOut})`
1092
- );
1093
- }
1094
- }
1095
- async complete(systemPrompt, userInput, signal) {
1096
- const isReasoning = isReasoningModel(this.config.model);
1097
- const res = await fetchWithRetry(
1098
- "https://api.openai.com/v1/chat/completions",
1099
- {
1100
- method: "POST",
1101
- headers: {
1102
- "Content-Type": "application/json",
1103
- Authorization: `Bearer ${this.config.apiKey}`
1104
- },
1105
- body: JSON.stringify({
1106
- model: this.config.model,
1107
- ...isReasoning ? { max_completion_tokens: this.config.maxTokens } : { max_tokens: this.config.maxTokens },
1108
- messages: [
1109
- { role: isReasoning ? "developer" : "system", content: systemPrompt },
1110
- { role: "user", content: userInput }
1111
- ]
1112
- })
1113
- },
1114
- signal
1115
- );
1116
- if (!res.ok) {
1117
- throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
1118
- }
1119
- const data = await res.json();
1120
- this.logTokens(data.usage);
1121
- return data.choices?.[0]?.message?.content ?? "";
1122
- }
1123
- async completeWithTools(systemPrompt, messages, tools, signal) {
1124
- const openaiTools = tools.map((t) => ({
1125
- type: "function",
1126
- function: {
1127
- name: t.name,
1128
- description: t.description,
1129
- parameters: {
1130
- type: "object",
1131
- properties: Object.fromEntries(
1132
- t.parameters.map((p) => [p.name, { type: "string", description: p.description }])
1133
- ),
1134
- required: t.parameters.filter((p) => p.required).map((p) => p.name)
1135
- }
1136
- }
1137
- }));
1138
- const isReasoning = isReasoningModel(this.config.model);
1139
- const res = await fetchWithRetry(
1140
- "https://api.openai.com/v1/chat/completions",
1141
- {
1142
- method: "POST",
1143
- headers: {
1144
- "Content-Type": "application/json",
1145
- Authorization: `Bearer ${this.config.apiKey}`
1146
- },
1147
- body: JSON.stringify({
1148
- model: this.config.model,
1149
- ...isReasoning ? { max_completion_tokens: this.config.maxTokens } : { max_tokens: this.config.maxTokens },
1150
- messages: [
1151
- { role: isReasoning ? "developer" : "system", content: systemPrompt },
1152
- ...messages
1153
- ],
1154
- tools: openaiTools
1155
- })
1156
- },
1157
- signal
1158
- );
1159
- if (!res.ok) {
1160
- throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
1161
- }
1162
- const data = await res.json();
1163
- this.logTokens(data.usage);
1164
- const message = data.choices?.[0]?.message;
1165
- if (message?.tool_calls?.length > 0) {
1166
- const calls = message.tool_calls.map((tc) => {
1167
- let args;
1168
- try {
1169
- args = JSON.parse(tc.function.arguments ?? "{}");
1170
- } catch {
1171
- args = {};
1172
- }
1173
- return { id: tc.id, name: tc.function.name, arguments: args };
1174
- });
1175
- return {
1176
- type: "tool_use",
1177
- calls,
1178
- assistantMessage: message
1179
- };
1180
- }
1181
- return { type: "text", text: message?.content ?? "" };
1182
- }
1183
- formatToolResultMessages(results) {
1184
- return results.map((r) => ({
1185
- role: "tool",
1186
- tool_call_id: r.callId,
1187
- content: r.content
1188
- }));
1189
- }
1190
- };
1509
+ // src/commands/start.ts
1510
+ init_llm();
1191
1511
 
1192
1512
  // src/llm/resolve.ts
1193
- var DEFAULT_MAX_TOKENS = 4096;
1513
+ var DEFAULT_MAX_TOKENS3 = 4096;
1194
1514
  function resolveSkillLlm(input, agentDefault) {
1195
1515
  const override = input.llmOverride;
1196
1516
  const overridePairSet = override?.provider !== void 0 && override.model !== void 0;
@@ -1208,7 +1528,7 @@ function resolveSkillLlm(input, agentDefault) {
1208
1528
  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>'.`
1209
1529
  };
1210
1530
  }
1211
- const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS;
1531
+ const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS3;
1212
1532
  return { provider, model, maxTokens };
1213
1533
  }
1214
1534
 
@@ -1234,34 +1554,32 @@ function resolveTripleForOverride(override, agentDefault) {
1234
1554
  if (!provider || !model) {
1235
1555
  return void 0;
1236
1556
  }
1237
- const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS;
1557
+ const maxTokens = override?.maxTokens ?? agentDefault?.max_tokens ?? DEFAULT_MAX_TOKENS3;
1238
1558
  return { provider, model, maxTokens };
1239
1559
  }
1240
1560
 
1241
1561
  // src/llm/keys.ts
1242
- var ENV_VAR_FOR_PROVIDER = {
1243
- anthropic: "ANTHROPIC_API_KEY",
1244
- openai: "OPENAI_API_KEY"
1245
- };
1246
- var SECRET_FIELD_FOR_PROVIDER = {
1247
- anthropic: "anthropic_api_key",
1248
- openai: "openai_api_key"
1249
- };
1562
+ init_registry();
1250
1563
  function resolveProviderApiKey(input) {
1251
1564
  const { provider, secrets, dependentSkills } = input;
1252
- const perProviderField = SECRET_FIELD_FOR_PROVIDER[provider];
1253
- const perProviderValue = secrets[perProviderField];
1254
- if (perProviderValue) {
1565
+ const descriptor = getLlmProvider(provider);
1566
+ if (!descriptor) {
1567
+ const skillList2 = dependentSkills.length > 0 ? dependentSkills.join(", ") : "<none>";
1568
+ return {
1569
+ error: `Provider "${provider}" is not registered (required by skill(s): ${skillList2}).`
1570
+ };
1571
+ }
1572
+ const perProviderValue = secrets.llm_api_keys?.[provider];
1573
+ if (typeof perProviderValue === "string" && perProviderValue.length > 0) {
1255
1574
  return { apiKey: perProviderValue, origin: "secrets-per-provider" };
1256
1575
  }
1257
- const envVar = ENV_VAR_FOR_PROVIDER[provider];
1258
- const envValue = process.env[envVar];
1576
+ const envValue = process.env[descriptor.envVar];
1259
1577
  if (envValue) {
1260
1578
  return { apiKey: envValue, origin: "env" };
1261
1579
  }
1262
1580
  const skillList = dependentSkills.length > 0 ? dependentSkills.join(", ") : "<none>";
1263
1581
  return {
1264
- error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.${perProviderField} via 'npx @elisym/cli profile <agent>' or export ${envVar}.`
1582
+ error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
1265
1583
  };
1266
1584
  }
1267
1585
  function resolveLevel(options) {
@@ -1330,13 +1648,14 @@ var GLOBAL_MAX_JOBS_PER_WINDOW = 200;
1330
1648
  var MAX_TRACKED_CUSTOMERS = 1e3;
1331
1649
  var GLOBAL_LIMITER_KEY = "__global__";
1332
1650
  var AgentRuntime = class {
1333
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}) {
1651
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor) {
1334
1652
  this.transport = transport;
1335
1653
  this.skills = skills;
1336
1654
  this.skillCtx = skillCtx;
1337
1655
  this.config = config;
1338
1656
  this.ledger = ledger;
1339
1657
  this.callbacks = callbacks;
1658
+ this.healthMonitor = healthMonitor;
1340
1659
  this.limit = pLimit(config.maxConcurrentJobs);
1341
1660
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
1342
1661
  }
@@ -1361,6 +1680,13 @@ var AgentRuntime = class {
1361
1680
  maxPerWindow: GLOBAL_MAX_JOBS_PER_WINDOW,
1362
1681
  maxKeys: 1
1363
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();
1364
1690
  /** Fetch on-chain protocol config (fee, treasury). Always fetches fresh to avoid stale treasury. */
1365
1691
  async fetchProtocolConfig() {
1366
1692
  if (this.config.network !== "devnet") {
@@ -1417,8 +1743,33 @@ var AgentRuntime = class {
1417
1743
  });
1418
1744
  return;
1419
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
+ }
1420
1767
  this.customerLimiter.check(job.customerId);
1421
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
+ }
1422
1773
  this.callbacks.onJobReceived?.(job);
1423
1774
  this.inFlight.add(job.jobId);
1424
1775
  this.pending++;
@@ -1446,10 +1797,12 @@ var AgentRuntime = class {
1446
1797
  process.on("SIGTERM", onSignal);
1447
1798
  });
1448
1799
  }
1449
- /** Drop expired hits from both sliding-window limiters. */
1800
+ /** Drop expired hits from every sliding-window limiter. */
1450
1801
  cleanupRateLimits() {
1451
1802
  this.customerLimiter.prune();
1452
1803
  this.globalLimiter.prune();
1804
+ this.freeLlmLimiters.globalLimiter.prune();
1805
+ this.freeLlmLimiters.prunePerCustomer();
1453
1806
  }
1454
1807
  stop() {
1455
1808
  if (this.stopped) {
@@ -1513,6 +1866,24 @@ var AgentRuntime = class {
1513
1866
  if (job.input.length > LIMITS.MAX_INPUT_LENGTH) {
1514
1867
  throw new Error(`Input too long: ${job.input.length} chars (max ${LIMITS.MAX_INPUT_LENGTH})`);
1515
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
+ }
1516
1887
  const jobPrice = resolveJobPrice(job.tags, this.skills);
1517
1888
  const jobAsset = resolveJobAsset(job.tags, this.skills);
1518
1889
  let netAmount;
@@ -2109,9 +2480,10 @@ var ScriptSkill = class {
2109
2480
 
2110
2481
  // src/skill/loader.ts
2111
2482
  function buildCliSkill(parsed, entryPath) {
2483
+ let skill;
2112
2484
  switch (parsed.mode) {
2113
2485
  case "llm":
2114
- return new ScriptSkill(
2486
+ skill = new ScriptSkill(
2115
2487
  parsed.name,
2116
2488
  parsed.description,
2117
2489
  parsed.capabilities,
@@ -2125,6 +2497,7 @@ function buildCliSkill(parsed, entryPath) {
2125
2497
  parsed.maxToolRounds,
2126
2498
  parsed.llmOverride
2127
2499
  );
2500
+ break;
2128
2501
  case "static-file": {
2129
2502
  if (parsed.outputFile === void 0) {
2130
2503
  throw new Error(
@@ -2137,7 +2510,7 @@ function buildCliSkill(parsed, entryPath) {
2137
2510
  `SKILL.md "${parsed.name}": "output_file" must stay inside the skill directory`
2138
2511
  );
2139
2512
  }
2140
- return new StaticFileSkill({
2513
+ skill = new StaticFileSkill({
2141
2514
  name: parsed.name,
2142
2515
  description: parsed.description,
2143
2516
  capabilities: parsed.capabilities,
@@ -2148,6 +2521,7 @@ function buildCliSkill(parsed, entryPath) {
2148
2521
  imageFile: parsed.imageFile,
2149
2522
  dir: entryPath
2150
2523
  });
2524
+ break;
2151
2525
  }
2152
2526
  case "static-script":
2153
2527
  case "dynamic-script": {
@@ -2161,7 +2535,7 @@ function buildCliSkill(parsed, entryPath) {
2161
2535
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
2162
2536
  }
2163
2537
  const Ctor = parsed.mode === "static-script" ? StaticScriptSkill : DynamicScriptSkill;
2164
- return new Ctor({
2538
+ skill = new Ctor({
2165
2539
  name: parsed.name,
2166
2540
  description: parsed.description,
2167
2541
  capabilities: parsed.capabilities,
@@ -2174,8 +2548,13 @@ function buildCliSkill(parsed, entryPath) {
2174
2548
  imageFile: parsed.imageFile,
2175
2549
  dir: entryPath
2176
2550
  });
2551
+ break;
2177
2552
  }
2178
2553
  }
2554
+ if (parsed.rateLimit) {
2555
+ skill.rateLimit = parsed.rateLimit;
2556
+ }
2557
+ return skill;
2179
2558
  }
2180
2559
  function loadSkillsFromDir(skillsDir) {
2181
2560
  const skills = [];
@@ -2581,6 +2960,7 @@ async function cmdStart(nameArg, options = {}) {
2581
2960
  const dependentSkillsByProvider = /* @__PURE__ */ new Map();
2582
2961
  let agentDefaultCacheKey;
2583
2962
  const llmClientCache = /* @__PURE__ */ new Map();
2963
+ const healthMonitor = new LlmHealthMonitor();
2584
2964
  if (llmSkills.length > 0) {
2585
2965
  const resolutionErrors = [];
2586
2966
  for (const skill of llmSkills) {
@@ -2595,6 +2975,11 @@ async function cmdStart(nameArg, options = {}) {
2595
2975
  }
2596
2976
  const cacheKey = cacheKeyFor(result);
2597
2977
  triplesByKey.set(cacheKey, result);
2978
+ skill.resolvedTriple = {
2979
+ provider: result.provider,
2980
+ model: result.model,
2981
+ maxTokens: result.maxTokens
2982
+ };
2598
2983
  const list = dependentSkillsByProvider.get(result.provider) ?? [];
2599
2984
  list.push(skill.name);
2600
2985
  dependentSkillsByProvider.set(result.provider, list);
@@ -2638,28 +3023,47 @@ async function cmdStart(nameArg, options = {}) {
2638
3023
  console.error("");
2639
3024
  process.exit(1);
2640
3025
  }
2641
- for (const provider of keyByProvider.keys()) {
2642
- const apiKey = keyByProvider.get(provider);
3026
+ for (const [, triple] of triplesByKey) {
3027
+ const apiKey = keyByProvider.get(triple.provider);
2643
3028
  if (!apiKey) {
2644
3029
  continue;
2645
3030
  }
2646
- const envVar = provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
2647
- process.stdout.write(` Verifying ${provider} API key... `);
2648
- 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);
2649
3042
  if (verification.ok) {
2650
3043
  console.log("ok");
2651
3044
  } else if (verification.reason === "invalid") {
2652
3045
  console.log("INVALID");
2653
- console.error(` ! ${provider} rejected the API key (HTTP ${verification.status}).`);
3046
+ console.error(` ! ${triple.provider} rejected the API key (HTTP ${verification.status}).`);
2654
3047
  console.error(
2655
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.
2656
3060
  `
2657
3061
  );
2658
3062
  process.exit(1);
2659
3063
  } else {
2660
3064
  console.log("unavailable");
2661
3065
  console.warn(
2662
- ` ! 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.
2663
3067
  `
2664
3068
  );
2665
3069
  }
@@ -2877,28 +3281,45 @@ async function cmdStart(nameArg, options = {}) {
2877
3281
  log: diagLog,
2878
3282
  logger
2879
3283
  });
2880
- const runtime = new AgentRuntime(transport, registry, skillCtx, runtimeConfig, ledger, {
2881
- onJobReceived: (job) => {
2882
- const cap = job.tags.find((t) => t !== "elisym") ?? "unknown";
2883
- 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}
2884
3302
  `);
2885
- logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
2886
- },
2887
- onJobCompleted: (jobId) => {
2888
- 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
2889
3307
  `);
2890
- logger.info({ event: "job_delivered", jobId });
2891
- },
2892
- onJobError: (jobId, error) => {
2893
- 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}
2894
3312
  `);
2895
- 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
+ }
2896
3320
  },
2897
- onLog: diagLog,
2898
- onStop: () => {
2899
- watchdog.stop();
2900
- }
2901
- });
3321
+ healthMonitor
3322
+ );
2902
3323
  console.log(" * Running. Press Ctrl+C to stop.\n");
2903
3324
  await runtime.run();
2904
3325
  }