@huaweidevtools/aishell-dsh-llm 0.0.1-beta.1 → 0.0.1-beta.3

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/LICENSE CHANGED
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright 2024 Huawei Cloud AI Shell
189
+ Copyright 2026 Huawei Cloud AI Shell
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  OpenAI-compatible LLM adapter for DSH. Registers the `aishell` provider route on `ctx.llm`, routing model calls through the aishell gateway with direct AK/SK signing.
4
4
 
5
+ Besides the live route, the plugin declares `aishell` in the configurable-provider directory (`ctx.llm.registerConfigurableProviders`) — the directory the Models page joins with live routes, so the route shows up as a configurable provider row with its own settings address. The plugin also carries a Config schema; the settings mirror only lists namespaces whose plugin declares one, so without it the Models page would not show the row.
6
+
7
+ ## The Models-page row
8
+
9
+ The row itself is read-only: the profile has no fields the host editor knows how to write, and credentials are entered through the form the `aishell-credentials` chips open (the account chip beside the chat input, or the chip on a New Session page). The browser half therefore does not extend the row with a card — it shadows the row's header at runtime (`src/client/provider-row-shadow.ts`, the same approach `aishell-brand` uses for its digest-protected host residues): a status dot after the row's name (green when AK/SK are stored, warn when they are not, with the readiness copy as its hover title) and the header's Edit button hidden, because there is nothing to edit. A MutationObserver keeps the shadow applied across the page's re-renders; the shadow is re-installed on a locale switch so the hover copy follows the active language; disabling the plugin restores the official row exactly.
10
+
5
11
  ## How it works
6
12
 
7
13
  ```
@@ -9,7 +15,7 @@ User selects aishell/<model>
9
15
  → GatewayAdapter.stream(options)
10
16
 
11
17
  Every request is signed directly with AK/SK (SDK-HMAC-SHA256):
12
- → credential store reads HW_ACCESS_KEY + HW_SECRET_KEY (+ optional HW_SECURITY_TOKEN)
18
+ → credential store reads the AK/SK pair (one record; optional STS token)
13
19
  → sign(POST, host, /chat/completions, query, body) → signed headers
14
20
  → POST {baseUrl}/chat/completions (signed headers, OpenAI wire format)
15
21
  → parseSse → translate → StreamChunks
@@ -21,6 +27,29 @@ User selects aishell/<model>
21
27
 
22
28
  No intermediate API-key exchange or config fetch — every request is signed with the user's AK/SK. The credential store (OS keyring with fingerprint + memory fallback) holds the AK/SK; this adapter only reads them.
23
29
 
30
+ ## Image input
31
+
32
+ The adapter carries images for models whose gateway catalog entry lists `image` in `modalities`. A model the catalog describes as text-only never receives one: `dsh-llm` projects such an image to a stable placeholder before the adapter sees it.
33
+
34
+ For an image-capable model the pipeline is:
35
+
36
+ ```
37
+ harness history (durable image *references*, not bytes)
38
+ → prepareRequestImages: one request version per attachment, via ctx.attachments
39
+ → byte budget check → IMAGE_OFFLOAD_REQUIRED when over
40
+ → projectOffloaded: occurrences an earlier offload decision marked
41
+ → serialize: [handle text, base64 data URL] parts on the user message
42
+ ```
43
+
44
+ Two properties are worth knowing:
45
+
46
+ - **Bytes are read from the attachment service, not the block.** An image block holds an opaque `attachmentId` that is deliberately neither a path nor a URL, so the adapter resolves bytes through `ctx.attachments.readImageRequest`, which also normalizes and re-encodes to the route's target size. A deployment without that service fails image requests with `UNSUPPORTED_CONTENT` rather than sending an empty turn.
47
+ - **Tool-result images ride a separate user turn.** OpenAI's `tool` message takes text only, so an image a tool returned is hoisted into a following `user` message — the same shape the ecosystem's OpenAI-path serializer produces.
48
+
49
+ Over-budget requests fail with `IMAGE_OFFLOAD_REQUIRED` and are retried by `@deepseek-ai/dsh-compaction-image-offload`, which `dsh-base` mounts: it marks the oldest occurrences offloaded, after which they travel as placeholder text. This adapter never offloads on its own.
50
+
51
+ Image token accounting uses the token meter's structural estimate — this adapter implements no `imageRequestPricing`, matching `dsh-llm-pi-ai`. Image-heavy sessions are therefore approximated rather than priced per model.
52
+
24
53
  ## What it does NOT touch
25
54
 
26
55
  - `ctx.credentials` — DSH's own credential provider stays `dsh-credentials-local`. User-configured models and `.credentials.yaml` keep working.
@@ -41,23 +70,30 @@ dsh plugin --profile <name> add @huaweidevtools/aishell-dsh-base
41
70
 
42
71
  | Field | Type | Default | Description |
43
72
  |---|---|---|---|
44
- | `baseUrl` | `string?` | `https://api.aishell.openbuildlab.com` | Chat API base URL. `/chat/completions` and `/models` are appended. |
45
- | `keyringService` | `string?` | `aishell-dsh` | Credential store service name. |
73
+ | `baseUrl` | `string?` | `https://openai.aishell.openbuildlab.com/v1` | Chat API base URL. `/chat/completions` and `/models` are appended, so a base carrying its own path segment is signed and requested in full. |
74
+ | `requestImagePixelBudget` | `number?` | `4194304` | Total-pixel cap one request image is scaled into. |
75
+ | `requestImageMaxBytes` | `number?` | `1048576` | Encoded-byte target for one request image. |
76
+ | `maxRequestImageBytes` | `number?` | `20971520` | Accumulated base64 image bytes one request accepts before the oldest images must be offloaded. |
77
+
78
+ The three image fields share `dsh-llm-pi-ai`'s defaults: the gateway publishes no image budget of its own, so the route supplies one.
46
79
 
47
80
  ## Storing credentials
48
81
 
49
- **Web UI (recommended):** Install `aishell-credentials` (or the `aishell-base` bundle) — the Settings page gets an "AI Shell" section with AK/SK/SecurityToken input fields. Values are transferred to the credential store.
82
+ **Web UI (recommended):** Install `aishell-credentials` (or the `aishell-base` bundle) — clicking the credentials button beside the chat input (or the one on a New Session page) opens a form with AK/SK/SecurityToken fields. Values are verified against Huawei Cloud IAM and transferred to the credential store; the button flips to the account name when they are stored.
50
83
 
51
84
  **CLI fallback:**
52
85
 
53
86
  ```sh
54
87
  node --input-type=module -e "
55
88
  import { createCredentialsStore } from '@huaweidevtools/aishell-dsh-credentials-store';
56
- const s = createCredentialsStore();
57
- s.set('HW_ACCESS_KEY', 'your-ak');
58
- s.set('HW_SECRET_KEY', 'your-sk');
59
- // Optional: temporary security token (STS)
60
- s.set('HW_SECURITY_TOKEN', 'your-token');
89
+ import { writeHuaweiCloud } from '@huaweidevtools/aishell-dsh-credentials-store/accounts';
90
+
91
+ // One record, one write. The pair and its optional STS token go in together.
92
+ writeHuaweiCloud(createCredentialsStore(), {
93
+ ak: 'your-ak',
94
+ sk: 'your-sk',
95
+ // securityToken: 'your-token', // only for temporary credentials
96
+ });
61
97
  "
62
98
  ```
63
99
 
package/lib/client.js ADDED
@@ -0,0 +1,234 @@
1
+ window.__ModuleLoader__.load({ id: "@huaweidevtools/aishell-dsh-llm", factory: (require) => {
2
+ "use strict";
3
+ var module = { exports: {} }; var exports = module.exports;
4
+
5
+ //#region src/provider-name.ts
6
+ /**
7
+ * The aishell provider's display name, shared by both plugin halves.
8
+ *
9
+ * The Host half registers it on the configurable-provider directory (the
10
+ * Models page renders it as the provider row's name), and the browser half's
11
+ * row shadow finds that row by matching the rendered name against this
12
+ * literal (see `client/provider-row-shadow.ts`). It must stay a single
13
+ * literal string — the shadow compares `textContent` with it.
14
+ *
15
+ * It is also the name `providerInfo()` reports, so the picker and the Models
16
+ * row call the route the same thing: this package's whole surface is one
17
+ * product, and qualifying it by cloud ("AI Shell (Huawei Cloud)") added a
18
+ * distinction no reader needed — there is no second AI Shell to tell it
19
+ * apart from.
20
+ */
21
+ const PROVIDER_DISPLAY_NAME = "AI Shell";
22
+
23
+ //#endregion
24
+ //#region src/client/provider-row-shadow.ts
25
+ /** Marker attribute the dot carries (exposed for the spec). */
26
+ const ROW_DOT_MARKER = "data-aishell-provider-dot";
27
+ /** Marker attribute a hidden Edit button carries (exposed for the spec). */
28
+ const ROW_EDIT_HIDDEN = "data-aishell-edit-hidden";
29
+ /** The dot's own CSS: the host credential dot's geometry and palette. */
30
+ const ROW_DOT_CSS = [
31
+ `[class*="rowIdentity"] [${ROW_DOT_MARKER}] {`,
32
+ " box-sizing: border-box;",
33
+ " border-radius: 50%;",
34
+ " flex: none;",
35
+ " width: 8px;",
36
+ " height: 8px;",
37
+ " display: inline-block;",
38
+ "}",
39
+ `[class*="rowIdentity"] [${ROW_DOT_MARKER}="ok"] {`,
40
+ " background: var(--dsw-alias-state-success-primary);",
41
+ "}",
42
+ `[class*="rowIdentity"] [${ROW_DOT_MARKER}="warn"] {`,
43
+ " background: var(--dsw-alias-state-warn-label);",
44
+ "}",
45
+ `[${ROW_EDIT_HIDDEN}] {`,
46
+ " display: none !important;",
47
+ "}"
48
+ ].join("\n");
49
+ /** Every Edit button an aishell row renders (there is ordinarily one; a
50
+ * re-render that leaves a second copy gets both, so none stays visible). */
51
+ function findEditButtons(row) {
52
+ const actions = row.querySelector("[class*=\"rowActions\"]");
53
+ if (actions === null) return [];
54
+ const found = [];
55
+ for (const button of actions.querySelectorAll("button")) {
56
+ const label = button.getAttribute("aria-label") ?? "";
57
+ if (label.includes(PROVIDER_DISPLAY_NAME) || button.textContent === "编辑") found.push(button);
58
+ }
59
+ return found;
60
+ }
61
+ /**
62
+ * Whether `node` is an Element (`querySelectorAll` roots can be non-element
63
+ * nodes like the Document, where the row check below simply answers false).
64
+ */
65
+ function isElement(node) {
66
+ return node.nodeType === Node.ELEMENT_NODE;
67
+ }
68
+ /**
69
+ * Whether `node` is a provider row whose rendered name is ours.
70
+ *
71
+ * Duck-typed on the attribute surface rather than `instanceof Element` so
72
+ * the shadow works identically in the browser and under the specs' DOM
73
+ * stub, which provides the attribute methods without the constructor.
74
+ */
75
+ function isProviderRow(node) {
76
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
77
+ const element = node;
78
+ const name = element.querySelector("[class*=\"rowName\"]");
79
+ return name !== null && name.textContent === PROVIDER_DISPLAY_NAME;
80
+ }
81
+ /**
82
+ * Apply the shadow to one row: add the dot (or repaint an existing one) and
83
+ * hide the Edit button. Idempotent — a second pass rewrites the dot's
84
+ * readiness instead of stacking another one.
85
+ *
86
+ * `target` is the owning document, passed in because the stub DOM the specs
87
+ * run on keeps no `ownerDocument` link.
88
+ */
89
+ function shadowRow(row, ready, titles, target) {
90
+ const identity = row.querySelector("[class*=\"rowIdentity\"]");
91
+ if (identity === null) return;
92
+ const state = ready ? "ok" : "warn";
93
+ const existing = identity.querySelector(`[${ROW_DOT_MARKER}]`);
94
+ const dot = existing ?? target.createElement("span");
95
+ dot.setAttribute(ROW_DOT_MARKER, state);
96
+ dot.setAttribute("title", titles[state]);
97
+ dot.setAttribute("aria-label", titles[state]);
98
+ if (existing === null) {
99
+ dot.setAttribute("role", "img");
100
+ identity.appendChild(dot);
101
+ }
102
+ for (const edit of findEditButtons(row)) edit.setAttribute(ROW_EDIT_HIDDEN, "");
103
+ }
104
+ /** Undo one row: remove the dot, restore the Edit button. */
105
+ function unshadowRow(row) {
106
+ row.querySelectorAll(`[${ROW_DOT_MARKER}]`).forEach((dot) => {
107
+ dot.remove();
108
+ });
109
+ row.querySelectorAll(`[${ROW_EDIT_HIDDEN}]`).forEach((button) => {
110
+ button.removeAttribute(ROW_EDIT_HIDDEN);
111
+ });
112
+ }
113
+ /**
114
+ * Install the provider-row shadow on a document.
115
+ *
116
+ * `ready` is the credential fact as the browser half derives it from the
117
+ * settings namespace `aishell-credentials` owns (see `client/index.ts`);
118
+ * `titles` is the dot's hover copy in the active locale (the installer
119
+ * translates; this module holds only the DOM work). Neither is read from any
120
+ * store here, so a later credential save or locale switch is a reinstall with
121
+ * the new values, not a re-derivation buried in here. Sweeps the page once,
122
+ * then observes the body so re-rendered rows are covered too. Returns the
123
+ * disposer that stops the observer and restores every row.
124
+ */
125
+ function installProviderRowShadow(target, ready, titles) {
126
+ if (typeof target.querySelector !== "function" || typeof MutationObserver !== "function") return () => {};
127
+ /** Run the shadow over every provider row at or below `root`. */
128
+ const sweep = (root) => {
129
+ if (isElement(root) && isProviderRow(root)) shadowRow(root, ready, titles, target);
130
+ for (const row of root.querySelectorAll("[class*=\"rowCard\"], [class*=\"setupCard\"]")) if (isProviderRow(row)) shadowRow(row, ready, titles, target);
131
+ };
132
+ const sweepIfRow = (node) => {
133
+ if (isProviderRow(node)) {
134
+ shadowRow(node, ready, titles, target);
135
+ return;
136
+ }
137
+ for (let parent = node.parentElement; parent !== null; parent = parent.parentElement) sweep(parent);
138
+ };
139
+ const style = target.createElement("style");
140
+ style.setAttribute(ROW_DOT_MARKER + "-css", "");
141
+ style.textContent = ROW_DOT_CSS;
142
+ target.head.appendChild(style);
143
+ sweep(target.body);
144
+ const observer = new MutationObserver((mutations) => {
145
+ for (const mutation of mutations) for (const node of mutation.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) sweepIfRow(node);
146
+ });
147
+ observer.observe(target.body, {
148
+ childList: true,
149
+ subtree: true
150
+ });
151
+ return () => {
152
+ observer.disconnect();
153
+ style.remove();
154
+ for (const row of target.body.querySelectorAll("[class*=\"rowCard\"], [class*=\"setupCard\"]")) if (isProviderRow(row)) unshadowRow(row);
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ //#region src/client/locales.ts
160
+ /**
161
+ * aishell-llm locale dictionaries — the Models-page provider row's copy.
162
+ *
163
+ * The row shadow is a DOM patch, not a React render, so its strings do not
164
+ * ride the framework's locale seat: the install reads them through `t` at
165
+ * install time and repaints on a locale switch (the client half re-installs
166
+ * on `locale/change`). zh is the key-set source of truth; en mirrors it.
167
+ *
168
+ * @module @huaweidevtools/aishell-dsh-llm/client/locales
169
+ */
170
+ /** Dictionary namespace owned by this plugin. */
171
+ const NS = "aishell-llm";
172
+ /** Simplified Chinese dictionary (the key-set source of truth). */
173
+ const zh = {
174
+ "provider.ready": "已就绪",
175
+ "provider.warn": "未就绪 — 凭据未配置(点击对话输入框上方的凭据按钮填写 AK/SK)"
176
+ };
177
+ /** English dictionary. */
178
+ const en = {
179
+ "provider.ready": "Ready",
180
+ "provider.warn": "Not ready — credentials missing (click the credentials button above the chat input to enter AK/SK)"
181
+ };
182
+
183
+ //#endregion
184
+ //#region src/client/index.ts
185
+ /** The settings namespace aishell-credentials owns (the shadow reads its status). */
186
+ const AISHELL_CREDENTIALS_NS = "aishell-client-credentials";
187
+ /** Required services. */
188
+ const inject = ["configForms", "locale"];
189
+ function apply(ctx) {
190
+ if (typeof document === "undefined") return;
191
+ ctx.effect(() => ctx.locale.register(NS, {
192
+ zh,
193
+ en
194
+ }), "aishell-llm: dictionaries");
195
+ const t = ctx.locale.bind(NS);
196
+ const form = ctx.configForms.get(AISHELL_CREDENTIALS_NS);
197
+ let readiness = form.getSnapshot().value?.credentialsStored === true;
198
+ let disposeShadow;
199
+ const titles = () => ({
200
+ ok: t("provider.ready"),
201
+ warn: t("provider.warn")
202
+ });
203
+ const sync = () => {
204
+ disposeShadow?.();
205
+ disposeShadow = installProviderRowShadow(document, readiness, titles());
206
+ };
207
+ const derive = () => {
208
+ const next = form.getSnapshot().value?.credentialsStored === true;
209
+ if (next !== readiness) {
210
+ readiness = next;
211
+ sync();
212
+ }
213
+ };
214
+ ctx.effect(() => ctx.configForms.whileServed([AISHELL_CREDENTIALS_NS], () => {
215
+ sync();
216
+ return () => {
217
+ disposeShadow?.();
218
+ disposeShadow = void 0;
219
+ };
220
+ }), "aishell-llm: models page provider row shadow");
221
+ ctx.effect(() => form.subscribe(derive), "aishell-llm: credential presence for the models row");
222
+ ctx.effect(() => ctx.on("locale/change", () => {
223
+ sync();
224
+ }), "aishell-llm: shadow locale repaint");
225
+ }
226
+
227
+ //#endregion
228
+ exports.ROW_DOT_MARKER = ROW_DOT_MARKER
229
+ exports.ROW_EDIT_HIDDEN = ROW_EDIT_HIDDEN
230
+ exports.apply = apply
231
+ exports.inject = inject
232
+ exports.installProviderRowShadow = installProviderRowShadow
233
+ return module.exports; } });
234
+ //# sourceMappingURL=client.js.map