dsh-model-pool 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/README.zh-CN.md +331 -0
- package/client.js +2847 -0
- package/cordis.patch.yml +15 -0
- package/docs/installation.md +115 -0
- package/docs/installation.zh-CN.md +107 -0
- package/icon.svg +17 -0
- package/index.js +718 -0
- package/lib/adapter.js +151 -0
- package/lib/capabilities.js +198 -0
- package/lib/catalog.js +175 -0
- package/lib/effort.js +151 -0
- package/lib/grouping.js +404 -0
- package/lib/health.js +175 -0
- package/lib/holiday-calendar.js +595 -0
- package/lib/peak.js +495 -0
- package/lib/router.js +584 -0
- package/lib/strategies.js +215 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +77 -4
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The virtual provider adapter for `dsh-model-pool`.
|
|
3
|
+
*
|
|
4
|
+
* The adapter exists for one reason: `listProviders()` / `listModels()` /
|
|
5
|
+
* `resolveModelInfo()` are the only surfaces the composer picker and Settings
|
|
6
|
+
* read (`dsh-api-session-controller/lib/index.js:563-611`), and a route appears
|
|
7
|
+
* in them only when an `LlmAdapter` claims it. Actual dispatch never reaches
|
|
8
|
+
* this adapter — the `llm/stream` listener in `index.js` intercepts the request
|
|
9
|
+
* first and forwards it to a concrete provider.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this does not extend `LlmAdapter`
|
|
12
|
+
*
|
|
13
|
+
* A profile bundle's `@deepseek-ai/dsh-llm` can resolve to a different physical
|
|
14
|
+
* copy (this profile links 0.1.7-rc.2 while the runtime is 0.2.1-rc.3), and the
|
|
15
|
+
* registry is duck-typed: `prepareRoutes` only calls `providerInfo()` and
|
|
16
|
+
* `providerRetryPolicy()`, and dispatch only calls `listModels()`,
|
|
17
|
+
* `resolveModel()`, `prepareCall()` and `stream()`. Implementing the surface
|
|
18
|
+
* directly removes a whole class of module-identity failure without weakening
|
|
19
|
+
* the contract.
|
|
20
|
+
*
|
|
21
|
+
* `stream()` is therefore a programming error, not a fallback: reaching it
|
|
22
|
+
* means the interceptor was not installed or named a different provider id.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-model-pool/lib/adapter
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a plain `Error` carrying the provider-neutral failure facts the runtime
|
|
29
|
+
* reads at its adapter boundary (`dsh-llm/lib/index.js:426-473`): an own
|
|
30
|
+
* `failure` snapshot whose `code` equals the own `code`.
|
|
31
|
+
* @param {string} message - human-readable summary.
|
|
32
|
+
* @param {string} code - stable machine-routable failure class.
|
|
33
|
+
* @returns {Error} the error to throw.
|
|
34
|
+
*/
|
|
35
|
+
function llmError(message, code) {
|
|
36
|
+
const error = new Error(message);
|
|
37
|
+
error.code = code;
|
|
38
|
+
error.failure = { message, code };
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Adapter serving one virtual pool route.
|
|
44
|
+
*/
|
|
45
|
+
export class PoolAdapter {
|
|
46
|
+
/**
|
|
47
|
+
* @param {() => object} snapshotOf - live pool snapshot reader.
|
|
48
|
+
* @param {string} providerId - the virtual route this adapter owns.
|
|
49
|
+
* @param {string} providerName - display name for that route.
|
|
50
|
+
*/
|
|
51
|
+
constructor(snapshotOf, providerId, providerName) {
|
|
52
|
+
this.snapshotOf = snapshotOf;
|
|
53
|
+
this.providerId = providerId;
|
|
54
|
+
this.providerName = providerName;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Describe the virtual route.
|
|
59
|
+
* @param {string} provider - the route passed to `registerAdapter()`.
|
|
60
|
+
* @returns {{ id: string, name: string }} display metadata.
|
|
61
|
+
*/
|
|
62
|
+
providerInfo(provider) {
|
|
63
|
+
return { id: provider, name: this.providerName };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Use the runtime's normal retry policy.
|
|
68
|
+
* @returns {undefined} always, meaning "no route-specific policy".
|
|
69
|
+
*/
|
|
70
|
+
providerRetryPolicy() {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Advertise one pool entry per logical model.
|
|
76
|
+
* @param {string} provider - the virtual route.
|
|
77
|
+
* @returns {Promise<object[]>} pool models as `LlmModelInfo` values.
|
|
78
|
+
*/
|
|
79
|
+
async listModels(provider) {
|
|
80
|
+
const snapshot = this.snapshotOf();
|
|
81
|
+
return snapshot.models.map((model) => ({
|
|
82
|
+
provider,
|
|
83
|
+
id: model.id,
|
|
84
|
+
name: model.name,
|
|
85
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
86
|
+
...model.inputModalities === undefined ? {} : { inputModalities: model.inputModalities },
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve one pool entry to the union capability set.
|
|
92
|
+
*
|
|
93
|
+
* The pool advertises the union of its candidates' thinking depths, the union
|
|
94
|
+
* of their input modalities, and the minimum context window: a depth some
|
|
95
|
+
* candidate cannot serve stays selectable and the router maps it down to the
|
|
96
|
+
* nearest tier that candidate supports, the minimum window keeps the request
|
|
97
|
+
* inside every candidate's limit so failover cannot hit
|
|
98
|
+
* `CONTEXT_WINDOW_EXCEEDED`, and the modality union lets the resident pick a
|
|
99
|
+
* screenshot here instead of being told the model is text-only because one
|
|
100
|
+
* candidate happens to be. `lib/router.js` then honours the modality promise
|
|
101
|
+
* per request by skipping routes that cannot read the image.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} provider - the virtual route.
|
|
104
|
+
* @param {string} model - pool model id.
|
|
105
|
+
* @returns {Promise<object>} resolved model info for the pool entry.
|
|
106
|
+
*/
|
|
107
|
+
async resolveModel(provider, model) {
|
|
108
|
+
const snapshot = this.snapshotOf();
|
|
109
|
+
const entry = snapshot.models.find((candidate) => candidate.id === model);
|
|
110
|
+
if (entry === undefined) {
|
|
111
|
+
throw llmError(`model pool has no model "${model}"`, 'INVALID_MODEL_INFO');
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
provider,
|
|
115
|
+
id: model,
|
|
116
|
+
name: entry.name,
|
|
117
|
+
...entry.description === undefined ? {} : { description: entry.description },
|
|
118
|
+
...entry.contextWindow === undefined ? {} : { context: { contextWindow: entry.contextWindow } },
|
|
119
|
+
...entry.reasoning === undefined ? {} : { reasoning: entry.reasoning },
|
|
120
|
+
...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
|
|
121
|
+
...entry.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: entry.systemPromptUpdate },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Bind one generation of pool metadata to the eventual dispatch.
|
|
127
|
+
* @param {string} provider - the virtual route.
|
|
128
|
+
* @param {string} model - pool model id.
|
|
129
|
+
* @param {AbortSignal} [signal] - cancellation for the metadata read.
|
|
130
|
+
* @returns {Promise<{ model: object, stream: (options: object) => AsyncIterable<object> }>} the call handle.
|
|
131
|
+
*/
|
|
132
|
+
async prepareCall(provider, model, signal) {
|
|
133
|
+
return {
|
|
134
|
+
model: await this.resolveModel(provider, model, signal),
|
|
135
|
+
stream: (options) => this.stream(options),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Report that the interceptor did not run.
|
|
141
|
+
* @returns {AsyncIterable<never>} never yields; the throw becomes a terminal
|
|
142
|
+
* `error` finish chunk at the runtime's adapter boundary.
|
|
143
|
+
*/
|
|
144
|
+
async *stream() {
|
|
145
|
+
throw llmError(
|
|
146
|
+
`the model-pool route "${this.providerId}" was dispatched to its adapter; `
|
|
147
|
+
+ 'the routing listener is not active, so no concrete provider was selected',
|
|
148
|
+
'NO_ADAPTER',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input-modality capabilities for `dsh-model-pool`.
|
|
3
|
+
*
|
|
4
|
+
* A pool model is one logical model served by several concrete routes, and those
|
|
5
|
+
* routes do not always agree about what they accept. `deepseek-v4.1-flash` is
|
|
6
|
+
* served by four routes that take images and one (`deepseek-v4.1-flash-sg`) that
|
|
7
|
+
* does not, so a single answer is needed to two different questions:
|
|
8
|
+
*
|
|
9
|
+
* - **What may the user send?** The runtime gates attachment admission on the
|
|
10
|
+
* *advertised* capability (`dsh-api-session-controller`: a prompt carrying an
|
|
11
|
+
* image is refused with `MODEL_DOES_NOT_SUPPORT_IMAGES` when the resolved
|
|
12
|
+
* `inputModalities` exists and omits `image`). Withholding `image` because
|
|
13
|
+
* *one* candidate is text-only makes the pool reject an upload the majority of
|
|
14
|
+
* its candidates handle natively. So the advertised set is the **union**: the
|
|
15
|
+
* pool offers every modality at least one candidate accepts.
|
|
16
|
+
*
|
|
17
|
+
* - **Where may this call go?** The router must then honour that promise, so a
|
|
18
|
+
* request carrying an image is only routed to routes that accept images. The
|
|
19
|
+
* union is therefore paired with a per-request filter, and a call never lands
|
|
20
|
+
* on a text-only route while an image-capable one is available.
|
|
21
|
+
*
|
|
22
|
+
* Undeclared capabilities stay undeclared. A route that reports no
|
|
23
|
+
* `inputModalities` is not evidence of anything, so the pool advertises
|
|
24
|
+
* `undefined` when *no* candidate declares a set (the runtime treats an absent
|
|
25
|
+
* set as "no constraint"), and treats an undeclared route as text-only when a
|
|
26
|
+
* request actually needs images.
|
|
27
|
+
*
|
|
28
|
+
* Pure: no I/O, no service access. Everything here is a function of its inputs.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-model-pool/lib/capabilities
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Modality id for text input. */
|
|
34
|
+
export const TEXT = 'text';
|
|
35
|
+
|
|
36
|
+
/** Modality id for image input. */
|
|
37
|
+
export const IMAGE = 'image';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coerce one advertised modality list into a deduplicated string array.
|
|
41
|
+
*
|
|
42
|
+
* A route may hand back a frozen array, a plain array, or nothing at all. The
|
|
43
|
+
* result is always a fresh array, so a caller can never mutate a route's own
|
|
44
|
+
* metadata through the snapshot.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} value - the raw `inputModalities` value.
|
|
47
|
+
* @returns {string[]|undefined} the modalities, or undefined when none were declared.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeModalities(value) {
|
|
50
|
+
if (!Array.isArray(value)) return undefined;
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const entry of value) {
|
|
53
|
+
if (typeof entry !== 'string') continue;
|
|
54
|
+
const modality = entry.trim().toLowerCase();
|
|
55
|
+
if (modality === '' || out.includes(modality)) continue;
|
|
56
|
+
out.push(modality);
|
|
57
|
+
}
|
|
58
|
+
return out.length === 0 ? undefined : out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The modalities one route is known to accept, with text as the floor.
|
|
63
|
+
*
|
|
64
|
+
* Text is implicit everywhere: every LLM route in the registry accepts text, and
|
|
65
|
+
* a route that advertises `['image']` alone still carries the text blocks that
|
|
66
|
+
* surround its images. Adding the floor keeps the filter below from ever
|
|
67
|
+
* classifying a route as unable to take a text-only call.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} route - a pool route or a concrete model entry.
|
|
70
|
+
* @returns {string[]} accepted modalities, always including `text`.
|
|
71
|
+
*/
|
|
72
|
+
export function routeModalities(route) {
|
|
73
|
+
const declared = normalizeModalities(route?.inputModalities) ?? [];
|
|
74
|
+
return declared.includes(TEXT) ? declared : [TEXT, ...declared];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether one route accepts every modality a request needs.
|
|
79
|
+
*
|
|
80
|
+
* An undeclared route is text-only here on purpose: routing an image into a
|
|
81
|
+
* route whose capability is unknown risks the provider rejecting the whole
|
|
82
|
+
* call, while the reverse (treating it as capable) is exactly the bug this
|
|
83
|
+
* module exists to fix.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} route - a pool route.
|
|
86
|
+
* @param {string[]} required - modalities the request carries.
|
|
87
|
+
* @returns {boolean} true when every required modality is accepted.
|
|
88
|
+
*/
|
|
89
|
+
export function routeServes(route, required) {
|
|
90
|
+
if (!Array.isArray(required) || required.length === 0) return true;
|
|
91
|
+
const accepted = routeModalities(route);
|
|
92
|
+
return required.every((modality) => accepted.includes(modality));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fold the candidates' modalities into the set the pool advertises.
|
|
97
|
+
*
|
|
98
|
+
* The union, not the intersection: see the module comment. Duplicates are
|
|
99
|
+
* dropped and `text` is kept first so the advertised order reads naturally.
|
|
100
|
+
*
|
|
101
|
+
* @param {object[]} routes - pool routes with their per-route capabilities.
|
|
102
|
+
* @returns {string[]|undefined} the union, or undefined when no route declares one.
|
|
103
|
+
*/
|
|
104
|
+
export function foldInputModalities(routes) {
|
|
105
|
+
const declared = (Array.isArray(routes) ? routes : [])
|
|
106
|
+
.map((route) => normalizeModalities(route?.inputModalities))
|
|
107
|
+
.filter((value) => value !== undefined);
|
|
108
|
+
if (declared.length === 0) return undefined;
|
|
109
|
+
const union = [];
|
|
110
|
+
for (const modality of declared.flat()) {
|
|
111
|
+
if (!union.includes(modality)) union.push(modality);
|
|
112
|
+
}
|
|
113
|
+
// Text first and always present: it is the floor every route accepts, the
|
|
114
|
+
// client renders this set, and a pool advertising `['image']` alone would be
|
|
115
|
+
// nonsense even if every candidate happened to declare only that.
|
|
116
|
+
return [TEXT, ...union.filter((modality) => modality !== TEXT)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether one content block array carries an image, walking tool results.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately deeper than the shipped runtime's `contentHasImage`
|
|
123
|
+
* (`dsh-llm/lib/types/content.js` checks only top-level blocks): its image
|
|
124
|
+
* projection is shallow too, so a screenshot returned inside a tool result is
|
|
125
|
+
* never degraded for a text-only route — it reaches that adapter raw and some
|
|
126
|
+
* adapters reject images in non-user messages outright. Walking tool results
|
|
127
|
+
* here routes those calls to a route that can actually read the image. Repeated
|
|
128
|
+
* locally rather than imported because the pool is duck-typed against a possibly
|
|
129
|
+
* different physical copy of `@deepseek-ai/dsh-llm`.
|
|
130
|
+
*
|
|
131
|
+
* @param {unknown} content - one message's content blocks.
|
|
132
|
+
* @returns {boolean} true when an image block is present at any depth.
|
|
133
|
+
*/
|
|
134
|
+
export function contentHasImage(content) {
|
|
135
|
+
if (!Array.isArray(content)) return false;
|
|
136
|
+
for (const block of content) {
|
|
137
|
+
if (block === null || typeof block !== 'object') continue;
|
|
138
|
+
if (block.type === IMAGE) return true;
|
|
139
|
+
if (block.type === 'tool-result' && contentHasImage(block.content)) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The modalities one request needs, derived from its messages.
|
|
146
|
+
*
|
|
147
|
+
* Only what the messages actually carry is required: a pool that advertises
|
|
148
|
+
* images must not refuse a text-only call, and a call with an image must not
|
|
149
|
+
* land on a route that cannot see it.
|
|
150
|
+
*
|
|
151
|
+
* @param {object[]} messages - the request messages (`{ role, content }`).
|
|
152
|
+
* @returns {string[]} required modalities, always including `text`.
|
|
153
|
+
*/
|
|
154
|
+
export function requiredModalities(messages) {
|
|
155
|
+
const required = [TEXT];
|
|
156
|
+
if (!Array.isArray(messages)) return required;
|
|
157
|
+
for (const message of messages) {
|
|
158
|
+
if (message === null || typeof message !== 'object') continue;
|
|
159
|
+
if (contentHasImage(message.content)) {
|
|
160
|
+
required.push(IMAGE);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return required;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Keep only the routes that can serve this request.
|
|
169
|
+
*
|
|
170
|
+
* Never returns an empty list for a non-empty input: the requirement is a
|
|
171
|
+
* preference the pool has already promised the user it can honour, so when a
|
|
172
|
+
* stale snapshot or an over-tight filter leaves nothing capable, the unfiltered
|
|
173
|
+
* list is returned and the runtime's own projection degrades the image instead
|
|
174
|
+
* of failing the call outright.
|
|
175
|
+
*
|
|
176
|
+
* @param {object[]} routes - candidate routes.
|
|
177
|
+
* @param {string[]} required - modalities the request carries.
|
|
178
|
+
* @returns {object[]} capable routes, or the original list when none are capable.
|
|
179
|
+
*/
|
|
180
|
+
export function filterByModalities(routes, required) {
|
|
181
|
+
if (!Array.isArray(routes) || routes.length === 0) return Array.isArray(routes) ? routes : [];
|
|
182
|
+
const capable = routes.filter((route) => routeServes(route, required));
|
|
183
|
+
return capable.length > 0 ? capable : routes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether an advertised modality set can take an image.
|
|
188
|
+
*
|
|
189
|
+
* Used by the client to render the pool-level capability badge without
|
|
190
|
+
* duplicating the `includes('image')` rule in two languages.
|
|
191
|
+
*
|
|
192
|
+
* @param {unknown} modalities - an advertised modality list.
|
|
193
|
+
* @returns {boolean} true when image input is advertised.
|
|
194
|
+
*/
|
|
195
|
+
export function acceptsImages(modalities) {
|
|
196
|
+
const normalized = normalizeModalities(modalities);
|
|
197
|
+
return normalized !== undefined && normalized.includes(IMAGE);
|
|
198
|
+
}
|
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-registry reader for `dsh-model-pool`.
|
|
3
|
+
*
|
|
4
|
+
* Enumerates every registered provider route and its models from the Host's
|
|
5
|
+
* `llm` service, then folds them into one pool snapshot through the pure
|
|
6
|
+
* grouping module. Per-provider failure isolation mirrors the shipped
|
|
7
|
+
* `buildModelCatalog` (`dsh-api-session-controller/lib/index.js:563-611`):
|
|
8
|
+
* one provider whose catalog throws must not hide the others.
|
|
9
|
+
*
|
|
10
|
+
* The reader also attaches the route-level capabilities the router and the
|
|
11
|
+
* virtual adapter need (`inputModalities`, `systemPromptUpdate`, `toolUpdate`)
|
|
12
|
+
* and folds them into pool-level aggregates. The fold is deliberately not uniform:
|
|
13
|
+
* a capability the Host gates on *before* the call exists (`inputModalities`) is
|
|
14
|
+
* the union so the pool does not look weaker than its candidates, while a
|
|
15
|
+
* protocol mode every route must agree on (`systemPromptUpdate`) stays
|
|
16
|
+
* conservative. See `lib/capabilities.js` for why.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-model-pool/lib/catalog
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { buildPoolSnapshot } from './grouping.js';
|
|
22
|
+
import { foldInputModalities } from './capabilities.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Enumerate one provider's models and their resolved metadata.
|
|
26
|
+
* @param {object} llm - the live `llm` service.
|
|
27
|
+
* @param {{ id: string, name: string }} provider - one registered route.
|
|
28
|
+
* @returns {Promise<{ provider: object, models: object[] }>} enumerated models.
|
|
29
|
+
*/
|
|
30
|
+
async function enumerateProvider(llm, provider) {
|
|
31
|
+
const listed = await llm.listModels(provider.id);
|
|
32
|
+
const models = await Promise.all(
|
|
33
|
+
listed.map(async (model) => {
|
|
34
|
+
// `resolveModelInfo` is advisory: a route may advertise a model whose
|
|
35
|
+
// exact metadata query fails. Keep the model with the advertised facts
|
|
36
|
+
// rather than dropping it from the pool.
|
|
37
|
+
let resolved;
|
|
38
|
+
try {
|
|
39
|
+
resolved = await llm.resolveModelInfo(provider.id, model.id);
|
|
40
|
+
} catch {
|
|
41
|
+
resolved = undefined;
|
|
42
|
+
}
|
|
43
|
+
const reasoning = resolved?.reasoning;
|
|
44
|
+
const inputModalities = resolved?.inputModalities ?? model.inputModalities;
|
|
45
|
+
return {
|
|
46
|
+
id: model.id,
|
|
47
|
+
name: model.name,
|
|
48
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
49
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
50
|
+
...resolved?.context?.contextWindow === undefined ? {} : { contextWindow: resolved.context.contextWindow },
|
|
51
|
+
...inputModalities === undefined ? {} : { inputModalities: [...inputModalities] },
|
|
52
|
+
...resolved?.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: resolved.systemPromptUpdate },
|
|
53
|
+
...resolved?.toolUpdate === undefined ? {} : { toolUpdate: resolved.toolUpdate },
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return { provider, models };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold a capability across every candidate route.
|
|
62
|
+
*
|
|
63
|
+
* - `inputModalities`: the **union**, so the pool offers every input at least one
|
|
64
|
+
* candidate accepts. This is the capability the user is judged by: the runtime
|
|
65
|
+
* refuses an image prompt outright when the advertised set omits `image`
|
|
66
|
+
* (`dsh-api-session-controller: MODEL_DOES_NOT_SUPPORT_IMAGES`), so advertising
|
|
67
|
+
* the intersection made a model whose majority routes read images reject every
|
|
68
|
+
* upload because one candidate was text-only. The promise is kept by
|
|
69
|
+
* `lib/router.js`, which routes an image call only to image-capable routes.
|
|
70
|
+
* - `systemPromptUpdate`: `'in-history'` only when every candidate declares it;
|
|
71
|
+
* otherwise absent, which makes the loop emit a leading system message that
|
|
72
|
+
* every route accepts.
|
|
73
|
+
*
|
|
74
|
+
* @param {object[]} routes - pool routes with their per-route capabilities.
|
|
75
|
+
* @returns {{ inputModalities?: string[], systemPromptUpdate?: string }} aggregates.
|
|
76
|
+
*/
|
|
77
|
+
function foldCapabilities(routes) {
|
|
78
|
+
const inputModalities = foldInputModalities(routes);
|
|
79
|
+
const inHistory = routes.length > 0 && routes.every((route) => route.systemPromptUpdate === 'in-history');
|
|
80
|
+
return {
|
|
81
|
+
...inputModalities === undefined ? {} : { inputModalities },
|
|
82
|
+
...inHistory ? { systemPromptUpdate: 'in-history' } : {},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the whole live registry into one pool snapshot.
|
|
88
|
+
*
|
|
89
|
+
* The virtual pool route itself is excluded: it must never become a candidate
|
|
90
|
+
* of another pool, which would let a pooled call route back into the pool.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} llm - the live `llm` service.
|
|
93
|
+
* @param {object} config - plain pool configuration (see `docs/CONTRACT.md` §3.2).
|
|
94
|
+
* @param {string} selfProviderId - the virtual route this plugin owns.
|
|
95
|
+
* @returns {Promise<{ snapshot: object, failures: object[] }>} the pool snapshot.
|
|
96
|
+
*/
|
|
97
|
+
export async function readPoolSnapshot(llm, config, selfProviderId) {
|
|
98
|
+
const providers = llm
|
|
99
|
+
.listProviders()
|
|
100
|
+
.filter((provider) => provider.id !== selfProviderId);
|
|
101
|
+
|
|
102
|
+
const enumerated = await Promise.all(
|
|
103
|
+
providers.map(async (provider) => {
|
|
104
|
+
try {
|
|
105
|
+
return { kind: 'group', group: await enumerateProvider(llm, provider) };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
kind: 'failure',
|
|
109
|
+
failure: {
|
|
110
|
+
provider: provider.id,
|
|
111
|
+
name: provider.name,
|
|
112
|
+
message: error instanceof Error ? error.message : String(error),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const groups = enumerated.flatMap((item) => (item.kind === 'group' ? [item.group] : []));
|
|
120
|
+
const failures = enumerated.flatMap((item) => (item.kind === 'failure' ? [item.failure] : []));
|
|
121
|
+
|
|
122
|
+
const built = buildPoolSnapshot(
|
|
123
|
+
{
|
|
124
|
+
// `grouping` takes the flat registry shape: `{ id, name, models }`.
|
|
125
|
+
providers: groups.map((group) => ({ ...group.provider, models: group.models })),
|
|
126
|
+
modelGroups: config.modelGroups,
|
|
127
|
+
providerOrder: config.providerOrder,
|
|
128
|
+
include: config.include,
|
|
129
|
+
exclude: config.exclude,
|
|
130
|
+
excludeModels: config.excludeModels,
|
|
131
|
+
},
|
|
132
|
+
failures,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Route-level capability lookup, keyed by concrete provider + model.
|
|
136
|
+
const capabilities = new Map();
|
|
137
|
+
for (const group of groups) {
|
|
138
|
+
for (const model of group.models) {
|
|
139
|
+
capabilities.set(`${group.provider.id}\u0000${model.id}`, model);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const models = built.models.map((model) => {
|
|
144
|
+
const routes = model.routes.map((route) => {
|
|
145
|
+
const source = capabilities.get(`${route.provider}\u0000${route.model}`);
|
|
146
|
+
return {
|
|
147
|
+
...route,
|
|
148
|
+
...source?.inputModalities === undefined ? {} : { inputModalities: source.inputModalities },
|
|
149
|
+
...source?.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: source.systemPromptUpdate },
|
|
150
|
+
...source?.toolUpdate === undefined ? {} : { toolUpdate: source.toolUpdate },
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
return { ...model, routes, ...foldCapabilities(routes) };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The registry's own provider directory, in registration order. Settings uses
|
|
157
|
+
// it to offer every provider by name instead of asking for raw ids.
|
|
158
|
+
const directory = providers.map((provider, index) => ({
|
|
159
|
+
id: provider.id,
|
|
160
|
+
name: typeof provider.name === "string" && provider.name !== "" ? provider.name : provider.id,
|
|
161
|
+
registrationIndex: index,
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
snapshot: {
|
|
166
|
+
providerId: selfProviderId,
|
|
167
|
+
providerName: config.providerName,
|
|
168
|
+
providers: directory,
|
|
169
|
+
models,
|
|
170
|
+
failures: built.failures,
|
|
171
|
+
takenAt: Date.now(),
|
|
172
|
+
},
|
|
173
|
+
failures: built.failures,
|
|
174
|
+
};
|
|
175
|
+
}
|
package/lib/effort.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-effort vocabulary and rank mapping shared by every pool route.
|
|
3
|
+
* Pure: no I/O, no clocks, no randomness.
|
|
4
|
+
* @module dsh-model-pool/lib/effort
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Canonical effort tiers, ascending. */
|
|
8
|
+
export const EFFORT_RANK = ["off", "minimal", "low", "medium", "high", "max", "xhigh"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Provider ids that mean the same tier under a different name.
|
|
12
|
+
*
|
|
13
|
+
* Adapters disagree on spelling: one advertises `off`, another calls the same
|
|
14
|
+
* thing `disabled`. Without this alias table a pool that aggregates both would
|
|
15
|
+
* offer two entries labelled "Off" and route each to a different wire value.
|
|
16
|
+
*/
|
|
17
|
+
const EFFORT_ALIASES = new Map([
|
|
18
|
+
// Observed in this deployment: one adapter advertises the off tier as `off`
|
|
19
|
+
// and another as `disabled`; both report the display name "Off".
|
|
20
|
+
["disabled", "off"],
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const RANKS = new Map(EFFORT_RANK.map((id, index) => [id, index]));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Canonical tier key for an effort id, folding aliases onto the canonical name.
|
|
27
|
+
* @param id - effort id as advertised by a provider.
|
|
28
|
+
* @returns the canonical tier id, or the trimmed lower-cased input when unknown.
|
|
29
|
+
*/
|
|
30
|
+
export function canonicalEffortId(id) {
|
|
31
|
+
if (typeof id !== "string") return "";
|
|
32
|
+
const key = id.trim().toLowerCase();
|
|
33
|
+
return EFFORT_ALIASES.get(key) ?? key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canonical rank of an effort id.
|
|
38
|
+
* @param id - effort id, matched case-insensitively.
|
|
39
|
+
* @returns the ascending tier index, or -1 when the id is unknown.
|
|
40
|
+
*/
|
|
41
|
+
export function rankOf(id) {
|
|
42
|
+
if (typeof id !== "string") return -1;
|
|
43
|
+
return RANKS.get(canonicalEffortId(id)) ?? -1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Order efforts by canonical rank; unknown ids keep their relative order at the end.
|
|
48
|
+
* @param efforts - efforts to order.
|
|
49
|
+
* @returns a new ordered array; malformed entries are dropped.
|
|
50
|
+
*/
|
|
51
|
+
export function rankEfforts(efforts) {
|
|
52
|
+
const known = [];
|
|
53
|
+
const unknown = [];
|
|
54
|
+
for (const effort of Array.isArray(efforts) ? efforts : []) {
|
|
55
|
+
if (effort === null || typeof effort !== "object" || typeof effort.id !== "string") continue;
|
|
56
|
+
(rankOf(effort.id) < 0 ? unknown : known).push(effort);
|
|
57
|
+
}
|
|
58
|
+
known.sort((left, right) => rankOf(left.id) - rankOf(right.id));
|
|
59
|
+
return [...known, ...unknown];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Union several effort lists by id.
|
|
64
|
+
* @param lists - effort lists in candidate order.
|
|
65
|
+
* @returns the ranked union; the first occurrence of an id wins its name and description.
|
|
66
|
+
*/
|
|
67
|
+
export function unionEfforts(lists) {
|
|
68
|
+
// Deduplicate by canonical tier, not raw id: two providers naming the same
|
|
69
|
+
// tier differently (`off` / `disabled`) must collapse into one entry, or the
|
|
70
|
+
// picker shows the label twice.
|
|
71
|
+
const byTier = new Map();
|
|
72
|
+
for (const list of Array.isArray(lists) ? lists : []) {
|
|
73
|
+
for (const effort of Array.isArray(list) ? list : []) {
|
|
74
|
+
if (effort === null || typeof effort !== "object" || typeof effort.id !== "string") continue;
|
|
75
|
+
const tier = canonicalEffortId(effort.id);
|
|
76
|
+
if (tier === "") continue;
|
|
77
|
+
if (!byTier.has(tier)) byTier.set(tier, effort);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return rankEfforts([...byTier.values()]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Map a requested effort onto the nearest tier a target route supports.
|
|
85
|
+
* Exact matches win; otherwise the smallest rank distance wins and ties go to the
|
|
86
|
+
* higher tier.
|
|
87
|
+
* @param targetEfforts - the concrete target route's efforts.
|
|
88
|
+
* @param requestedId - the effort requested for the pooled model.
|
|
89
|
+
* @returns the chosen target effort id, or undefined when the target has no efforts
|
|
90
|
+
* or the request is missing/unknown.
|
|
91
|
+
*/
|
|
92
|
+
export function pickEffort(targetEfforts, requestedId) {
|
|
93
|
+
const efforts = (Array.isArray(targetEfforts) ? targetEfforts : []).filter(
|
|
94
|
+
(effort) => effort !== null && typeof effort === "object" && typeof effort.id === "string",
|
|
95
|
+
);
|
|
96
|
+
if (efforts.length === 0) return undefined;
|
|
97
|
+
if (typeof requestedId !== "string" || requestedId.trim() === "") return undefined;
|
|
98
|
+
const wanted = requestedId.trim();
|
|
99
|
+
const wantedTier = canonicalEffortId(wanted);
|
|
100
|
+
// Match on the canonical tier, not the raw spelling: a route advertising
|
|
101
|
+
// `disabled` satisfies a request for `off`, and vice versa. Returning that
|
|
102
|
+
// route's own id keeps the wire value legal for it.
|
|
103
|
+
const exact = efforts.find((effort) => effort.id === wanted || canonicalEffortId(effort.id) === wantedTier);
|
|
104
|
+
if (exact !== undefined) return exact.id;
|
|
105
|
+
const wantedRank = rankOf(wanted);
|
|
106
|
+
if (wantedRank < 0) return undefined;
|
|
107
|
+
|
|
108
|
+
// `off` means "do not reason". A route with no off tier cannot honour that,
|
|
109
|
+
// and rounding it up to `medium`/`high` would silently spend more reasoning
|
|
110
|
+
// than asked. Fall back to that route's cheapest tier instead — the closest
|
|
111
|
+
// thing it has to "off" — so the adjustment is always toward less work.
|
|
112
|
+
if (wantedTier === "off") {
|
|
113
|
+
let cheapest;
|
|
114
|
+
for (const effort of efforts) {
|
|
115
|
+
const rank = rankOf(effort.id);
|
|
116
|
+
if (rank < 0) continue;
|
|
117
|
+
if (cheapest === undefined || rank < rankOf(cheapest.id)) cheapest = effort;
|
|
118
|
+
}
|
|
119
|
+
if (cheapest !== undefined) return cheapest.id;
|
|
120
|
+
}
|
|
121
|
+
let best;
|
|
122
|
+
let bestDistance = Infinity;
|
|
123
|
+
for (const effort of efforts) {
|
|
124
|
+
const rank = rankOf(effort.id);
|
|
125
|
+
if (rank < 0) continue;
|
|
126
|
+
const distance = Math.abs(rank - wantedRank);
|
|
127
|
+
if (distance < bestDistance || (distance === bestDistance && (best === undefined || rank > rankOf(best.id)))) {
|
|
128
|
+
best = effort;
|
|
129
|
+
bestDistance = distance;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return best?.id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether an effort list contains an id.
|
|
137
|
+
* @param efforts - efforts to search.
|
|
138
|
+
* @param id - effort id, matched case-insensitively.
|
|
139
|
+
* @returns true when a matching effort exists.
|
|
140
|
+
*/
|
|
141
|
+
export function isSupported(efforts, id) {
|
|
142
|
+
if (typeof id !== "string") return false;
|
|
143
|
+
const wanted = id.trim().toLowerCase();
|
|
144
|
+
return (Array.isArray(efforts) ? efforts : []).some(
|
|
145
|
+
(effort) =>
|
|
146
|
+
effort !== null &&
|
|
147
|
+
typeof effort === "object" &&
|
|
148
|
+
typeof effort.id === "string" &&
|
|
149
|
+
effort.id.trim().toLowerCase() === wanted,
|
|
150
|
+
);
|
|
151
|
+
}
|