@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 +1 -1
- package/README.md +45 -9
- package/lib/client.js +234 -0
- package/lib/index.js +1344 -47
- package/lib/types/adapter.d.ts +100 -4
- package/lib/types/client/index.d.ts +32 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/provider-row-shadow.d.ts +80 -0
- package/lib/types/images.d.ts +120 -0
- package/lib/types/index.d.ts +39 -5
- package/lib/types/provider-name.d.ts +17 -0
- package/lib/types/serialize.d.ts +21 -4
- package/lib/types/sse.d.ts +1 -1
- package/lib/types/types.d.ts +47 -13
- package/package.json +29 -7
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
|
|
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
|
|
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://
|
|
45
|
-
| `
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|