@cyrilmarin/dsh-lemonade 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.fr.md ADDED
@@ -0,0 +1,186 @@
1
+ # dsh-lemonade-provider
2
+
3
+ Plugin **dsh** qui intègre [Lemonade Server](https://lemonade-server.ai) comme
4
+ fournisseur de modèles du DeepSeek Harness.
5
+
6
+ Lemonade expose une API compatible OpenAI (Chat Completions). Ce plugin branche
7
+ cette API sur le service `ctx.llm` du Harness sous la route provider
8
+ **`lemonade`** :
9
+
10
+ - **Chat Completions** en streaming (SSE) via `POST {baseURL}/chat/completions`
11
+ - **Découverte de modèles** live depuis `GET {baseURL}/models`
12
+ - **Clé API optionnelle** (`LEMONADE_API_KEY`) — utile seulement quand le
13
+ serveur est configuré avec authentification
14
+ - Support **vision** : les blocs image du Harness sont envoyés en `image_url`
15
+ (data URL) aux modèles `vision`
16
+ - **Tool calling** au format OpenAI standard
17
+
18
+ ## Prérequis
19
+
20
+ - Un Lemonade Server en cours d'exécution (par défaut `http://localhost:13305`)
21
+ - Node.js ≥ 22
22
+ - Une installation dsh (profil), par ex. le profil `web`
23
+
24
+ > Le paquet doit être **compilé au préalable** : `pnpm install && pnpm build`
25
+ > (volet « Développement » ci-dessous) — dsh charge le code depuis `lib/`.
26
+
27
+ ## Installation dans un profil
28
+
29
+ Depuis le répertoire du profil (ex. `~/.dsh/profiles/web`) :
30
+
31
+ ```sh
32
+ pnpm add file:../dsh-lemonade-provider
33
+ ```
34
+
35
+ ou, en ligne de commande dsh :
36
+
37
+ ```sh
38
+ dsh plugin --profile web add file:../dsh-lemonade-provider
39
+ ```
40
+
41
+ Ajoutez ensuite une entrée dans `cordis.patch.yml` du profil (voir
42
+ [exemple/cordis.patch.yml](exemple/cordis.patch.yml)) :
43
+
44
+ ```yaml
45
+ - id: llm-lemonade
46
+ name: 'llm-lemonade'
47
+ config:
48
+ baseURL: http://localhost:13305
49
+ ```
50
+
51
+ > `baseURL` est la racine du serveur (un suffixe `/v1` d'anciennes configs est
52
+ > supporté). Si `LEMONADE_BASE_URL` est définie, elle est utilisée quand
53
+ > `baseURL` est omise.
54
+
55
+ ## Configuration
56
+
57
+ | Champ | Type | Défaut | Description |
58
+ | --- | --- | --- | --- |
59
+ | `baseURL` | string | `http://localhost:13305` (ou `LEMONADE_BASE_URL`) | Normalisée à `scheme://host/api` — `/api` ajouté si manquant, `/v1/…` ajouté par endpoint |
60
+ | `apiKeyEnv` | string (credential-ref) | `LEMONADE_API_KEY` | Clé régulière (endpoints /v1/*) |
61
+ | `adminApiKeyEnv` | string (credential-ref) | `LEMONADE_ADMIN_API_KEY` | Clé admin optionnelle (endpoints internes /internal/* et /metrics) |
62
+ | `requireAuth` | boolean | `false` | Échouer quand la clé est absente (distant protégé) |
63
+ | `models` | array | `[]` | Catalogue advisory épinglé par l'utilisateur |
64
+ | `defaultContextWindow` | number | `32768` | Fenêtre de contexte utilisée quand le serveur n'en déclare pas |
65
+ | `maxTokens` | number | `8192` | Cap de sortie par défaut |
66
+ | `streamIdleTimeoutMs` | number | `300000` | Timeout d'inactivité du flux SSE |
67
+ | `retryPolicy` | object | valeurs par défaut | Politique de retry du provider |
68
+
69
+ Chaque entrée dans `models` : `id` (obligatoire), `name`, `description`,
70
+ `contextWindow`, `maxTokens`, `vision` (boolean).
71
+
72
+ ## Découverte des modèles
73
+
74
+ La page Modèles du Harness peut interroger `GET {baseURL}/models` via la
75
+ discovery enregistrée pour l'espace de réglages `llm-lemonade`. Les modèles
76
+ non-téléchargés et ceux routés vers d'autres endpoints (embeddings, image,
77
+ TTS, transcription, …) sont exclus de la liste proposée ; la fenêtre de
78
+ contexte déclarée (`max_context_window`) est reprise quand elle est présente.
79
+
80
+ ## Configuration UI (Settings → Models)
81
+
82
+ La page Settings/Models de dsh n'a pas de porte de sortie tierce : son éditeur ne
83
+ connaît que les namespaces `llm-deepseek` et `llm-pi-ai`. Le plugin branche donc
84
+ sa carte d'édition sur la carte `pi-ai` de la page Models via un patch ponctuel
85
+ du bundle vendu `dsh-client-ui-settings-models` (route `llm-lemonade` → famille
86
+ `pi-ai`). Après toute réinstallation du cache npm (`npm exec`), relancer :
87
+
88
+ ```sh
89
+ node scripts/patch-models-ui.mjs
90
+ node scripts/patch-models-ui-admin.mjs
91
+ ```
92
+
93
+ Le formulaire (Settings → Models -> ligne Lemonade) permet de saisir la clé API
94
+ (optionnelle, stockée via le service credentials sous `LEMONADE_API_KEY`),
95
+ la **clé admin optionnelle** (`LEMONADE_ADMIN_API_KEY` — endpoints internes
96
+ `/internal/*` et `/metrics`, via le patch `patch-models-ui-admin.mjs`),
97
+ la base URL (repli « customized »), et de sélectionner les modèles servis par
98
+ Lemonade via « Fetch available models » (découverte `llm.discoverModels`).
99
+
100
+ ## Vue « Lemonade » (onglet de la conversation)
101
+
102
+ Un onglet **Lemonade** (à côté de Chat/Trajectory) expose les points d'entrée de
103
+ l'API spécifique Lemonade (health/liveness, télémétrie, modèles avec
104
+ Load/Unload/Delete/Fichiers/MAJ, téléchargements contrôlables, et clés cloud).
105
+ Le navigateur appelle le serveur dsh en même origine (`/dsh-lemonade/api/<op>`) ;
106
+ le host proxi vers Lemonade (`src/server-api.ts`) en résolvant baseURL + clé
107
+ (ceux-ci ne quittent jamais le host). La sélection de la clé est **par endpoint** : les endpoints réguliers (/v1/*, /live) s'authentifient avec `LEMONADE_API_KEY`, et les endpoints de contrôle (`/internal/*`, `/metrics`) avec `LEMONADE_ADMIN_API_KEY` (avec repli sur la clé régulière). La route est enregistrée via
108
+ `ctx.webServer.register({ kind: 'prefix', path: '/dsh-lemonade/api', ... })`
109
+ quand le service `webServer` est disponible.
110
+
111
+ ### Bundle client navigateur
112
+
113
+ La moitié navigateur vit dans `src/client/index.js` et est copiée telle quelle
114
+ vers `lib/client.js` par le build (`scripts/copy-client.mjs`). Le bundle
115
+ s'enregistre auprès du module loader sous le **nom du paquet** —
116
+ `@cyrilmarin/dsh-lemonade` — car le harness identifie les modules client des plugins
117
+ par leur nom de paquet dans son manifeste de boot :
118
+
119
+ ```js
120
+ window.__ModuleLoader__.load({ id: "@cyrilmarin/dsh-lemonade", factory: (require) => { /* … */ } })
121
+ ```
122
+
123
+ L'id d'enregistrement doit correspondre exactement à l'id de la ligne du graphe ;
124
+ en cas de désaccord, le harness échoue avec *« loaded without registering
125
+ `<id>` via `__ModuleLoader__.load` »*. Comme `lib/` est ignoré par git (sortie de
126
+ build), pensez toujours à reconstruire après toute modification de
127
+ `src/client/index.js` et à réinstaller le paquet dans le profil avant de
128
+ recharger le GUI.
129
+
130
+ ## Développement
131
+
132
+ ```sh
133
+ pnpm install
134
+ pnpm build # compile TypeScript vers lib/
135
+ pnpm test # tests du protocole (serveur SSE simulé)
136
+ ```
137
+
138
+ ### Structure
139
+
140
+ - `src/index.ts` — plugin : schéma de config, `apply`, découverte, credentials
141
+ - `src/adapter.ts` — `LemonadeAdapter extends LlmAdapter` (fetch + SSE)
142
+ - `src/serialize.ts` — messages Harness → filaire OpenAI
143
+ - `src/translate.ts` — payloads SSE → chunks `StreamChunk`
144
+ - `src/client/index.js` — moitié navigateur (onglet Lemonade), copiée vers `lib/client.js`
145
+ - `test/adapter.test.mjs` — suite de tests sans dépendance (mock HTTP)
146
+
147
+ ### Licence
148
+
149
+ MIT
150
+
151
+ ## Release
152
+
153
+ L'automatisation de release est un script autonome sans dépendance tierce :
154
+ `scripts/release.mjs`. Commandes disponibles :
155
+
156
+ ```sh
157
+ pnpm release:dry # affiche le plan (aucun fichier écrit, aucune commande git mutative)
158
+ pnpm release # détecte l'incrément depuis les commits conventionnels
159
+ pnpm release:major # force un bump major
160
+ pnpm release:minor # force un bump minor
161
+ pnpm release:patch # force un bump patch
162
+ ```
163
+
164
+ Le script de release :
165
+
166
+ 1. Détermine l'incrément depuis les commits conventionnels entre le dernier
167
+ tag (ou tout l'historique s'il n'y a aucun tag) et HEAD :
168
+
169
+ | Commit | Bump |
170
+ | --- | --- |
171
+ | sujet `type(scope)!: …` ou `BREAKING CHANGE:` dans le corps | major |
172
+ | `feat` | minor |
173
+ | `fix`, `perf` | patch |
174
+ | tout autre commit non encore tagué | patch (fallback) |
175
+
176
+ S'il n'y a aucun commit à publier, il affiche un message et sort avec le
177
+ code 0 sans rien faire.
178
+ 2. Incrémente `version` dans `package.json` (un suffixe pré-release, s'il en
179
+ existe un, est retiré — la release est finale).
180
+ 3. Commite `chore(release): <version>` (ne stage que `package.json`).
181
+ 4. Crée le tag annoté `v<version>` (préfixe configurable via `--tag-prefix`).
182
+ 5. Pousse la branche courante et le tag vers `origin` (omis avec `--no-push`).
183
+
184
+ > La publication npm proprement dite n'est pas réalisée par le script : elle
185
+ > est déclenchée par la GitHub Release créée sur le tag poussé (workflow
186
+ > [.github/workflows/publish.yml](.github/workflows/publish.yml)).
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # dsh-lemonade-provider
2
+
3
+ A **dsh** plugin that integrates [Lemonade Server](https://lemonade-server.ai) as
4
+ a model provider for DeepSeek Harness.
5
+
6
+ Lemonade exposes an OpenAI-compatible API (Chat Completions). This plugin wires
7
+ this API to the Harness's `ctx.llm` service under the **`lemonade`** provider
8
+ route:
9
+
10
+ - **Chat Completions** streaming (SSE) via `POST {baseURL}/chat/completions`
11
+ - **Model discovery** live from `GET {baseURL}/models`
12
+ - **Optional API key** (`LEMONADE_API_KEY`) — only needed when
13
+ the server is configured with authentication
14
+ - **Vision support**: Harness image blocks are sent as `image_url`
15
+ (data URL) to `vision` models
16
+ - **Tool calling** in standard OpenAI format
17
+
18
+ ## Prerequisites
19
+
20
+ - A running Lemonade Server (default `http://localhost:13305`)
21
+ - Node.js ≥ 22
22
+ - A dsh installation (profile), e.g. the `web` profile
23
+
24
+ > The package must be **built beforehand**: `pnpm install && pnpm build`
25
+ > ("Development" section below) — dsh loads code from `lib/`.
26
+
27
+ ## Installation in a profile
28
+
29
+ From the profile directory (e.g. `~/.dsh/profiles/web`):
30
+
31
+ ```sh
32
+ pnpm add file:../dsh-lemonade-provider
33
+ ```
34
+
35
+ Or, via the dsh command line:
36
+
37
+ ```sh
38
+ dsh plugin --profile web add file:../dsh-lemonade-provider
39
+ ```
40
+
41
+ Then add an entry to the profile's `cordis.patch.yml` (see
42
+ [example/cordis.patch.yml](example/cordis.patch.yml)):
43
+
44
+ ```yaml
45
+ - id: llm-lemonade
46
+ name: 'llm-lemonade'
47
+ config:
48
+ baseURL: http://localhost:13305
49
+ ```
50
+
51
+ > `baseURL` is the server root (an old `/v1` suffix from legacy configs is also
52
+ > supported). If `LEMONADE_BASE_URL` is set, it is used when `baseURL` is omitted.
53
+
54
+ ## Configuration
55
+
56
+ | Field | Type | Default | Description |
57
+ | --- | --- | --- | --- |
58
+ | `baseURL` | string | `http://localhost:13305` (or `LEMONADE_BASE_URL`) | Normalized to `scheme://host/api` — `/api` appended if missing, `/v1/…` appended per endpoint |
59
+ | `apiKeyEnv` | string (credential-ref) | `LEMONADE_API_KEY` | Regular API key (for /v1/* endpoints) |
60
+ | `adminApiKeyEnv` | string (credential-ref) | `LEMONADE_ADMIN_API_KEY` | Optional admin API key (for internal /internal/* and /metrics endpoints) |
61
+ | `requireAuth` | boolean | `false` | Fail when the key is absent (protected remote) |
62
+ | `models` | array | `[]` | User-pinned advisory model catalog |
63
+ | `defaultContextWindow` | number | `32768` | Context window used when the server doesn't declare one |
64
+ | `maxTokens` | number | `8192` | Default output cap |
65
+ | `streamIdleTimeoutMs` | number | `300000` | SSE stream idle timeout |
66
+ | `retryPolicy` | object | default values | Provider retry policy |
67
+
68
+ Each entry in `models`: `id` (required), `name`, `description`,
69
+ `contextWindow`, `maxTokens`, `vision` (boolean).
70
+
71
+ ## Model Discovery
72
+
73
+ The Harness Models page can query `GET {baseURL}/models` through the
74
+ discovery registered for the `llm-lemonade` settings namespace. Undownloaded
75
+ models and those routed to other endpoints (embeddings, image,
76
+ TTS, transcription, …) are excluded from the proposed list; the declared
77
+ context window (`max_context_window`) is reused when present.
78
+
79
+ ## UI Configuration (Settings → Models)
80
+
81
+ The dsh Settings/Models page has no third-party exit gate: its editor only
82
+ knows the `llm-deepseek` and `llm-pi-ai` namespaces. The plugin therefore wires
83
+ its edit card to the `pi-ai` card on the Models page through a targeted patch
84
+ of the bundled `dsh-client-ui-settings-models` bundle (`llm-lemonade` route →
85
+ `pi-ai` family). After any npm cache reinstallation (`npm exec`), rerun:
86
+
87
+ ```sh
88
+ node scripts/patch-models-ui.mjs
89
+ node scripts/patch-models-ui-admin.mjs
90
+ ```
91
+
92
+ The form (Settings → Models -> Lemonade row) allows entering the API key
93
+ (optional, stored via the credentials service under `LEMONADE_API_KEY`),
94
+ the **optional admin key** (`LEMONADE_ADMIN_API_KEY` — internal endpoints
95
+ `/internal/*` and `/metrics`, via the `patch-models-ui-admin.mjs` patch),
96
+ the base URL (falling back to "customized"), and selecting models served by
97
+ Lemonade via "Fetch available models" (`llm.discoverModels`).
98
+
99
+ ## Lemonade View (Conversation Tab)
100
+
101
+ A **Lemonade** tab (next to Chat/Trajectory) exposes the entry points of the
102
+ Lemonade-specific API (health/liveness, telemetry, models with
103
+ Load/Unload/Delete/Files/Update, controllable downloads, and cloud keys).
104
+ The browser calls the dsh server on the same origin (`/dsh-lemonade/api/<op>`);
105
+ the host proxy to Lemonade (`src/server-api.ts`) resolves baseURL + key
106
+ (these never leave the host). Key selection is **per endpoint**: regular
107
+ endpoints (/v1/*, /live) authenticate with `LEMONADE_API_KEY`, and control
108
+ endpoints (`/internal/*`, `/metrics`) with `LEMONADE_ADMIN_API_KEY` (falling
109
+ back to the regular key). The route is registered via
110
+ `ctx.webServer.register({ kind: 'prefix', path: '/dsh-lemonade/api', ... })`
111
+ when the `webServer` service is available.
112
+
113
+ ### Client browser bundle
114
+
115
+ The browser half lives in `src/client/index.js` and is copied verbatim to
116
+ `lib/client.js` by the build (`scripts/copy-client.mjs`). The bundle registers
117
+ itself with the module loader under the **package name** — `@cyrilmarin/dsh-lemonade`
118
+ — because the harness keys plugin client modules by package name in its boot
119
+ manifest:
120
+
121
+ ```js
122
+ window.__ModuleLoader__.load({ id: "@cyrilmarin/dsh-lemonade", factory: (require) => { /* … */ } })
123
+ ```
124
+
125
+ The registration id must match the graph row id exactly; a mismatch makes the
126
+ harness fail with *"loaded without registering `<id>` via `__ModuleLoader__.load`"*.
127
+ Because `lib/` is gitignored (build output), always rebuild after touching
128
+ `src/client/index.js` and reinstall the package in the profile before reloading
129
+ the GUI.
130
+
131
+ ## Development
132
+
133
+ ```sh
134
+ pnpm install
135
+ pnpm build # compile TypeScript to lib/
136
+ pnpm test # protocol tests (simulated SSE server)
137
+ ```
138
+
139
+ ### Structure
140
+
141
+ - `src/index.ts` — plugin: config schema, `apply`, discovery, credentials
142
+ - `src/adapter.ts` — `LemonadeAdapter extends LlmAdapter` (fetch + SSE)
143
+ - `src/serialize.ts` — Harness messages → OpenAI wire format
144
+ - `src/translate.ts` — SSE payloads → `StreamChunk` chunks
145
+ - `src/client/index.js` — browser half (Lemonade conversation tab), copied to `lib/client.js`
146
+ - `test/adapter.test.mjs` — dependency-free test suite (mock HTTP)
147
+
148
+ ### License
149
+
150
+ MIT
151
+
152
+ ## Release
153
+
154
+ Release automation is a self-contained script with no third-party dependency:
155
+ `scripts/release.mjs`. Available commands:
156
+
157
+ ```sh
158
+ pnpm release:dry # show the plan (no file writes, no mutating git commands)
159
+ pnpm release # auto-detect the bump from the conventional commits
160
+ pnpm release:major # force a major bump
161
+ pnpm release:minor # force a minor bump
162
+ pnpm release:patch # force a patch bump
163
+ ```
164
+
165
+ The release script:
166
+
167
+ 1. Determines the increment from the conventional commits between the last
168
+ tag (or the full history when no tag exists) and HEAD:
169
+
170
+ | Commit | Bump |
171
+ | --- | --- |
172
+ | `type(scope)!: …` subject or `BREAKING CHANGE:` in the body | major |
173
+ | `feat` | minor |
174
+ | `fix`, `perf` | patch |
175
+ | any other commit not yet tagged | patch (fallback) |
176
+
177
+ With no commit to publish, it prints a message and exits 0 without doing
178
+ anything.
179
+ 2. Bumps `version` in `package.json` (a prerelease suffix, if present, is
180
+ dropped — the release is final).
181
+ 3. Commits `chore(release): <version>` (stages `package.json` only).
182
+ 4. Creates the annotated tag `v<version>` (prefix configurable via
183
+ `--tag-prefix`).
184
+ 5. Pushes the current branch and the tag to `origin` (omitted with
185
+ `--no-push`).
186
+
187
+ > The npm publication itself is not performed by the script: it is triggered
188
+ > by the GitHub Release created on the pushed tag (workflow
189
+ > [.github/workflows/publish.yml](.github/workflows/publish.yml)).
package/lib/adapter.js ADDED
@@ -0,0 +1,287 @@
1
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, attributionHeaders, isContextWindowExceededError, isQuotaExceededError, } from '@deepseek-ai/dsh-llm';
2
+ import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout';
3
+ import { serializeRequest } from './serialize.js';
4
+ import { parseSse, translate } from './translate.js';
5
+ /** Default endpoint (baseURL is the server root; /v1 paths are appended by each endpoint builder). */
6
+ export const DEFAULT_BASE_URL = 'http://localhost:13305';
7
+ /** Default combined request/response context capacity for models with no metadata. */
8
+ export const DEFAULT_CONTEXT_WINDOW = 32768;
9
+ /** Default per-request output-token cap. */
10
+ export const DEFAULT_MAX_TOKENS = 8192;
11
+ /** Default maximum idle interval while an adapter stream read is outstanding. */
12
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
13
+ /** Maximum time one live model-listing query may take. */
14
+ export const LISTING_TIMEOUT_MS = 5_000;
15
+ const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT';
16
+ /**
17
+ * Deployment labels that route a model to a non-chat endpoint; such models are
18
+ * excluded from the chat model listing. Characteristic labels (vision,
19
+ * reasoning, tool-calling, …) and chat-capable modality labels are kept.
20
+ */
21
+ const NON_CHAT_LABELS = new Set(['transcription', 'embeddings', 'reranking', 'image', 'edit', 'tts']);
22
+ /** Map an HTTP status to a stable LlmError code. */
23
+ function httpErrorCode(status, error) {
24
+ if (status === 401 || status === 403)
25
+ return 'AUTH';
26
+ const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ');
27
+ if (isQuotaExceededError(detail))
28
+ return QUOTA_EXCEEDED_CODE;
29
+ if (status === 429)
30
+ return 'RATE_LIMIT';
31
+ if (status === 400) {
32
+ if (isContextWindowExceededError(detail))
33
+ return CONTEXT_WINDOW_EXCEEDED_CODE;
34
+ return 'INVALID_REQUEST';
35
+ }
36
+ if (status >= 500)
37
+ return 'SERVER';
38
+ return `HTTP_${status}`;
39
+ }
40
+ function providerRetryAfterMs(value) {
41
+ if (value === null)
42
+ return undefined;
43
+ if (/^\d+$/.test(value)) {
44
+ const delay = Number(value) * 1_000;
45
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
46
+ }
47
+ const delay = Date.parse(value) - Date.now();
48
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
49
+ }
50
+ function requestId(headers) {
51
+ const value = headers.get('x-request-id') ?? headers.get('x-lemonade-request-id');
52
+ return value === null || value.length === 0 ? undefined : ProviderRequestId(value);
53
+ }
54
+ /**
55
+ * Read one Lemonade model listing, filtering out models that are not chat
56
+ * completions targets (non-downloaded entries, and entries whose deployment
57
+ * labels route them to another endpoint).
58
+ */
59
+ export async function fetchModelEntries(baseURL, apiKey, signal) {
60
+ const url = `${baseURL}/v1/models`;
61
+ const headers = { accept: 'application/json', ...attributionHeaders() };
62
+ if (apiKey !== undefined)
63
+ headers.authorization = `Bearer ${apiKey}`;
64
+ let response;
65
+ try {
66
+ response = await fetch(url, { method: 'GET', headers, signal });
67
+ }
68
+ catch (error) {
69
+ if (signal?.aborted)
70
+ throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error });
71
+ throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error });
72
+ }
73
+ if (!response.ok) {
74
+ throw new LlmError(`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`, 'DISCOVERY_FAILED');
75
+ }
76
+ let body;
77
+ try {
78
+ body = await response.json();
79
+ }
80
+ catch (error) {
81
+ throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error });
82
+ }
83
+ const data = body?.data;
84
+ if (!Array.isArray(data)) {
85
+ throw new LlmError(`${url} has no "data" array; enter this server's models by hand`, 'DISCOVERY_FAILED');
86
+ }
87
+ const entries = [];
88
+ const seen = new Set();
89
+ for (const raw of data) {
90
+ const record = (raw ?? {});
91
+ const id = typeof record['id'] === 'string' ? record['id'] : '';
92
+ if (id.length === 0 || seen.has(id))
93
+ continue;
94
+ // Skip alias entries (Lemonade Server exposes model aliases alongside real
95
+ // models; an alias has a "model" field pointing to its target).
96
+ if (typeof record['model'] === 'string' && record['model'].length > 0)
97
+ continue;
98
+ if (record['downloaded'] === false)
99
+ continue;
100
+ const labels = Array.isArray(record['labels'])
101
+ ? record['labels'].filter((label) => typeof label === 'string')
102
+ : [];
103
+ if (labels.some((label) => NON_CHAT_LABELS.has(label)))
104
+ continue;
105
+ seen.add(id);
106
+ const maxContextWindow = typeof record['max_context_window'] === 'number' && record['max_context_window'] > 0
107
+ ? record['max_context_window']
108
+ : undefined;
109
+ entries.push({
110
+ id,
111
+ ...(maxContextWindow !== undefined ? { maxContextWindow } : {}),
112
+ ...(labels.length > 0 ? { labels } : {}),
113
+ ...(labels.includes('vision') ? { vision: true } : {}),
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+ /**
119
+ * Interrogate one Lemonade endpoint for the models it advertises, mapped to
120
+ * the harness discovery vocabulary (id + optional context window).
121
+ */
122
+ export async function discoverModels(baseURL, apiKey, signal) {
123
+ const entries = await fetchModelEntries(baseURL, apiKey, signal);
124
+ return entries.map((entry) => ({
125
+ id: entry.id,
126
+ ...(entry.maxContextWindow !== undefined ? { contextWindow: entry.maxContextWindow } : {}),
127
+ }));
128
+ }
129
+ /** Build display metadata for one model. */
130
+ function modelInfo(provider, id, live, configured) {
131
+ return {
132
+ provider,
133
+ id,
134
+ name: configured?.name ?? id,
135
+ ...(configured?.description !== undefined ? { description: configured.description } : {}),
136
+ inputModalities: live?.vision === true || configured?.vision === true ? ['text', 'image'] : ['text'],
137
+ };
138
+ }
139
+ /**
140
+ * The Lemonade adapter. One instance serves every model name it is registered
141
+ * under (the harness model name IS the wire model name).
142
+ *
143
+ * One stable signal reaches both the initial fetch and the body reads. Caller
144
+ * aborts map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
145
+ */
146
+ export class LemonadeAdapter extends LlmAdapter {
147
+ config;
148
+ /** The most recent successful live listing, keyed by model id (advisory cache, never authoritative). */
149
+ lastKnown = new Map();
150
+ constructor(config) {
151
+ super();
152
+ this.config = config;
153
+ }
154
+ providerInfo(provider) {
155
+ return { id: provider, name: 'Lemonade' };
156
+ }
157
+ providerRetryPolicy(_provider) {
158
+ return this.config.options().retryPolicy;
159
+ }
160
+ async listModels(provider) {
161
+ const options = this.config.options();
162
+ // The configured catalog IS the selection: when models are pinned in the
163
+ // plugin configuration, the model selector offers ONLY those.
164
+ if (options.models.length > 0) {
165
+ return options.models.map((model) => modelInfo(provider, model.id, undefined, model));
166
+ }
167
+ // No configured selection: advertise whatever the server currently offers.
168
+ try {
169
+ const apiKey = await this.config.resolveApiKey();
170
+ const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(LISTING_TIMEOUT_MS));
171
+ this.lastKnown = new Map(entries.map((entry) => [entry.id, entry]));
172
+ return entries.map((entry) => modelInfo(provider, entry.id, entry));
173
+ }
174
+ catch {
175
+ return [];
176
+ }
177
+ }
178
+ async resolveModel(provider, model, _signal) {
179
+ const options = this.config.options();
180
+ const configured = options.models.find((entry) => entry.id === model);
181
+ const live = this.lastKnown.get(model);
182
+ const contextWindow = configured?.contextWindow ?? live?.maxContextWindow ?? options.defaultContextWindow;
183
+ return {
184
+ ...modelInfo(provider, model, live, configured),
185
+ context: { contextWindow },
186
+ defaultMaxTokens: configured?.maxTokens ?? options.maxTokens,
187
+ };
188
+ }
189
+ async *stream(options) {
190
+ const connection = this.config.options();
191
+ const apiKey = await this.config.resolveApiKey();
192
+ const consumer = new AbortController();
193
+ const watchdog = idleWatchdog(options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]), connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE);
194
+ const iterator = this.request(options, watchdog.signal, connection, apiKey, () => watchdog.pulse())[Symbol.asyncIterator]();
195
+ let exhausted = false;
196
+ try {
197
+ while (true) {
198
+ const result = await watchdog.next(iterator);
199
+ if (result.done) {
200
+ exhausted = true;
201
+ return;
202
+ }
203
+ yield result.value;
204
+ }
205
+ }
206
+ catch (error) {
207
+ if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
208
+ throw new LlmError(`Lemonade stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error });
209
+ }
210
+ if (options.signal?.aborted)
211
+ throw new LlmError('Lemonade request aborted by caller', 'ABORTED', { cause: error });
212
+ if (error instanceof LlmError)
213
+ throw error;
214
+ throw new LlmError(`Lemonade API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error });
215
+ }
216
+ finally {
217
+ consumer.abort('Lemonade stream consumer stopped');
218
+ watchdog[Symbol.dispose]();
219
+ if (!exhausted && iterator.return !== undefined) {
220
+ try {
221
+ await iterator.return(undefined);
222
+ }
223
+ catch {
224
+ // transport teardown — the streaming error (if any) already surfaced above
225
+ }
226
+ }
227
+ }
228
+ }
229
+ /** Build the image resolver from the mounted attachment service, if any. */
230
+ async resolveImage(signal) {
231
+ const attachments = this.config.resolveAttachments();
232
+ if (attachments === undefined)
233
+ return undefined;
234
+ return async (ref) => {
235
+ const stored = await attachments.readImage(ref, signal);
236
+ const base64 = Buffer.from(stored.data).toString('base64');
237
+ return `data:${stored.ref.mediaType};base64,${base64}`;
238
+ };
239
+ }
240
+ async *request(options, signal, connection, apiKey, onComment) {
241
+ const resolveImage = await this.resolveImage(signal);
242
+ const body = await serializeRequest(options, resolveImage);
243
+ const headers = {
244
+ 'content-type': 'application/json',
245
+ accept: 'text/event-stream',
246
+ ...attributionHeaders(),
247
+ ...(apiKey !== undefined ? { authorization: `Bearer ${apiKey}` } : {}),
248
+ };
249
+ const url = `${connection.baseURL}/v1/chat/completions`;
250
+ let response;
251
+ try {
252
+ response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
253
+ }
254
+ catch (error) {
255
+ if (signal.aborted)
256
+ throw error;
257
+ throw new LlmError(`Lemonade API request to ${url} failed`, 'TRANSPORT', { cause: error });
258
+ }
259
+ if (!response.ok) {
260
+ let message = `Lemonade API error (HTTP ${response.status})`;
261
+ let providerError;
262
+ try {
263
+ const parsed = await response.json();
264
+ if (parsed && typeof parsed === 'object' && parsed.error && typeof parsed.error === 'object') {
265
+ providerError = parsed.error;
266
+ if (typeof providerError.message === 'string' && providerError.message.length > 0) {
267
+ message = providerError.message;
268
+ }
269
+ }
270
+ }
271
+ catch {
272
+ // no parseable error body — keep the generic message
273
+ }
274
+ const delay = providerRetryAfterMs(response.headers.get('retry-after'));
275
+ const id = requestId(response.headers);
276
+ throw new LlmError(message, httpErrorCode(response.status, providerError), {
277
+ status: response.status,
278
+ ...(delay !== undefined ? { providerRetryAfterMs: delay } : {}),
279
+ ...(id !== undefined ? { requestId: id } : {}),
280
+ });
281
+ }
282
+ if (!response.body)
283
+ throw new LlmError('Lemonade API returned no response body', 'EMPTY_RESPONSE');
284
+ yield* translate(parseSse(response.body, onComment));
285
+ }
286
+ }
287
+ //# sourceMappingURL=adapter.js.map