@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 +186 -0
- package/README.md +189 -0
- package/lib/adapter.js +287 -0
- package/lib/client.js +561 -0
- package/lib/index.js +263 -0
- package/lib/serialize.js +113 -0
- package/lib/server-api.js +356 -0
- package/lib/translate.js +163 -0
- package/lib/types/adapter.d.ts +94 -0
- package/lib/types/index.d.ts +91 -0
- package/lib/types/serialize.d.ts +68 -0
- package/lib/types/server-api.d.ts +77 -0
- package/lib/types/translate.d.ts +10 -0
- package/package.json +94 -0
- package/src/adapter.ts +381 -0
- package/src/client/index.js +561 -0
- package/src/index.ts +323 -0
- package/src/serialize.ts +171 -0
- package/src/server-api.ts +402 -0
- package/src/translate.ts +190 -0
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
|