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/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
+ }