@rhizomatics/signalk-einklabel-genai-plugin 0.1.0 → 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # [1.0.2]
2
+
3
+ - AI providers drop-down now restricted to available AI SDK plugins
4
+ - Test option added to validate AI configuration, keys and connectivity
5
+
6
+ # [1.0.1]
7
+
8
+ - Packaging fixes
9
+ - Simplified adding GenAI support to `esl-cli`
10
+ - Added `openrouter` as the default built-in AI provider
11
+ - Other providers are available by `npm install`
12
+
13
+ # [1.0.0]
14
+
15
+ - Initial release with support for major cloud GenAI services and local LLMs
16
+ - As an alternative to a template, an image can be generated by an LLM based on customized prompt
17
+ - Prompt will automatically include details of the ESL, including available colours, height, width, manufacturer and locally supported fonts
18
+ - A marine forecast prompt is provided as an example
19
+ - It automatically includes vessel length, type and position.
20
+ - The [Vercel AI SDK](https://ai-sdk.dev) is used, so a wide range of [providers](https://ai-sdk.dev/providers/ai-sdk-providers) are supported by the framework, although only a handful of the major ones imported to minimze bloat. These are all optional dependencies, so the plugin will not fail to load if they are unavailable, and they can be omitted if installing directly using `npm`
21
+ - [Anthropic](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic) (Claude)
22
+ - [Google](https://ai-sdk.dev/providers/ai-sdk-providers/google) (Gemini)
23
+ - [OpenAI](https://ai-sdk.dev/providers/ai-sdk-providers/openai) (ChatGPT)
24
+ - [xAI](https://ai-sdk.dev/providers/ai-sdk-providers/xai) (Grok)
25
+ - [Moonshot AI](https://ai-sdk.dev/providers/ai-sdk-providers/moonshotai) (Kimi)
26
+ - [DeepSeek](https://ai-sdk.dev/providers/ai-sdk-providers/deepseek)
27
+ - [Ollama](https://ai-sdk.dev/providers/community-providers/ollama) - local and cloud using the offical API
28
+ - [OpenAI Compatible](https://ai-sdk.dev/providers/openai-compatible-providers) - including local LM Studio
package/README.md CHANGED
@@ -2,21 +2,31 @@
2
2
 
3
3
  A companion plugin for [`@rhizomatics/signalk-einklabel-plugin`](https://github.com/rhizomatics/signalk-einklabel-plugin) that generates a device's content from an LLM prompt, as an alternative to a hand-authored SVG template.
4
4
 
5
- This is deliberately a separate plugin, not a feature of the core one: the core plugin has nothing to do with any LLM SDK or API key, so installing it never pulls in GenAI dependencies unless you also explicitly install and enable this one - useful for anyone on a small/constrained install, or who'd simply rather not have any GenAI code in their install at all.
5
+ By default it uses [OpenRouter](https://openrouter.ai), however it can use any of the [Vercel AI SDK providers](https://ai-sdk.dev/providers/ai-sdk-providers) directly, including local LLMs like Ollama.
6
6
 
7
- ## How it fits together
7
+ ## Working Example
8
8
 
9
- This plugin registers a `TemplateProvider` with the core plugin (the same extension mechanism a new vendor's hardware driver uses - see the core plugin's README, "Extending"). Once both plugins are installed and enabled:
9
+ This plugin comes with a marine weather forecast example prompt. When installed, and SignalK server restarted, you should see this appear as a template to choose in the main eInk Label plugin.
10
10
 
11
- - Its prompts show up as ordinary entries in the core plugin's own "Template" picker, suffixed `(GenAI)` - pick one exactly like you'd pick a `.svg` file. No separate "render mode" setting.
12
- - All LLM provider/model/API-key configuration lives in **this** plugin's own config screen - the core plugin knows nothing about any of that.
13
- - If the LLM call fails (network/API error, exhausted retries) or its response isn't a renderable SVG, the **core** plugin pushes its own generic fallback-warning template instead of leaving the previous, possibly now-wrong, content on screen - the same safety net a broken hand-authored template gets. The next scheduled repaint retries automatically.
11
+ ### Customizing Prompts
14
12
 
15
- Since each repaint may call a (usually paid) LLM API, a GenAI-backed device should use Repaint Trigger `interval`, not `subscription`.
13
+ Prompts have the same template syntax as the SVG images, so the weather forecast will give the current GPS position, and the length and type of boat.
14
+
15
+ All prompts also get a generated appendix to the prompt that gives details about the eInk label, including manufacturer, name, pixel height and width, colour range, and the description provided in the main plugin.
16
+
17
+ ### Full Example
18
+
19
+ > Summarize the maritime weather outlook for the next 24 hours, appropriate for the sailor of a 13.6m Sailing craft positioned at latitude 40.689247, longitude -74.044502
20
+ >
21
+ > Generate an SVG image for the result suitable for a ZhunyCo 4.13" eInk Electronic Shelf Label display with pixel height 240 and width 416. Available colours are black (#000000), white (#FFFFFF), red (#FF0000), yellow (#FFFF00). Fonts available are serif, sans-serif, monospace.
22
+ >
23
+ > Use graphics to make it easier to read and use the space better, and use colour and layout to draw attention to safety critical information. Include in minimal size font the source of the information.
24
+
25
+ CAUTION: Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
16
26
 
17
27
  ## Installation
18
28
 
19
- Install both plugins from the SignalK AppStore (or `npm install @rhizomatics/signalk-einklabel-plugin @rhizomatics/signalk-einklabel-genai-plugin`), then install whichever LLM provider package(s) you'll actually use - these are `optionalDependencies`, not installed automatically, since a user only ever needs one:
29
+ Install both plugins from the SignalK AppStore (or `npm install @rhizomatics/signalk-einklabel-plugin @rhizomatics/signalk-einklabel-genai-plugin`), then install whichever LLM provider package(s) you'll actually use - these are `optionalDependencies`, not installed automatically, since most people will likely only ever need one. `openrouter` is the one exception: `@openrouter/ai-sdk-provider` is a regular `dependency`, so it's already installed and needs no extra step, as is the base `ai` package:
20
30
 
21
31
  ```bash
22
32
  npm install ai @ai-sdk/openai # llmProvider: "openai"
@@ -31,25 +41,21 @@ npm install ai @ai-sdk/openai-compatible # llmProvider: "local" (LM Studio, vLL
31
41
 
32
42
  If the package for your configured provider isn't installed, a GenAI-backed device falls back to the core plugin's warning template with a log message naming exactly which package to `npm install`.
33
43
 
44
+ ### Local LLMs
45
+
46
+ Many Raspberry Pi type tiny computers either have an onboard NPU for AI work, or an add-on 'hat', like the [Raspberry Pi AI Kit](https://www.raspberrypi.com/products/ai-kit/).
47
+
48
+ Combined with a distilled model, for example Llama 3.2 3B, Phi-4 Mini 3.8B, or Qwen3.5-4B, it is possible to have something reasonable in 8Gb, and the eInk use case is ideal, since it runs in the background a couple of times a day, so slow token generation isn't an issue.
49
+
34
50
  ## Configuration
35
51
 
36
- - `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `ollama`, or `local` (any other OpenAI-compatible server)
52
+ - `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `openrouter`, `ollama`, or `local` (any other OpenAI-compatible server). The config screen's dropdown only lists providers whose package is actually installed (`openrouter` always is) - install another one's optional package and reopen the page to add it.
37
53
  - `llmApiKey` - not needed for `ollama`/`local` unless your server itself checks one
38
54
  - `llmModel` - provider-specific model id, e.g. `gpt-4o`, `claude-sonnet-4-5`, `gemini-2.5-flash`, `grok-4`, or an Ollama/local model tag
39
55
  - `llmBaseUrl` - required for `local`; optional for `ollama` (defaults to `http://localhost:11434/v1`)
40
56
  - `llmTimeoutSeconds`/`llmRetries` - how long to wait, and how many attempts, before giving up (the core plugin then shows its fallback warning)
41
57
  - `promptsDir` - where to look for your own `.md` prompt files, same convention as the core plugin's `templatesDir` (empty for the default, a relative path resolves against `~/.signalk`, absolute used as-is)
42
-
43
- ### Adding another LLM provider
44
-
45
- The provider list above is `resolveModel`'s (`src/llmGateway.ts`) built-in switch, but you're not limited to it: call `registerLlmProvider(name, factory)` (exported from this plugin, mirroring the core plugin's own extension points) from your own SignalK plugin's `start()` to add support for a provider this plugin doesn't bundle, without forking it. A registered name takes priority over the built-in switch, so it can even replace one of the names above.
46
-
47
- ```ts
48
- import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
49
- genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
50
- ```
51
-
52
- Declare this package as a regular npm `dependency` (**not** a `peerDependency` - see the core plugin's own README for why) plus `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json`.
58
+ - `testConnection` - not a persistent setting, just a one-shot checkbox: check it and save to send one test message to the LLM settings above as soon as the plugin (re)starts. The result (success, or the error) shows up on this plugin's own entry on the Plugin Config page - the checkbox clears itself afterwards, so it won't refire on the next ordinary restart.
53
59
 
54
60
  ## Writing Prompts
55
61
 
@@ -71,8 +77,8 @@ Every prompt also has a bundled `.assets/target_guidance.md` fragment appended a
71
77
  This plugin contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, loaded via `-r`:
72
78
 
73
79
  ```bash
74
- esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js prompt forecast -e examples
75
- esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js generate forecast -e examples -o forecast.png --llm-provider ollama --llm-model llama3.2
80
+ esl-cli -r @rhizomatics/signalk-einklabel-genai-plugin/cli prompt forecast -e examples
81
+ esl-cli -r @rhizomatics/signalk-einklabel-genai-plugin/cli generate forecast -e examples -o forecast.png --llm-provider ollama --llm-model llama3.2
76
82
  ```
77
83
 
78
84
  - `prompt [name]` - resolve a prompt's placeholders against a live SignalK server (or example data) and print the exact text an LLM would be sent, with no LLM call
@@ -80,6 +86,29 @@ esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExte
80
86
 
81
87
  The optional `[name]` argument names a prompt the same way the config UI's picker does - a full path also works. Use `--help` on either for the full option list.
82
88
 
83
- ## Publishing note
89
+ ## Architecture
90
+
91
+ ### Why 2 plugins?
92
+
93
+ This is deliberately a separate plugin, not a feature of the core one: the core plugin has nothing to do with any LLM SDK or API key, so installing it never pulls in GenAI dependencies unless you also explicitly install and enable this one - useful for anyone on a small/constrained install, or who'd simply rather not have any GenAI code in their install at all.
94
+
95
+ ### How it fits together
96
+
97
+ This plugin registers a `TemplateProvider` with the core plugin (the same extension mechanism a new vendor's hardware driver uses - see the core plugin's README, "Extending"). Once both plugins are installed and enabled:
98
+
99
+ - Its prompts show up as ordinary entries in the core plugin's own "Template" picker, suffixed `(GenAI)` - pick one exactly like you'd pick a `.svg` file. No separate "render mode" setting.
100
+ - All LLM provider/model/API-key configuration lives in **this** plugin's own config screen - the core plugin knows nothing about any of that.
101
+ - If the LLM call fails (network/API error, exhausted retries) or its response isn't a renderable SVG, the **core** plugin pushes its own generic fallback-warning template instead of leaving the previous, possibly now-wrong, content on screen - the same safety net a broken hand-authored template gets. The next scheduled repaint retries automatically.
84
102
 
85
- This plugin's own `package.json` currently points its `@rhizomatics/signalk-einklabel-plugin` dependency at a local `file:` path for development against an unpublished core plugin - update it to a real semver range once both are published.
103
+ Since each repaint may call a (usually paid) LLM API, a GenAI-backed device should use Repaint Trigger `interval`, not `subscription`.
104
+
105
+ ### Adding another LLM provider
106
+
107
+ The provider list above is `resolveModel`'s (`src/llmGateway.ts`) built-in switch, but you're not limited to it: call `registerLlmProvider(name, factory)` (exported from this plugin, mirroring the core plugin's own extension points) from your own SignalK plugin's `start()` to add support for a provider this plugin doesn't bundle, without forking it. A registered name takes priority over the built-in switch, so it can even replace one of the names above.
108
+
109
+ ```ts
110
+ import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
111
+ genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
112
+ ```
113
+
114
+ Declare this package as a regular npm `dependency` (**not** a `peerDependency` - see the core plugin's own README for why) plus `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json`.
@@ -5,10 +5,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  /**
7
7
  * Contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, for testing prompts
8
- * without a device or a live SignalK server. Load via `esl-cli -r <path-to-this-file's-dist> prompt ...`
9
- * - the core CLI pre-scans `-r`/`--require` and loads those modules *before* building its own command
10
- * tree (see its own README/source for why), which is what makes adding a brand-new subcommand from here
11
- * possible at all, unlike a module that only registers a vendor driver/template provider.
8
+ * without a device or a live SignalK server. Load via `esl-cli -r @rhizomatics/signalk-einklabel-genai-plugin/cli prompt ...`
9
+ * (the `./cli` subpath export in package.json, so callers don't need to know this compiles to
10
+ * `dist/cliExtension.js`) - the core CLI pre-scans `-r`/`--require` and loads those modules *before*
11
+ * building its own command tree (see its own README/source for why), which is what makes adding a
12
+ * brand-new subcommand from here possible at all, unlike a module that only registers a vendor
13
+ * driver/template provider.
14
+ *
15
+ * Deliberately NOT loaded from this package's main entry (`./index.ts`, what SignalK server actually
16
+ * requires to load the plugin) - the core CLI module this file requires below calls
17
+ * `program.parseAsync(process.argv)` at the top level as a side effect of being required, which is
18
+ * correct for a CLI invocation but would misfire if pulled into a running SignalK server process.
12
19
  *
13
20
  * A plain top-level `require()` (rather than a static `import`) of the core CLI module is deliberate -
14
21
  * it makes the load-time side effect (adding commands to an already-running CLI process) obvious to a
@@ -95,7 +102,7 @@ addPromptOptions(cli.program
95
102
  .description("Call an LLM with a resolved prompt and write the rendered SVG's PNG, without needing a device"))
96
103
  .requiredOption("-o, --output <path>", "output PNG path")
97
104
  .option("--save-svg <path>", "also write the LLM's raw SVG response to this path (feed it straight into the core plugin's `paint -t`/`render -t` afterwards)")
98
- .option("--llm-provider <name>", "openai, anthropic, google, xai, deepseek, moonshotai, ollama, or local", "openai")
105
+ .option("--llm-provider <name>", "openrouter, openai, anthropic, google, xai, deepseek, moonshotai, ollama, or local", "openrouter")
99
106
  .option("--llm-api-key <key>", "LLM API key - falls back to the provider's usual environment variable if omitted")
100
107
  .requiredOption("--llm-model <name>", "provider-specific model id/name, e.g. gpt-4o, claude-sonnet-4-5, gemini-2.5-flash, grok-4, or a local/ollama model tag")
101
108
  .option("--llm-base-url <url>", 'OpenAI-compatible endpoint - required if --llm-provider is "local"; for "ollama" defaults to http://localhost:11434/v1')
package/dist/config.d.ts CHANGED
@@ -3,9 +3,11 @@ export interface PluginConfig {
3
3
  * Which LLM gateway a prompt is sent to - see `./llmGateway.ts`. `"ollama"`/`"local"` both go through
4
4
  * the generic OpenAI-compatible provider; any other value not in this list is looked up in the
5
5
  * `registerLlmProvider` registry (`./llmProviderRegistry.ts`) before giving up - so someone keen can
6
- * add support for another provider entirely without forking this plugin. Defaults to `"openai"`.
6
+ * add support for another provider entirely without forking this plugin. Defaults to `"openrouter"`,
7
+ * the only provider bundled as a regular `dependency` rather than an `optionalDependency` - see
8
+ * `./llmGateway.ts`'s top doc comment.
7
9
  */
8
- llmProvider?: "openai" | "anthropic" | "google" | "xai" | "deepseek" | "moonshotai" | "ollama" | "local";
10
+ llmProvider?: "openrouter" | "openai" | "anthropic" | "google" | "xai" | "deepseek" | "moonshotai" | "ollama" | "local";
9
11
  /** Ignored for `llmProvider: "ollama"`/`"local"` unless the server itself requires one. */
10
12
  llmApiKey?: string;
11
13
  /** Provider-specific model id, e.g. `"gpt-4o"`, `"claude-sonnet-4-5"`, `"gemini-2.5-flash"`, or an Ollama/local model tag. */
@@ -27,6 +29,13 @@ export interface PluginConfig {
27
29
  * absolute path. Use `resolvePromptsDir` to turn this into an actual path.
28
30
  */
29
31
  promptsDir: string;
32
+ /**
33
+ * When `true`, `start()` sends one test message to the configured LLM and reports the result via
34
+ * `app.setPluginStatus`/`setPluginError` (visible on the server's Plugin Config page) - then clears
35
+ * itself back to `false` via `savePluginOptions` so it doesn't refire on every ordinary restart. Not
36
+ * a persistent setting, just a one-shot "test connection now" checkbox - see `plugin.ts`'s `start()`.
37
+ */
38
+ testConnection?: boolean;
30
39
  }
31
40
  /**
32
41
  * This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
@@ -4,17 +4,31 @@ export type LlmSettings = RegistryLlmSettings & {
4
4
  llmTimeoutSeconds?: number;
5
5
  };
6
6
  /**
7
- * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
8
- * package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
9
- * force every one of them to install, and an install that fails to fetch one it doesn't even use (a
10
- * network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
11
- * for everyone else. That means every reference to one of these packages has to be a `require()` reached
12
- * only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
13
- * module loads, throwing before any config is even read. Types are still fully checked via
14
- * `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
15
- * compile time and leaves no runtime trace for `tsc` to eagerly require.
7
+ * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package (other than `@openrouter/ai-sdk-provider`,
8
+ * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - user picks
9
+ * exactly one provider, so there's no reason to force every one of them to install, and an install that
10
+ * fails to fetch one it doesn't even use (a network hiccup, `npm install --omit=optional`, an unsupported
11
+ * platform) must not break this plugin for everyone else. That means every reference to one of these
12
+ * packages has to be a `require()` reached only when `callLlm` actually needs it - a top-level `import`
13
+ * would be resolved eagerly the moment this module loads, throwing before any config is even read. Types
14
+ * are still fully checked via `typeof import(...)` at each call site, which - unlike a value `import` -
15
+ * is erased entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
16
16
  */
17
17
  export declare function loadOptional<M>(moduleName: string): M;
18
+ /** Whether `moduleName` resolves at all - cheaper than `loadOptional`, since it never actually evaluates the module. */
19
+ export declare function isPackageInstalled(moduleName: string): boolean;
20
+ /**
21
+ * Every `llmProvider` name actually usable right now, for the plugin config screen's `llmProvider`
22
+ * dropdown (`plugin.ts`'s `schema()`) - so a provider whose package isn't installed doesn't even show
23
+ * up as pickable. Rather than a second hardcoded provider-to-package table duplicating `resolveModel`'s
24
+ * switch, this reads this plugin's own `optionalDependencies` straight out of `package.json` (the
25
+ * actual, already-curated list of installable provider packages - `require.resolve`-able but never
26
+ * `require()`'d, so a failed optional install can't crash this) and checks each with `isPackageInstalled`.
27
+ * `"openrouter"` is always included (it's a regular `dependency`, see this module's top doc comment);
28
+ * any name added via `registerLlmProvider` is included unconditionally too, since there's no package to
29
+ * check for those.
30
+ */
31
+ export declare function availableProviders(): string[];
18
32
  /**
19
33
  * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
20
34
  * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
@@ -1,19 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadOptional = loadOptional;
4
+ exports.isPackageInstalled = isPackageInstalled;
5
+ exports.availableProviders = availableProviders;
4
6
  exports.callLlm = callLlm;
5
7
  exports.extractSvg = extractSvg;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const ai_sdk_provider_1 = require("@openrouter/ai-sdk-provider");
6
11
  const llmProviderRegistry_1 = require("./llmProviderRegistry");
7
12
  /**
8
- * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
9
- * package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
10
- * force every one of them to install, and an install that fails to fetch one it doesn't even use (a
11
- * network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
12
- * for everyone else. That means every reference to one of these packages has to be a `require()` reached
13
- * only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
14
- * module loads, throwing before any config is even read. Types are still fully checked via
15
- * `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
16
- * compile time and leaves no runtime trace for `tsc` to eagerly require.
13
+ * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package (other than `@openrouter/ai-sdk-provider`,
14
+ * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - user picks
15
+ * exactly one provider, so there's no reason to force every one of them to install, and an install that
16
+ * fails to fetch one it doesn't even use (a network hiccup, `npm install --omit=optional`, an unsupported
17
+ * platform) must not break this plugin for everyone else. That means every reference to one of these
18
+ * packages has to be a `require()` reached only when `callLlm` actually needs it - a top-level `import`
19
+ * would be resolved eagerly the moment this module loads, throwing before any config is even read. Types
20
+ * are still fully checked via `typeof import(...)` at each call site, which - unlike a value `import` -
21
+ * is erased entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
17
22
  */
18
23
  function loadOptional(moduleName) {
19
24
  try {
@@ -26,6 +31,56 @@ function loadOptional(moduleName) {
26
31
  }
27
32
  /** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
28
33
  const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
34
+ /** Whether `moduleName` resolves at all - cheaper than `loadOptional`, since it never actually evaluates the module. */
35
+ function isPackageInstalled(moduleName) {
36
+ try {
37
+ require.resolve(moduleName);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * A package name from this plugin's own `optionalDependencies` doesn't always match the `llmProvider`
46
+ * id `resolveModel`'s switch uses it for: `ai-sdk-ollama` isn't under the `@ai-sdk/` scope at all, and
47
+ * `@ai-sdk/openai-compatible` backs `"local"`, not a provider literally named "openai-compatible".
48
+ * Every other optional package *does* match `@ai-sdk/<id>` (see `resolveModel`'s cases), so only these
49
+ * two irregular ones need spelling out - everything else is derived by stripping the scope below.
50
+ */
51
+ const PACKAGE_TO_PROVIDER_ID = {
52
+ "ai-sdk-ollama": "ollama",
53
+ "@ai-sdk/openai-compatible": "local",
54
+ };
55
+ /** Preferred dropdown order - purely cosmetic; anything not listed here (a future package, a custom-registered name) sorts after these, alphabetically. */
56
+ const PROVIDER_DISPLAY_ORDER = ["openrouter", "openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"];
57
+ function byDisplayOrder(a, b) {
58
+ const ai = PROVIDER_DISPLAY_ORDER.indexOf(a);
59
+ const bi = PROVIDER_DISPLAY_ORDER.indexOf(b);
60
+ if (ai === -1 && bi === -1)
61
+ return a.localeCompare(b);
62
+ if (ai === -1 || bi === -1)
63
+ return ai === -1 ? 1 : -1;
64
+ return ai - bi;
65
+ }
66
+ /**
67
+ * Every `llmProvider` name actually usable right now, for the plugin config screen's `llmProvider`
68
+ * dropdown (`plugin.ts`'s `schema()`) - so a provider whose package isn't installed doesn't even show
69
+ * up as pickable. Rather than a second hardcoded provider-to-package table duplicating `resolveModel`'s
70
+ * switch, this reads this plugin's own `optionalDependencies` straight out of `package.json` (the
71
+ * actual, already-curated list of installable provider packages - `require.resolve`-able but never
72
+ * `require()`'d, so a failed optional install can't crash this) and checks each with `isPackageInstalled`.
73
+ * `"openrouter"` is always included (it's a regular `dependency`, see this module's top doc comment);
74
+ * any name added via `registerLlmProvider` is included unconditionally too, since there's no package to
75
+ * check for those.
76
+ */
77
+ function availableProviders() {
78
+ const packageJson = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, "..", "package.json"), "utf-8"));
79
+ const installed = Object.keys(packageJson.optionalDependencies ?? {})
80
+ .filter(isPackageInstalled)
81
+ .map((pkg) => PACKAGE_TO_PROVIDER_ID[pkg] ?? pkg.replace(/^@ai-sdk\//, ""));
82
+ return [...new Set(["openrouter", ...installed, ...(0, llmProviderRegistry_1.listRegisteredProviders)()])].sort(byDisplayOrder);
83
+ }
29
84
  /**
30
85
  * Resolves `settings` to a Vercel AI SDK model handle. Checks `registerLlmProvider`'s registry
31
86
  * (`./llmProviderRegistry.ts`) first, by exact `llmProvider` name, before falling through to this
@@ -46,7 +101,7 @@ function resolveModel(settings) {
46
101
  if (!model) {
47
102
  throw new Error("no LLM model configured (llmModel)");
48
103
  }
49
- const provider = settings.llmProvider ?? "openai";
104
+ const provider = settings.llmProvider ?? "openrouter";
50
105
  const registered = (0, llmProviderRegistry_1.getLlmProvider)(provider);
51
106
  if (registered)
52
107
  return registered(settings);
@@ -71,6 +126,9 @@ function resolveModel(settings) {
71
126
  const { createMoonshotAI } = loadOptional("@ai-sdk/moonshotai");
72
127
  return createMoonshotAI({ apiKey: settings.llmApiKey })(model);
73
128
  }
129
+ case "openrouter": {
130
+ return (0, ai_sdk_provider_1.createOpenRouter)({ apiKey: settings.llmApiKey })(model);
131
+ }
74
132
  case "ollama": {
75
133
  const { createOllama } = loadOptional("ai-sdk-ollama");
76
134
  return createOllama({ baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL, apiKey: settings.llmApiKey })(model);
@@ -12,7 +12,8 @@ export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
12
12
  * Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
13
13
  * `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
14
14
  * providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
15
- * ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
15
+ * ollama/local as `optionalDependencies`, plus openrouter as a regular `dependency`), but someone
16
+ * wanting a provider not in that list (a bespoke
16
17
  * wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
17
18
  * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
18
19
  * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
@@ -23,3 +24,5 @@ export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
23
24
  */
24
25
  export declare function registerLlmProvider(name: string, factory: LlmProviderFactory): void;
25
26
  export declare function getLlmProvider(name: string): LlmProviderFactory | undefined;
27
+ /** Names of every custom provider registered via `registerLlmProvider` - see `llmGateway.ts`'s `availableProviders`. */
28
+ export declare function listRegisteredProviders(): string[];
@@ -2,12 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerLlmProvider = registerLlmProvider;
4
4
  exports.getLlmProvider = getLlmProvider;
5
+ exports.listRegisteredProviders = listRegisteredProviders;
5
6
  const providers = new Map();
6
7
  /**
7
8
  * Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
8
9
  * `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
9
10
  * providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
10
- * ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
11
+ * ollama/local as `optionalDependencies`, plus openrouter as a regular `dependency`), but someone
12
+ * wanting a provider not in that list (a bespoke
11
13
  * wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
12
14
  * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
13
15
  * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
@@ -22,3 +24,7 @@ function registerLlmProvider(name, factory) {
22
24
  function getLlmProvider(name) {
23
25
  return providers.get(name);
24
26
  }
27
+ /** Names of every custom provider registered via `registerLlmProvider` - see `llmGateway.ts`'s `availableProviders`. */
28
+ function listRegisteredProviders() {
29
+ return [...providers.keys()];
30
+ }
package/dist/plugin.js CHANGED
@@ -6,7 +6,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createPlugin = createPlugin;
7
7
  const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
8
8
  const config_1 = require("./config");
9
+ const llmGateway_1 = require("./llmGateway");
10
+ const retry_1 = require("./retry");
9
11
  const templateProvider_1 = require("./templateProvider");
12
+ /** Sent by the `testConnection` checkbox - short and cheap on tokens, just enough to prove the round trip works. */
13
+ const TEST_PROMPT = 'Reply with exactly the word "OK", nothing else.';
14
+ /**
15
+ * Runs one test call against `config`'s LLM settings and reports the outcome via
16
+ * `setPluginStatus`/`setPluginError` - both shown on the server's Plugin Config page - then clears the
17
+ * one-shot `testConnection` flag via `savePluginOptions` so it doesn't refire on the next ordinary
18
+ * restart. Fire-and-forget from `start()`: failures are reported through `setPluginError`, not thrown,
19
+ * since nothing in `start()`'s own contract is waiting on this.
20
+ */
21
+ async function runConnectionTest(app, config) {
22
+ const label = `${config.llmProvider ?? "openrouter"}/${config.llmModel ?? "(no model configured)"}`;
23
+ try {
24
+ const reply = await (0, retry_1.withRetries)(config.llmRetries, () => (0, llmGateway_1.callLlm)(config, TEST_PROMPT));
25
+ app.setPluginStatus(`LLM test OK (${label}): ${reply.trim().slice(0, 200)}`);
26
+ }
27
+ catch (err) {
28
+ app.setPluginError(`LLM test failed (${label}): ${err.message}`);
29
+ }
30
+ finally {
31
+ app.savePluginOptions({ ...config, testConnection: false }, (err) => {
32
+ if (err)
33
+ app.debug(`failed to clear testConnection flag: ${err.message}`);
34
+ });
35
+ }
36
+ }
10
37
  function createPlugin(app) {
11
38
  // Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
12
39
  // this closure rather than capturing one fixed config object, so a saved config change takes effect
@@ -24,11 +51,13 @@ function createPlugin(app) {
24
51
  llmProvider: {
25
52
  type: "string",
26
53
  title: "LLM provider",
27
- description: "Which LLM gateway to call - each (other than local/ollama) needs its own optional npm package installed, e.g. " +
28
- '`npm install ai @ai-sdk/anthropic` (see README). "Ollama" defaults to a local Ollama server with no further ' +
29
- 'config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) and needs "LLM base URL" below.',
30
- enum: ["openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"],
31
- default: "openai",
54
+ description: "Which LLM gateway to call - only providers whose npm package is actually installed are listed here; " +
55
+ "install another one's optional package (e.g. `npm install ai @ai-sdk/anthropic`, see README) and reopen " +
56
+ 'this page to add it. "OpenRouter" is bundled and needs no extra install. "Ollama" defaults to a local ' +
57
+ 'Ollama server with no further config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) ' +
58
+ 'and needs "LLM base URL" below.',
59
+ enum: (0, llmGateway_1.availableProviders)(),
60
+ default: "openrouter",
32
61
  },
33
62
  llmApiKey: {
34
63
  type: "string",
@@ -68,12 +97,23 @@ function createPlugin(app) {
68
97
  'picker, suffixed "(GenAI)".',
69
98
  default: (0, config_1.defaultConfig)().promptsDir,
70
99
  },
100
+ testConnection: {
101
+ type: "boolean",
102
+ title: "Test connection now",
103
+ description: "Check this and save to send one test message to the LLM settings above as soon as the plugin (re)starts. " +
104
+ "Watch this plugin's entry on the Plugin Config page for the result (success or the error) - the checkbox " +
105
+ "clears itself afterwards, so it won't refire on the next ordinary restart.",
106
+ default: false,
107
+ },
71
108
  },
72
109
  }),
73
110
  uiSchema: () => ({ llmApiKey: { "ui:widget": "password" } }),
74
111
  start(config) {
75
112
  currentConfig = { ...(0, config_1.defaultConfig)(), ...config };
76
- app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openai"})`);
113
+ app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openrouter"})`);
114
+ if (currentConfig.testConnection) {
115
+ runConnectionTest(app, currentConfig).catch((err) => app.debug(`connection test failed unexpectedly: ${err.message}`));
116
+ }
77
117
  },
78
118
  stop() { },
79
119
  };
@@ -14,9 +14,9 @@ const llmGateway_1 = require("./llmGateway");
14
14
  /** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
15
15
  exports.SUFFIX = "(GenAI)";
16
16
  const GUIDANCE_NAME = (0, path_1.join)(".assets", "target_guidance.md");
17
- /** Strips ` (GenAI)` back off a dropdown entry to get the bare prompt file name, e.g. `"forecast (GenAI)"` -> `"forecast.md"`. */
17
+ /** Strips ` (GenAI)` back off a dropdown entry and restores the `.md` extension hidden from it, e.g. `"forecast (GenAI)"` -> `"forecast.md"`. */
18
18
  function promptNameFor(templateName) {
19
- return templateName.slice(0, templateName.length - ` ${exports.SUFFIX}`.length);
19
+ return `${templateName.slice(0, templateName.length - ` ${exports.SUFFIX}`.length)}.md`;
20
20
  }
21
21
  /**
22
22
  * The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
@@ -38,7 +38,7 @@ function createTemplateProvider(getConfig) {
38
38
  return {
39
39
  suffix: exports.SUFFIX,
40
40
  listTemplates() {
41
- return (0, config_1.promptNameOptions)(promptsDir()).map((name) => `${name} ${exports.SUFFIX}`);
41
+ return (0, config_1.promptNameOptions)(promptsDir()).map((name) => `${name.replace(/\.md$/, "")} ${exports.SUFFIX}`);
42
42
  },
43
43
  describeBindings(templateName) {
44
44
  const { promptPath, guidancePath } = promptPathFor(templateName);
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-genai-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "GenAI prompt generated labels for the eInk Electronic Shelf Label plugin. Contributes prompts as an alternative to hand-authored SVG templates.",
5
5
  "keywords": [
6
6
  "eink",
@@ -24,10 +24,22 @@
24
24
  "files": [
25
25
  "dist",
26
26
  "prompts",
27
- "README.md"
27
+ "docs",
28
+ "README.md",
29
+ "CHANGELOG.md"
28
30
  ],
29
31
  "main": "dist/index.js",
30
32
  "types": "dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./cli": {
39
+ "types": "./dist/cliExtension.d.ts",
40
+ "default": "./dist/cliExtension.js"
41
+ }
42
+ },
31
43
  "scripts": {
32
44
  "build": "tsc",
33
45
  "watch": "tsc --watch",
@@ -39,7 +51,9 @@
39
51
  "lint": "oxlint"
40
52
  },
41
53
  "dependencies": {
42
- "@rhizomatics/signalk-einklabel-plugin": "file:../signalk-einklabel-plugin"
54
+ "@openrouter/ai-sdk-provider": "^3.0.0",
55
+ "@rhizomatics/signalk-einklabel-plugin": "^1.0.0",
56
+ "ai": "^7.0.37"
43
57
  },
44
58
  "devDependencies": {
45
59
  "@signalk/server-api": "^2.28.0",
@@ -59,7 +73,6 @@
59
73
  "@ai-sdk/openai": "^4.0.20",
60
74
  "@ai-sdk/openai-compatible": "^3.0.14",
61
75
  "@ai-sdk/xai": "^4.0.18",
62
- "ai": "^7.0.37",
63
76
  "ai-sdk-ollama": "^4.1.0"
64
77
  },
65
78
  "engines": {
@@ -67,6 +80,10 @@
67
80
  },
68
81
  "signalk": {
69
82
  "displayName": "eInk Label GenAI Rendering",
83
+ "appIcon": "docs/assets/icons/icon_tmp.png",
84
+ "screenshots": [
85
+ "docs/assets/screenshots/example_weather_openai.png"
86
+ ],
70
87
  "requires": [
71
88
  "@rhizomatics/signalk-einklabel-plugin"
72
89
  ]