omnikey-cli 1.5.7 → 1.6.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.
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for the Nemotron adapter.
4
+ *
5
+ * The Nemotron adapter delegates to the `openai` SDK with a custom `baseURL`
6
+ * (NVIDIA NIM is OpenAI-compatible), so these tests mock the same surface as
7
+ * `ai-client.adapters.test.ts` and verify that:
8
+ * - the OpenAI client is constructed with the correct `baseURL`,
9
+ * - chat completions are routed through the Nemotron model id, and
10
+ * - streaming is wired up end-to-end including usage accounting.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ const vitest_1 = require("vitest");
14
+ const mocks = vitest_1.vi.hoisted(() => ({
15
+ openaiCreate: vitest_1.vi.fn(),
16
+ openaiCtor: vitest_1.vi.fn(),
17
+ }));
18
+ vitest_1.vi.mock('openai', () => ({
19
+ default: class MockOpenAI {
20
+ constructor(opts) {
21
+ this.chat = { completions: { create: mocks.openaiCreate } };
22
+ this.images = { generate: vitest_1.vi.fn() };
23
+ mocks.openaiCtor(opts);
24
+ }
25
+ },
26
+ }));
27
+ vitest_1.vi.mock('@anthropic-ai/sdk', () => ({
28
+ default: class MockAnthropic {
29
+ constructor(_opts) {
30
+ this.messages = { create: vitest_1.vi.fn(), stream: vitest_1.vi.fn() };
31
+ }
32
+ },
33
+ }));
34
+ vitest_1.vi.mock('@google/genai', () => ({
35
+ GoogleGenAI: class MockGoogleGenAI {
36
+ constructor(_opts) {
37
+ this.models = {
38
+ generateContent: vitest_1.vi.fn(),
39
+ generateContentStream: vitest_1.vi.fn(),
40
+ generateImages: vitest_1.vi.fn(),
41
+ };
42
+ }
43
+ },
44
+ Content: class {
45
+ },
46
+ Tool: class {
47
+ },
48
+ }));
49
+ const ai_client_1 = require("../ai-client");
50
+ const messages = [{ role: 'user', content: 'hello' }];
51
+ function asAsyncIterable(chunks) {
52
+ return {
53
+ [Symbol.asyncIterator]: async function* () {
54
+ for (const c of chunks)
55
+ yield c;
56
+ },
57
+ };
58
+ }
59
+ (0, vitest_1.beforeEach)(() => {
60
+ mocks.openaiCreate.mockReset();
61
+ mocks.openaiCtor.mockReset();
62
+ });
63
+ (0, vitest_1.describe)('NemotronAdapter', () => {
64
+ (0, vitest_1.it)('targets the public NVIDIA NIM endpoint by default', () => {
65
+ new ai_client_1.AIClient('nemotron', 'nvapi-test');
66
+ (0, vitest_1.expect)(mocks.openaiCtor).toHaveBeenCalledTimes(1);
67
+ const opts = mocks.openaiCtor.mock.calls[0][0];
68
+ (0, vitest_1.expect)(opts).toMatchObject({
69
+ apiKey: 'nvapi-test',
70
+ baseURL: 'https://integrate.api.nvidia.com/v1',
71
+ });
72
+ });
73
+ (0, vitest_1.it)('honours a custom NEMOTRON_BASE_URL for self-hosted NIM', () => {
74
+ new ai_client_1.AIClient('nemotron', 'nvapi-test', {
75
+ nemotronBaseURL: 'http://my-nim:8000/v1',
76
+ });
77
+ const opts = mocks.openaiCtor.mock.calls[0][0];
78
+ (0, vitest_1.expect)(opts.baseURL).toBe('http://my-nim:8000/v1');
79
+ });
80
+ (0, vitest_1.it)('complete: sends the Nemotron model id and passes temperature', async () => {
81
+ mocks.openaiCreate.mockResolvedValueOnce({
82
+ choices: [{ message: { content: 'hi', tool_calls: undefined }, finish_reason: 'stop' }],
83
+ usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
84
+ });
85
+ const client = new ai_client_1.AIClient('nemotron', 'nvapi-test');
86
+ const result = await client.complete('nvidia/nemotron-3-super-120b-a12b', messages, {
87
+ temperature: 0.42,
88
+ });
89
+ const body = mocks.openaiCreate.mock.calls[0][0];
90
+ (0, vitest_1.expect)(body).toMatchObject({
91
+ model: 'nvidia/nemotron-3-super-120b-a12b',
92
+ temperature: 0.42,
93
+ });
94
+ (0, vitest_1.expect)(result.content).toBe('hi');
95
+ (0, vitest_1.expect)(result.usage?.total_tokens).toBe(6);
96
+ });
97
+ (0, vitest_1.it)('streamComplete: forwards deltas and captures usage', async () => {
98
+ mocks.openaiCreate.mockResolvedValueOnce(asAsyncIterable([
99
+ { choices: [{ delta: { content: 'he' } }] },
100
+ { choices: [{ delta: { content: 'llo' } }] },
101
+ {
102
+ choices: [{ delta: {} }],
103
+ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
104
+ },
105
+ ]));
106
+ const client = new ai_client_1.AIClient('nemotron', 'nvapi-test');
107
+ const received = [];
108
+ const { usage } = await client.streamComplete('nvidia/nemotron-3-nano-30b-a3b', messages, {}, (d) => received.push(d));
109
+ (0, vitest_1.expect)(received.join('')).toBe('hello');
110
+ (0, vitest_1.expect)(usage).toEqual({ prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 });
111
+ const body = mocks.openaiCreate.mock.calls[0][0];
112
+ (0, vitest_1.expect)(body).toMatchObject({ stream: true });
113
+ });
114
+ (0, vitest_1.it)('exposes fast and smart defaults via getDefaultModel', () => {
115
+ (0, vitest_1.expect)((0, ai_client_1.getDefaultModel)('nemotron', 'fast')).toBe('nvidia/nemotron-3-nano-30b-a3b');
116
+ (0, vitest_1.expect)((0, ai_client_1.getDefaultModel)('nemotron', 'smart')).toBe('nvidia/nemotron-3-ultra-550b-a55b');
117
+ });
118
+ (0, vitest_1.it)('reports image generation as unsupported', () => {
119
+ const client = new ai_client_1.AIClient('nemotron', 'nvapi-test');
120
+ (0, vitest_1.expect)(client.supportsImageGeneration()).toBe(false);
121
+ (0, vitest_1.expect)((0, ai_client_1.providerSupportsImageGeneration)('nemotron')).toBe(false);
122
+ });
123
+ (0, vitest_1.it)('generateImage rejects with an unsupported-provider error', async () => {
124
+ const client = new ai_client_1.AIClient('nemotron', 'nvapi-test');
125
+ await (0, vitest_1.expect)(client.generateImage({ prompt: 'a test image' })).rejects.toThrow(/not supported for provider "nemotron"/);
126
+ });
127
+ });
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getAgentPrompt = getAgentPrompt;
4
+ const ai_client_1 = require("../ai-client");
4
5
  const config_1 = require("../config");
5
6
  // MCP server names and descriptions are user-controlled and embedded into the agent
6
7
  // system prompt. Sanitize them to mitigate prompt-injection: strip control characters
@@ -28,7 +29,7 @@ function getAgentPrompt(platform, hasTaskInstructions, installedMcps = []) {
28
29
  return `
29
30
  You are an AI agent with the following capabilities:
30
31
  - **Shell execution** (\`<shell_script>\` XML tag) — runs commands on the user's machine; output returns as \`TERMINAL OUTPUT:\`.
31
- - **Web tools** — call \`web_search\` and \`web_fetch\` via native function calling to retrieve live information from the internet.${config_1.config.aiProvider !== 'anthropic' ? '\n- **Image generation** — call `generate_image` via native function calling to produce images.' : ''}${config_1.config.browserDebugPort !== undefined ? '\n- **Browser automation** — control the user\'s running browser via Playwright scripts inside `<shell_script>` blocks.' : ''}
32
+ - **Web tools** — call \`web_search\` and \`web_fetch\` via native function calling to retrieve live information from the internet.${(0, ai_client_1.providerSupportsImageGeneration)(config_1.config.aiProvider) ? '\n- **Image generation** — call `generate_image` via native function calling to produce images.' : ''}${config_1.config.browserDebugPort !== undefined ? "\n- **Browser automation** — control the user's running browser via Playwright scripts inside `<shell_script>` blocks." : ''}
32
33
  ${installedMcps.length > 0 ? '- **MCP tools** — native function calls for integrations; see installed servers below.' : ''}
33
34
 
34
35
  Use these capabilities to take real action. Default to doing rather than asking.
@@ -78,7 +79,7 @@ ${config_1.config.browserDebugPort !== undefined
78
79
  - Create the directory first if needed: \`mkdir -p ~/.omnikey/garbage\`.
79
80
  - Always tell the user the exact path where the configuration was saved in your \`<final_answer>\`.
80
81
 
81
- ${config_1.config.aiProvider === 'anthropic'
82
+ ${!(0, ai_client_1.providerSupportsImageGeneration)(config_1.config.aiProvider)
82
83
  ? `**Image generation:**
83
84
  - No image-generation tool is available in this environment. Do **not** call any tool whose name suggests image, picture, render, draw, or visual asset creation (e.g., \`generate_image\`, \`image_generate\`, \`create_image\`). If the user asks for an image, respond in \`<final_answer>\` explaining that image generation is not supported with the current provider.
84
85
  `
@@ -122,7 +123,7 @@ ${installedMcps
122
123
 
123
124
  **Response format — every response must be exactly one of:**
124
125
  1. \`<shell_script>...</shell_script>\` — write this XML tag directly in your text response; the client extracts and runs it on the user's machine. Never generate a script as an internal tool call or function call. Always use the \`<shell_script>\` tag for scripts — do NOT wrap them in any other tags or envelopes. The script must be the entire content of your response, with no extra text before or after.
125
- 2. ${config_1.config.aiProvider === 'anthropic' ? 'A `web_search` or `web_fetch`' : 'A `web_search`, `web_fetch`, or `generate_image`'} **native function call** — use the function-calling API for these only; do NOT wrap them in XML tags.${installedMcps.length > 0 ? ' Same for MCP tools (`mcp_<server>__<tool>`).' : ''}
126
+ 2. ${(0, ai_client_1.providerSupportsImageGeneration)(config_1.config.aiProvider) ? 'A `web_search`, `web_fetch`, or `generate_image`' : 'A `web_search` or `web_fetch`'} **native function call** — use the function-calling API for these only; do NOT wrap them in XML tags.${installedMcps.length > 0 ? ' Same for MCP tools (`mcp_<server>__<tool>`).' : ''}
126
127
  3. \`<final_answer>...</final_answer>\` — your conclusion once you have enough information.
127
128
 
128
129
  **Critical rule — zero tolerance for text outside tags or extra wrappers:**
@@ -16,17 +16,18 @@ const imageTool_1 = require("./imageTool");
16
16
  * `web_search` is always included because DuckDuckGo is used as a free
17
17
  * fallback when no third-party search key is configured.
18
18
  *
19
- * `generate_image` is omitted for the Anthropic provider because the
20
- * underlying `aiClient.generateImage()` only supports OpenAI and Gemini
21
- * registering an unsupported tool would invite the model to call it and
22
- * fail at execution time. The system prompt for Anthropic is built without
19
+ * `generate_image` is omitted for providers without image-generation
20
+ * support (currently Anthropic and Nemotron) because the underlying
21
+ * `aiClient.generateImage()` only supports OpenAI and Gemini registering
22
+ * an unsupported tool would invite the model to call it and fail at
23
+ * execution time. The system prompt for those providers is built without
23
24
  * the image-tool section to match this tool set.
24
25
  *
25
26
  * @returns An array of `AITool` definitions ready to pass to the AI client.
26
27
  */
27
28
  function buildAvailableTools(extraTools = []) {
28
29
  const baseTools = [web_search_provider_1.WEB_FETCH_TOOL, web_search_provider_1.WEB_SEARCH_TOOL];
29
- if (config_1.config.aiProvider !== 'anthropic') {
30
+ if ((0, ai_client_1.providerSupportsImageGeneration)(config_1.config.aiProvider)) {
30
31
  baseTools.push(imageTool_1.IMAGE_GENERATE_TOOL);
31
32
  }
32
33
  return [...baseTools, ...extraTools];
@@ -9,6 +9,7 @@ exports.modelSupportsTemperature = modelSupportsTemperature;
9
9
  exports.getMaxMessageContentLength = getMaxMessageContentLength;
10
10
  exports.getMaxHistoryLength = getMaxHistoryLength;
11
11
  exports.getContextWindowSize = getContextWindowSize;
12
+ exports.providerSupportsImageGeneration = providerSupportsImageGeneration;
12
13
  const openai_1 = __importDefault(require("openai"));
13
14
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
14
15
  const genai_1 = require("@google/genai");
@@ -27,6 +28,17 @@ const DEFAULT_MODELS = {
27
28
  openai: { fast: 'gpt-4o-mini', smart: 'gpt-5.5' },
28
29
  gemini: { fast: 'gemini-2.5-flash', smart: 'gemini-2.5-pro' },
29
30
  anthropic: { fast: 'claude-haiku-4-5-20251001', smart: 'claude-opus-4-7' },
31
+ // NVIDIA Nemotron is exposed through the OpenAI-compatible NIM endpoint at
32
+ // https://integrate.api.nvidia.com/v1. The "fast" tier maps to Nemotron Nano
33
+ // for high-throughput sub-agent workloads; the "smart" tier maps to Nemotron
34
+ // Ultra — the frontier-level model in the family — for complex multi-agent
35
+ // reasoning, planning, code generation, and deep research. Drop down to
36
+ // Nemotron Super (`nvidia/nemotron-3-super-120b-a12b`) here if single-GPU
37
+ // data-center deployment is required.
38
+ nemotron: {
39
+ fast: 'nvidia/nemotron-3-nano-30b-a3b',
40
+ smart: 'nvidia/nemotron-3-ultra-550b-a55b',
41
+ },
30
42
  };
31
43
  function getDefaultModel(provider, tier) {
32
44
  return DEFAULT_MODELS[provider][tier];
@@ -78,6 +90,9 @@ const MAX_MESSAGE_CONTENT_LENGTH_BY_PROVIDER = {
78
90
  anthropic: 10000000,
79
91
  openai: 800000,
80
92
  gemini: 3500000,
93
+ // Nemotron 3 ships a 1M-token context window via NIM; mirror Gemini's
94
+ // per-string cap (no documented hard limit, bounded by the context window).
95
+ nemotron: 3500000,
81
96
  };
82
97
  /**
83
98
  * Maximum total character length across all messages in the conversation
@@ -95,6 +110,8 @@ const MAX_HISTORY_LENGTH_BY_PROVIDER = {
95
110
  anthropic: 1800000,
96
111
  openai: 460000,
97
112
  gemini: 1800000,
113
+ // 1M-token context with 100K reserved for output → 900K target × 2 chars
114
+ nemotron: 1800000,
98
115
  };
99
116
  /**
100
117
  * Hard token limit of the context window for each provider/model tier.
@@ -104,6 +121,8 @@ const CONTEXT_WINDOW_BY_PROVIDER = {
104
121
  anthropic: 1000000,
105
122
  openai: 272000,
106
123
  gemini: 1000000,
124
+ // Nemotron 3 hybrid Mamba-Transformer MoE family ships with 1M-token context.
125
+ nemotron: 1000000,
107
126
  };
108
127
  function getMaxMessageContentLength(provider) {
109
128
  return MAX_MESSAGE_CONTENT_LENGTH_BY_PROVIDER[provider];
@@ -165,9 +184,7 @@ class OpenAIAdapter {
165
184
  const stream = await this.client.chat.completions.create({
166
185
  model,
167
186
  messages: oaiMessages,
168
- ...(modelSupportsTemperature(model)
169
- ? { temperature: options.temperature ?? 0.3 }
170
- : {}),
187
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.3 } : {}),
171
188
  stream: true,
172
189
  stream_options: { include_usage: true },
173
190
  });
@@ -277,9 +294,7 @@ class AnthropicAdapter {
277
294
  max_tokens: options.maxTokens ?? 8192,
278
295
  ...(system ? { system } : {}),
279
296
  messages: anthropicMessages,
280
- ...(modelSupportsTemperature(model)
281
- ? { temperature: options.temperature ?? 0.3 }
282
- : {}),
297
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.3 } : {}),
283
298
  });
284
299
  for await (const event of stream) {
285
300
  if (event.type === 'content_block_delta' &&
@@ -298,6 +313,96 @@ class AnthropicAdapter {
298
313
  }
299
314
  }
300
315
  // ---------------------------------------------------------------------------
316
+ // Nemotron adapter (NVIDIA NIM — OpenAI-compatible REST API)
317
+ // ---------------------------------------------------------------------------
318
+ /**
319
+ * NVIDIA Nemotron models are served behind an OpenAI-compatible endpoint at
320
+ * `https://integrate.api.nvidia.com/v1` (the NVIDIA NIM gateway, also used by
321
+ * self-hosted NIM microservices). Because the wire protocol matches OpenAI's
322
+ * Chat Completions API, we reuse the `openai` SDK by constructing a client
323
+ * with a custom `baseURL`. This keeps the message/tool-call conversion logic
324
+ * identical to OpenAI and avoids pulling in another transport library.
325
+ *
326
+ * Notes on Nemotron-specific quirks:
327
+ * - The endpoint accepts `temperature`, `top_p`, `max_tokens`, and `tools`
328
+ * in the standard OpenAI shape, so no schema translation is needed.
329
+ * - Image generation is not exposed for the text-only Nemotron models, so
330
+ * `generateImage` is intentionally not implemented (the unified `AIClient`
331
+ * surfaces a clear error for unsupported providers).
332
+ * - Self-hosted NIM deployments can be targeted by setting the
333
+ * `NEMOTRON_BASE_URL` env var (handled in `config.ts`). The API key can be
334
+ * any non-empty string for self-hosted NIM.
335
+ */
336
+ class NemotronAdapter {
337
+ constructor(apiKey, baseURL) {
338
+ this.client = new openai_1.default({ apiKey, baseURL });
339
+ }
340
+ async complete(model, messages, options) {
341
+ const oaiMessages = toOpenAIMessages(messages);
342
+ const tools = options.tools?.length ? toOpenAITools(options.tools) : undefined;
343
+ const completion = await this.client.chat.completions.create({
344
+ model,
345
+ messages: oaiMessages,
346
+ tools: tools?.length ? tools : undefined,
347
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
348
+ max_tokens: options.maxTokens,
349
+ });
350
+ const choice = completion.choices[0];
351
+ const msg = choice.message;
352
+ const content = (msg.content ?? '').toString().trim();
353
+ const tool_calls = msg.tool_calls
354
+ ?.filter((tc) => tc.type === 'function' && 'function' in tc)
355
+ .map((tc) => ({
356
+ id: tc.id,
357
+ name: tc.function.name,
358
+ arguments: JSON.parse(tc.function.arguments || '{}'),
359
+ }));
360
+ const finishReason = choice.finish_reason === 'tool_calls'
361
+ ? 'tool_calls'
362
+ : choice.finish_reason === 'length'
363
+ ? 'length'
364
+ : 'stop';
365
+ const usage = completion.usage
366
+ ? {
367
+ prompt_tokens: completion.usage.prompt_tokens,
368
+ completion_tokens: completion.usage.completion_tokens,
369
+ total_tokens: completion.usage.total_tokens,
370
+ }
371
+ : undefined;
372
+ const assistantMessage = {
373
+ role: 'assistant',
374
+ content,
375
+ ...(tool_calls?.length ? { tool_calls } : {}),
376
+ };
377
+ return { content, finish_reason: finishReason, tool_calls, usage, model, assistantMessage };
378
+ }
379
+ async streamComplete(model, messages, options, onDelta) {
380
+ const oaiMessages = toOpenAIMessages(messages);
381
+ const stream = await this.client.chat.completions.create({
382
+ model,
383
+ messages: oaiMessages,
384
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.3 } : {}),
385
+ stream: true,
386
+ stream_options: { include_usage: true },
387
+ });
388
+ let usage;
389
+ for await (const part of stream) {
390
+ const delta = part.choices?.[0]?.delta?.content ?? '';
391
+ if (delta) {
392
+ onDelta(delta);
393
+ }
394
+ if (part.usage) {
395
+ usage = {
396
+ prompt_tokens: part.usage.prompt_tokens ?? 0,
397
+ completion_tokens: part.usage.completion_tokens ?? 0,
398
+ total_tokens: part.usage.total_tokens ?? 0,
399
+ };
400
+ }
401
+ }
402
+ return { usage, model };
403
+ }
404
+ }
405
+ // ---------------------------------------------------------------------------
301
406
  // Gemini adapter
302
407
  // ---------------------------------------------------------------------------
303
408
  class GeminiAdapter {
@@ -313,9 +418,7 @@ class GeminiAdapter {
313
418
  config: {
314
419
  ...(systemInstruction ? { systemInstruction } : {}),
315
420
  ...(tools?.length ? { tools } : {}),
316
- ...(modelSupportsTemperature(model)
317
- ? { temperature: options.temperature ?? 0.2 }
318
- : {}),
421
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
319
422
  },
320
423
  });
321
424
  const candidate = response.candidates?.[0];
@@ -366,9 +469,7 @@ class GeminiAdapter {
366
469
  contents,
367
470
  config: {
368
471
  ...(systemInstruction ? { systemInstruction } : {}),
369
- ...(modelSupportsTemperature(model)
370
- ? { temperature: options.temperature ?? 0.3 }
371
- : {}),
472
+ ...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.3 } : {}),
372
473
  },
373
474
  });
374
475
  let usage;
@@ -427,7 +528,7 @@ class GeminiAdapter {
427
528
  // Main AIClient
428
529
  // ---------------------------------------------------------------------------
429
530
  class AIClient {
430
- constructor(provider, apiKey) {
531
+ constructor(provider, apiKey, options = {}) {
431
532
  this.provider = provider;
432
533
  if (provider === 'openai') {
433
534
  this.openai = new OpenAIAdapter(apiKey);
@@ -438,6 +539,12 @@ class AIClient {
438
539
  else if (provider === 'gemini') {
439
540
  this.gemini = new GeminiAdapter(apiKey);
440
541
  }
542
+ else if (provider === 'nemotron') {
543
+ // Default to the public NVIDIA NIM gateway. Self-hosted NIM deployments
544
+ // can override this via `NEMOTRON_BASE_URL` (see config.ts).
545
+ const baseURL = options.nemotronBaseURL || 'https://integrate.api.nvidia.com/v1';
546
+ this.nemotron = new NemotronAdapter(apiKey, baseURL);
547
+ }
441
548
  }
442
549
  getProvider() {
443
550
  return this.provider;
@@ -452,6 +559,9 @@ class AIClient {
452
559
  if (this.provider === 'gemini' && this.gemini) {
453
560
  return this.gemini.complete(model, messages, options);
454
561
  }
562
+ if (this.provider === 'nemotron' && this.nemotron) {
563
+ return this.nemotron.complete(model, messages, options);
564
+ }
455
565
  throw new Error(`AI provider "${this.provider}" is not configured.`);
456
566
  }
457
567
  async streamComplete(model, messages, options = {}, onDelta) {
@@ -464,13 +574,27 @@ class AIClient {
464
574
  if (this.provider === 'gemini' && this.gemini) {
465
575
  return this.gemini.streamComplete(model, messages, options, onDelta);
466
576
  }
577
+ if (this.provider === 'nemotron' && this.nemotron) {
578
+ return this.nemotron.streamComplete(model, messages, options, onDelta);
579
+ }
467
580
  throw new Error(`AI provider "${this.provider}" is not configured.`);
468
581
  }
582
+ /**
583
+ * Reports whether the configured provider can generate images.
584
+ *
585
+ * Centralising this check means agent tool registration and system-prompt
586
+ * builders no longer need to keep a hand-maintained allow/deny list of
587
+ * providers — they ask the client directly. Currently OpenAI and Gemini
588
+ * support image generation; Anthropic and Nemotron (text-only) do not.
589
+ */
590
+ supportsImageGeneration() {
591
+ return this.provider === 'openai' || this.provider === 'gemini';
592
+ }
469
593
  /**
470
594
  * Generates an image with the currently configured provider.
471
595
  *
472
- * Supported providers are OpenAI and Gemini. Anthropic does not currently
473
- * expose a text-to-image generation endpoint in this project.
596
+ * Supported providers are OpenAI and Gemini. Anthropic and Nemotron do not
597
+ * currently expose a text-to-image generation endpoint in this project.
474
598
  *
475
599
  * @param options - Unified image-generation options.
476
600
  * @returns Provider-normalized image payload.
@@ -486,6 +610,15 @@ class AIClient {
486
610
  }
487
611
  }
488
612
  exports.AIClient = AIClient;
613
+ /**
614
+ * Returns whether the given provider supports image generation.
615
+ *
616
+ * Module-level helper for callers that only have the provider id at hand
617
+ * (e.g. system-prompt builders) and don't want to construct an `AIClient`.
618
+ */
619
+ function providerSupportsImageGeneration(provider) {
620
+ return provider === 'openai' || provider === 'gemini';
621
+ }
489
622
  // ---------------------------------------------------------------------------
490
623
  // Message format converters — OpenAI
491
624
  // ---------------------------------------------------------------------------
@@ -646,4 +779,6 @@ function toGeminiTools(tools) {
646
779
  // ---------------------------------------------------------------------------
647
780
  // Shared singleton — import this instead of constructing a new AIClient
648
781
  // ---------------------------------------------------------------------------
649
- exports.aiClient = new AIClient(config_1.config.aiProvider, config_1.config.aiApiKey);
782
+ exports.aiClient = new AIClient(config_1.config.aiProvider, config_1.config.aiApiKey, {
783
+ nemotronBaseURL: config_1.config.nemotronBaseUrl,
784
+ });
@@ -45,13 +45,16 @@ function getSqlitePath() {
45
45
  }
46
46
  function getAIProvider() {
47
47
  const value = getEnv('AI_PROVIDER', false);
48
- if (value === 'gemini' || value === 'anthropic' || value === 'openai')
48
+ if (value === 'gemini' || value === 'anthropic' || value === 'openai' || value === 'nemotron') {
49
49
  return value;
50
+ }
50
51
  // Auto-detect from available keys
51
52
  if (getEnv('ANTHROPIC_API_KEY', false))
52
53
  return 'anthropic';
53
54
  if (getEnv('GEMINI_API_KEY', false))
54
55
  return 'gemini';
56
+ if (getEnv('NVIDIA_API_KEY', false) || getEnv('NEMOTRON_API_KEY', false))
57
+ return 'nemotron';
55
58
  return 'openai';
56
59
  }
57
60
  function getActiveApiKey(provider) {
@@ -61,6 +64,14 @@ function getActiveApiKey(provider) {
61
64
  return getEnv('ANTHROPIC_API_KEY', true);
62
65
  if (provider === 'gemini')
63
66
  return getEnv('GEMINI_API_KEY', true);
67
+ if (provider === 'nemotron') {
68
+ // Accept either NVIDIA_API_KEY (default name on build.nvidia.com) or
69
+ // NEMOTRON_API_KEY (more explicit). The latter wins if both are set.
70
+ const explicit = getEnv('NEMOTRON_API_KEY', false);
71
+ if (explicit)
72
+ return explicit;
73
+ return getEnv('NVIDIA_API_KEY', true);
74
+ }
64
75
  throw new Error(`Unknown AI provider: ${provider}`);
65
76
  }
66
77
  const _provider = getAIProvider();
@@ -73,6 +84,10 @@ exports.config = {
73
84
  aiApiKey: getActiveApiKey(_provider),
74
85
  // Legacy — kept for backwards compatibility; may be undefined when using another provider
75
86
  openaiApiKey: getEnv('OPENAI_API_KEY', false),
87
+ // Optional override for the NVIDIA NIM endpoint. Defaults to the public
88
+ // `https://integrate.api.nvidia.com/v1` gateway when unset. Point this at a
89
+ // self-hosted NIM (e.g. `http://my-nim-host:8000/v1`) to use private weights.
90
+ nemotronBaseUrl: getEnv('NEMOTRON_BASE_URL', false),
76
91
  // Database
77
92
  databaseUrl: getEnv('DATABASE_URL', getBooleanEnv('IS_SELF_HOSTED', false) ? false : true),
78
93
  dbLogging: getBooleanEnv('DB_LOGGING', false),
@@ -30,7 +30,11 @@ else if (config_1.config.databaseUrl) {
30
30
  }
31
31
  const COLUMN_MIGRATIONS = [
32
32
  // Added: context-window tracking (prompt token count of last API call)
33
- { table: 'agent_sessions', column: 'last_prompt_tokens', definition: 'INTEGER NOT NULL DEFAULT 0' },
33
+ {
34
+ table: 'agent_sessions',
35
+ column: 'last_prompt_tokens',
36
+ definition: 'INTEGER NOT NULL DEFAULT 0',
37
+ },
34
38
  // Added: project grouping
35
39
  { table: 'agent_sessions', column: 'group_name', definition: 'VARCHAR(255)' },
36
40
  { table: 'agent_sessions', column: 'group_description', definition: 'TEXT' },
@@ -165,11 +165,23 @@ function mcpServerRouter() {
165
165
  // When transport changes, clear fields incompatible with the new transport so
166
166
  // stale credentials/config never persist across a transport switch.
167
167
  const command = transportChanged
168
- ? (transport === 'stdio' ? (parsed.command !== undefined ? parsed.command : server.command) : null)
169
- : (parsed.command !== undefined ? parsed.command : server.command);
168
+ ? transport === 'stdio'
169
+ ? parsed.command !== undefined
170
+ ? parsed.command
171
+ : server.command
172
+ : null
173
+ : parsed.command !== undefined
174
+ ? parsed.command
175
+ : server.command;
170
176
  const url = transportChanged
171
- ? (transport !== 'stdio' ? (parsed.url !== undefined ? parsed.url : server.url) : null)
172
- : (parsed.url !== undefined ? parsed.url : server.url);
177
+ ? transport !== 'stdio'
178
+ ? parsed.url !== undefined
179
+ ? parsed.url
180
+ : server.url
181
+ : null
182
+ : parsed.url !== undefined
183
+ ? parsed.url
184
+ : server.url;
173
185
  const args = transportChanged && transport !== 'stdio' ? [] : (parsed.args ?? server.args);
174
186
  const env = transportChanged && transport !== 'stdio' ? {} : (parsed.env ?? server.env);
175
187
  const headers = transportChanged && transport === 'stdio' ? {} : (parsed.headers ?? server.headers);
@@ -14,7 +14,10 @@ const CRON_REGEX = /^(\S+\s){4}\S+$/;
14
14
  const jobSchema = zod_1.default.object({
15
15
  label: zod_1.default.string().min(1).max(200),
16
16
  prompt: zod_1.default.string().min(1),
17
- cronExpression: zod_1.default.string().regex(CRON_REGEX, 'Invalid cron expression (must be 5 fields)').optional(),
17
+ cronExpression: zod_1.default
18
+ .string()
19
+ .regex(CRON_REGEX, 'Invalid cron expression (must be 5 fields)')
20
+ .optional(),
18
21
  runAt: zod_1.default.string().optional(),
19
22
  isActive: zod_1.default.boolean().optional(),
20
23
  sessionId: zod_1.default.string().nullable().optional(),
@@ -108,7 +111,7 @@ function scheduledJobRouter() {
108
111
  if (!job) {
109
112
  return res.status(404).json({ error: 'Scheduled job not found.' });
110
113
  }
111
- const cronExpression = parsed.cronExpression !== undefined ? parsed.cronExpression ?? null : job.cronExpression;
114
+ const cronExpression = parsed.cronExpression !== undefined ? (parsed.cronExpression ?? null) : job.cronExpression;
112
115
  let runAt = job.runAt;
113
116
  if (parsed.runAt !== undefined) {
114
117
  if (parsed.runAt) {
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ const program = new commander_1.Command();
18
18
  program
19
19
  .name('omnikey')
20
20
  .description('Omnikey CLI for onboarding and configuration')
21
- .version('1.5.4');
21
+ .version('1.6.0');
22
22
  program
23
23
  .command('onboard')
24
24
  .description('Onboard and configure your AI provider')
package/dist/onboard.js CHANGED
@@ -12,6 +12,10 @@ const AI_PROVIDERS = [
12
12
  { name: 'OpenAI (gpt-4o-mini / gpt-5.5)', value: 'openai' },
13
13
  { name: 'Anthropic — Claude (claude-haiku / claude-opus)', value: 'anthropic' },
14
14
  { name: 'Google Gemini (gemini-2.5-flash / gemini-2.5-pro)', value: 'gemini' },
15
+ {
16
+ name: 'NVIDIA Nemotron (nemotron-3-nano / nemotron-3-ultra) — open weights',
17
+ value: 'nemotron',
18
+ },
15
19
  ];
16
20
  const SEARCH_PROVIDERS = [
17
21
  { name: 'Skip', value: 'skip' },
@@ -25,11 +29,13 @@ const AI_PROVIDER_KEY_ENV = {
25
29
  openai: 'OPENAI_API_KEY',
26
30
  anthropic: 'ANTHROPIC_API_KEY',
27
31
  gemini: 'GEMINI_API_KEY',
32
+ nemotron: 'NVIDIA_API_KEY',
28
33
  };
29
34
  const AI_PROVIDER_KEY_LABEL = {
30
35
  openai: 'OpenAI API key (from platform.openai.com)',
31
36
  anthropic: 'Anthropic API key (from console.anthropic.com)',
32
37
  gemini: 'Google Gemini API key (from ai.google.dev)',
38
+ nemotron: 'NVIDIA API key (from build.nvidia.com — used by NIM/Nemotron)',
33
39
  };
34
40
  /**
35
41
  * Onboard the user by configuring their AI provider API key and generating config for self-hosted use.
@@ -55,6 +61,37 @@ async function onboard() {
55
61
  validate: (input) => input.trim() !== '' || 'API key cannot be empty',
56
62
  },
57
63
  ]);
64
+ // Provider-specific extras
65
+ const providerExtras = {};
66
+ if (aiProvider === 'nemotron') {
67
+ // Nemotron is served either via the public NVIDIA NIM gateway or a
68
+ // self-hosted NIM microservice. Always ask the user for the base URL so
69
+ // they can point at either. The default value matches the public gateway
70
+ // so pressing Enter "just works" for build.nvidia.com keys.
71
+ const DEFAULT_NEMOTRON_URL = 'https://integrate.api.nvidia.com/v1';
72
+ const { nemotronBaseUrl } = await inquirer_1.default.prompt([
73
+ {
74
+ type: 'input',
75
+ name: 'nemotronBaseUrl',
76
+ message: 'Enter the Nemotron / NVIDIA NIM base URL (press Enter for the public gateway):',
77
+ default: DEFAULT_NEMOTRON_URL,
78
+ validate: (input) => {
79
+ const trimmed = input.trim();
80
+ if (trimmed === '')
81
+ return 'URL cannot be empty';
82
+ try {
83
+ // eslint-disable-next-line no-new
84
+ new URL(trimmed);
85
+ return true;
86
+ }
87
+ catch {
88
+ return 'Please enter a valid URL (including the scheme, e.g. https://...)';
89
+ }
90
+ },
91
+ },
92
+ ]);
93
+ providerExtras['NEMOTRON_BASE_URL'] = nemotronBaseUrl.trim();
94
+ }
58
95
  // Web search provider (optional)
59
96
  const { provider } = await inquirer_1.default.prompt([
60
97
  {
@@ -119,6 +156,7 @@ async function onboard() {
119
156
  [AI_PROVIDER_KEY_ENV[aiProvider]]: apiKey,
120
157
  IS_SELF_HOSTED: true,
121
158
  SQLITE_PATH: sqlitePath,
159
+ ...providerExtras,
122
160
  ...searchConfig,
123
161
  };
124
162
  fs_1.default.writeFileSync(configPath, JSON.stringify(configVars, null, 2));
@@ -23,7 +23,7 @@ function resolveBundleRoot() {
23
23
  return path_1.default.resolve(__dirname, '..', 'telegram-client-dist');
24
24
  }
25
25
  function resolveBundledEntry() {
26
- return path_1.default.join(resolveBundleRoot(), 'dist', 'index.js');
26
+ return path_1.default.join(resolveBundleRoot(), 'index.js');
27
27
  }
28
28
  function persistConfig(values) {
29
29
  const configDir = (0, utils_1.getConfigDir)();