@leaves615/dsh-llm-ctl 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/queue.js ADDED
@@ -0,0 +1,313 @@
1
+ /** Production scheduler backed by the global timers. */
2
+ export const realScheduler = {
3
+ now: () => Date.now(),
4
+ setTimer: (callback, delayMs) => setTimeout(callback, Math.max(0, delayMs)),
5
+ clearTimer: (handle) => clearTimeout(handle),
6
+ };
7
+ /** Admission gate over all provider routes. */
8
+ export class ProviderGate {
9
+ scheduler;
10
+ concurrencyFor;
11
+ maxQueueDepth;
12
+ maxWaitMs;
13
+ onEvent;
14
+ idFactory;
15
+ lanes = new Map();
16
+ disposed = false;
17
+ counter = 0;
18
+ constructor(options) {
19
+ this.scheduler = options.scheduler ?? realScheduler;
20
+ this.concurrencyFor = options.concurrencyFor;
21
+ this.maxQueueDepth = Math.max(1, options.maxQueueDepth);
22
+ this.maxWaitMs = Math.max(0, options.maxWaitMs);
23
+ this.onEvent = options.onEvent;
24
+ this.idFactory = options.idFactory ?? (() => `q${(this.counter += 1)}`);
25
+ }
26
+ lane(provider) {
27
+ let lane = this.lanes.get(provider);
28
+ if (lane === undefined) {
29
+ lane = { active: 0, waiters: [], cooldownUntil: 0, cooldownTimer: undefined, averageDurationMs: 1_000 };
30
+ this.lanes.set(provider, lane);
31
+ }
32
+ return lane;
33
+ }
34
+ emit(kind, detail) {
35
+ this.onEvent?.(kind, detail);
36
+ }
37
+ grant(provider, lane, origin, queueId, waitMs) {
38
+ const grantedAt = this.scheduler.now();
39
+ lane.active += 1;
40
+ this.emit('granted', { provider, origin, queueId, waitMs, position: 0 });
41
+ return {
42
+ ok: true,
43
+ queueId,
44
+ provider,
45
+ origin,
46
+ waitMs,
47
+ release: () => this.release(provider, this.scheduler.now() - grantedAt),
48
+ };
49
+ }
50
+ /** Refuse immediately when the provider cannot serve inside the budget. */
51
+ preflightRefusal(lane, provider, origin, now) {
52
+ const cooldownRemaining = lane.cooldownUntil - now;
53
+ if (cooldownRemaining > this.maxWaitMs) {
54
+ this.emit('queue-timeout', { provider, origin, reason: 'cooldown-exceeds-budget', delayMs: cooldownRemaining });
55
+ return { ok: false, code: 'QUEUE_TIMEOUT', provider, origin, waitMs: 0, reason: 'cooldown-exceeds-budget' };
56
+ }
57
+ if (lane.waiters.length >= this.maxQueueDepth) {
58
+ this.emit('queue-full', { provider, origin, reason: 'max-queue-depth', position: lane.waiters.length });
59
+ return { ok: false, code: 'QUEUE_FULL', provider, origin, waitMs: 0, reason: 'max-queue-depth' };
60
+ }
61
+ return undefined;
62
+ }
63
+ /** Request admission; resolves once a slot is granted or refused. */
64
+ acquire(provider, options) {
65
+ const { origin, signal } = options;
66
+ if (this.disposed) {
67
+ return Promise.resolve({ ok: false, code: 'ABORTED', provider, origin, waitMs: 0, reason: 'disposed' });
68
+ }
69
+ const lane = this.lane(provider);
70
+ const now = this.scheduler.now();
71
+ if (lane.active < this.concurrencyFor(provider) && lane.waiters.length === 0 && now >= lane.cooldownUntil) {
72
+ const queueId = this.idFactory();
73
+ return Promise.resolve(this.grant(provider, lane, origin, queueId, 0));
74
+ }
75
+ const refused = this.preflightRefusal(lane, provider, origin, now);
76
+ if (refused !== undefined)
77
+ return Promise.resolve(refused);
78
+ return new Promise((resolve) => {
79
+ const waiter = {
80
+ id: this.idFactory(),
81
+ provider,
82
+ origin,
83
+ enqueuedAt: now,
84
+ settled: false,
85
+ deadline: undefined,
86
+ resolve,
87
+ detachAbort: () => undefined,
88
+ settle: () => undefined,
89
+ };
90
+ const settle = (outcome) => {
91
+ if (waiter.settled)
92
+ return;
93
+ waiter.settled = true;
94
+ this.scheduler.clearTimer(waiter.deadline);
95
+ waiter.detachAbort();
96
+ const index = lane.waiters.indexOf(waiter);
97
+ if (index >= 0)
98
+ lane.waiters.splice(index, 1);
99
+ resolve(outcome);
100
+ };
101
+ waiter.settle = settle;
102
+ waiter.deadline = this.scheduler.setTimer(() => {
103
+ const waitedMs = this.scheduler.now() - waiter.enqueuedAt;
104
+ this.emit('queue-timeout', { provider, origin, queueId: waiter.id, reason: 'max-wait', waitMs: waitedMs });
105
+ settle({ ok: false, code: 'QUEUE_TIMEOUT', provider, origin, waitMs: waitedMs, reason: 'max-wait', queueId: waiter.id });
106
+ }, this.maxWaitMs);
107
+ if (signal !== undefined) {
108
+ if (signal.aborted) {
109
+ settle({ ok: false, code: 'ABORTED', provider, origin, waitMs: 0, reason: 'aborted', queueId: waiter.id });
110
+ return;
111
+ }
112
+ const onAbort = () => {
113
+ settle({
114
+ ok: false,
115
+ code: 'ABORTED',
116
+ provider,
117
+ origin,
118
+ waitMs: this.scheduler.now() - waiter.enqueuedAt,
119
+ reason: 'aborted',
120
+ queueId: waiter.id,
121
+ });
122
+ };
123
+ signal.addEventListener('abort', onAbort, { once: true });
124
+ waiter.detachAbort = () => signal.removeEventListener('abort', onAbort);
125
+ }
126
+ lane.waiters.push(waiter);
127
+ this.emit('queued', {
128
+ provider,
129
+ origin,
130
+ queueId: waiter.id,
131
+ position: lane.waiters.length,
132
+ waitMs: 0,
133
+ delayMs: Math.max(0, lane.cooldownUntil - now),
134
+ });
135
+ this.armCooldown(provider, lane);
136
+ });
137
+ }
138
+ /**
139
+ * Replace the global wait budget and depth cap at runtime.
140
+ *
141
+ * Only admissions started after the call observe the new values: waiters
142
+ * already queued keep the deadline timer armed at acquire time.
143
+ *
144
+ * @param limits - partial limits; each defined field replaces the current one.
145
+ */
146
+ updateLimits(limits) {
147
+ if (limits.maxWaitMs !== undefined)
148
+ this.maxWaitMs = Math.max(0, limits.maxWaitMs);
149
+ if (limits.maxQueueDepth !== undefined)
150
+ this.maxQueueDepth = Math.max(1, limits.maxQueueDepth);
151
+ }
152
+ /** Return a slot granted by {@link acquire}. */
153
+ release(provider, durationMs) {
154
+ const lane = this.lanes.get(provider);
155
+ if (lane === undefined)
156
+ return;
157
+ if (lane.active > 0)
158
+ lane.active -= 1;
159
+ if (durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0) {
160
+ lane.averageDurationMs = lane.averageDurationMs * 0.7 + durationMs * 0.3;
161
+ }
162
+ this.emit('released', { provider, position: lane.active });
163
+ this.pump(provider);
164
+ }
165
+ /**
166
+ * Push the provider's cooldown to at least `now + delayMs`.
167
+ *
168
+ * A cooldown longer than the wait budget fails every current waiter at once:
169
+ * none of them could be served before its own deadline, so waiting would only
170
+ * spend the user's time to produce the same failure.
171
+ */
172
+ registerCooldown(provider, delayMs, reason, source) {
173
+ if (!Number.isFinite(delayMs) || delayMs <= 0)
174
+ return;
175
+ const lane = this.lane(provider);
176
+ const now = this.scheduler.now();
177
+ const until = now + delayMs;
178
+ if (until <= lane.cooldownUntil)
179
+ return;
180
+ lane.cooldownUntil = until;
181
+ this.emit('cooldown', { provider, delayMs, reason, ...(source === undefined ? {} : { source }) });
182
+ if (delayMs > this.maxWaitMs) {
183
+ for (const waiter of [...lane.waiters]) {
184
+ waiter.settle({
185
+ ok: false,
186
+ code: 'QUEUE_TIMEOUT',
187
+ provider,
188
+ origin: waiter.origin,
189
+ waitMs: now - waiter.enqueuedAt,
190
+ reason: 'cooldown-exceeds-budget',
191
+ queueId: waiter.id,
192
+ });
193
+ this.emit('queue-timeout', {
194
+ provider,
195
+ origin: waiter.origin,
196
+ queueId: waiter.id,
197
+ reason: 'cooldown-exceeds-budget',
198
+ delayMs,
199
+ });
200
+ }
201
+ return;
202
+ }
203
+ this.armCooldown(provider, lane);
204
+ }
205
+ /** Cancel one still-queued request. */
206
+ cancel(queueId) {
207
+ for (const lane of this.lanes.values()) {
208
+ const waiter = lane.waiters.find((candidate) => candidate.id === queueId);
209
+ if (waiter === undefined)
210
+ continue;
211
+ waiter.settle({
212
+ ok: false,
213
+ code: 'ABORTED',
214
+ provider: waiter.provider,
215
+ origin: waiter.origin,
216
+ waitMs: this.scheduler.now() - waiter.enqueuedAt,
217
+ reason: 'cancelled',
218
+ queueId: waiter.id,
219
+ });
220
+ this.emit('cancelled', { provider: waiter.provider, origin: waiter.origin, queueId: waiter.id, reason: 'user' });
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+ /** Current lanes and waiters, ordered by provider then arrival. */
226
+ snapshot() {
227
+ const now = this.scheduler.now();
228
+ const lanes = [];
229
+ const waiters = [];
230
+ for (const [provider, lane] of this.lanes) {
231
+ const limit = this.concurrencyFor(provider);
232
+ // The wire value stays JSON-safe: Infinity would serialize to null.
233
+ const concurrency = Number.isFinite(limit) ? Math.max(1, limit) : 0;
234
+ lanes.push({
235
+ provider,
236
+ active: lane.active,
237
+ concurrency,
238
+ queued: lane.waiters.length,
239
+ cooldownRemainingMs: Math.max(0, lane.cooldownUntil - now),
240
+ averageDurationMs: lane.averageDurationMs,
241
+ });
242
+ lane.waiters.forEach((waiter, index) => {
243
+ waiters.push({
244
+ queueId: waiter.id,
245
+ provider,
246
+ origin: waiter.origin,
247
+ position: index + 1,
248
+ waitedMs: now - waiter.enqueuedAt,
249
+ // index 之前还有 lane.active 个正在跑的请求占着并发槽;
250
+ // (index + active) / limit 算出还要排空几波(不限流时只剩冷却)。
251
+ etaMs: Math.max(0, lane.cooldownUntil - now) + Math.floor((index + lane.active) / limit) * lane.averageDurationMs,
252
+ });
253
+ });
254
+ }
255
+ return { at: now, lanes, waiters };
256
+ }
257
+ /** Fail every waiter and stop all timers. */
258
+ dispose() {
259
+ this.disposed = true;
260
+ const now = this.scheduler.now();
261
+ for (const lane of this.lanes.values()) {
262
+ this.scheduler.clearTimer(lane.cooldownTimer);
263
+ lane.cooldownTimer = undefined;
264
+ for (const waiter of [...lane.waiters]) {
265
+ waiter.settle({
266
+ ok: false,
267
+ code: 'ABORTED',
268
+ provider: waiter.provider,
269
+ origin: waiter.origin,
270
+ waitMs: now - waiter.enqueuedAt,
271
+ reason: 'disposed',
272
+ queueId: waiter.id,
273
+ });
274
+ }
275
+ }
276
+ }
277
+ armCooldown(provider, lane) {
278
+ this.scheduler.clearTimer(lane.cooldownTimer);
279
+ lane.cooldownTimer = undefined;
280
+ const remaining = lane.cooldownUntil - this.scheduler.now();
281
+ if (remaining <= 0) {
282
+ this.pump(provider);
283
+ return;
284
+ }
285
+ lane.cooldownTimer = this.scheduler.setTimer(() => {
286
+ lane.cooldownTimer = undefined;
287
+ this.pump(provider);
288
+ }, remaining);
289
+ }
290
+ pump(provider) {
291
+ const lane = this.lanes.get(provider);
292
+ if (lane === undefined || this.disposed)
293
+ return;
294
+ const now = this.scheduler.now();
295
+ if (now < lane.cooldownUntil) {
296
+ this.armCooldown(provider, lane);
297
+ return;
298
+ }
299
+ const concurrency = Math.max(1, this.concurrencyFor(provider));
300
+ while (lane.waiters.length > 0 && lane.active < concurrency) {
301
+ const waiter = lane.waiters.shift();
302
+ if (waiter === undefined)
303
+ break;
304
+ if (waiter.settled)
305
+ continue;
306
+ waiter.settled = true;
307
+ this.scheduler.clearTimer(waiter.deadline);
308
+ waiter.detachAbort();
309
+ const waitMs = now - waiter.enqueuedAt;
310
+ waiter.resolve(this.grant(provider, lane, waiter.origin, waiter.id, waitMs));
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Standalone request recovery for deployments without `@deepseek-ai/dsh-llm-retry`.
3
+ *
4
+ * The plugin never races the retry executor: it registers the provider cooldown,
5
+ * asks the rest of the `agent/request-error` waterfall for a decision, and only
6
+ * spends its own bounded budget when no downstream listener took the failure.
7
+ *
8
+ * @module dsh-llm-ctl/reactive
9
+ */
10
+ import type { DelayResolution } from './delay.ts';
11
+ /**
12
+ * Failure codes a queue can meaningfully wait out. Mirrors the default retryable
13
+ * set of `dsh-llm`; quota, auth, and context failures are terminal.
14
+ */
15
+ export declare const WAITABLE_CODES: readonly string[];
16
+ /** Why standalone recovery did or did not schedule a retry. */
17
+ export type ReactiveReason = 'scheduled' | 'downstream-owns' | 'code-not-waitable' | 'disabled' | 'budget-exhausted' | 'over-budget';
18
+ /** One standalone recovery decision. */
19
+ export interface ReactiveDecision {
20
+ retry: boolean;
21
+ delayMs: number;
22
+ reason: ReactiveReason;
23
+ }
24
+ /** Inputs to {@link decideReactive}. */
25
+ export interface ReactiveInput {
26
+ code: string;
27
+ /** True when a downstream listener returned `{kind:'retry'}`. */
28
+ delegated: boolean;
29
+ /** Retries already scheduled for this session/provider/turn/step. */
30
+ attempts: number;
31
+ /** Configured cap; 0 disables standalone recovery. */
32
+ limit: number;
33
+ delay: DelayResolution;
34
+ }
35
+ /**
36
+ * Decide whether this plugin should schedule the retry itself.
37
+ *
38
+ * A downstream decision always wins; the local budget is the fallback for a
39
+ * deployment that mounts no retry executor at all.
40
+ */
41
+ export declare function decideReactive(input: ReactiveInput): ReactiveDecision;
42
+ /** Bounded per-step retry counters keyed by session, provider, turn, and step. */
43
+ export declare class RetryBudget {
44
+ private readonly counts;
45
+ private readonly maxEntries;
46
+ constructor(maxEntries?: number);
47
+ /** Retries already scheduled for this key. */
48
+ attempts(key: string): number;
49
+ /** Record one scheduled retry and return the new count. */
50
+ record(key: string): number;
51
+ /** Forget one key, e.g. after the step completed. */
52
+ forget(key: string): void;
53
+ /** Current entry count, for tests and diagnostics. */
54
+ get size(): number;
55
+ }
56
+ /** Abortable delay; resolves false when the wait was cancelled. */
57
+ export declare function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Failure codes a queue can meaningfully wait out. Mirrors the default retryable
3
+ * set of `dsh-llm`; quota, auth, and context failures are terminal.
4
+ */
5
+ export const WAITABLE_CODES = ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE'];
6
+ /**
7
+ * Decide whether this plugin should schedule the retry itself.
8
+ *
9
+ * A downstream decision always wins; the local budget is the fallback for a
10
+ * deployment that mounts no retry executor at all.
11
+ */
12
+ export function decideReactive(input) {
13
+ if (input.delegated)
14
+ return { retry: false, delayMs: input.delay.delayMs, reason: 'downstream-owns' };
15
+ if (!WAITABLE_CODES.includes(input.code))
16
+ return { retry: false, delayMs: input.delay.delayMs, reason: 'code-not-waitable' };
17
+ if (input.limit <= 0)
18
+ return { retry: false, delayMs: input.delay.delayMs, reason: 'disabled' };
19
+ if (input.attempts >= input.limit)
20
+ return { retry: false, delayMs: input.delay.delayMs, reason: 'budget-exhausted' };
21
+ if (input.delay.overBudget)
22
+ return { retry: false, delayMs: input.delay.delayMs, reason: 'over-budget' };
23
+ return { retry: true, delayMs: input.delay.delayMs, reason: 'scheduled' };
24
+ }
25
+ /** Bounded per-step retry counters keyed by session, provider, turn, and step. */
26
+ export class RetryBudget {
27
+ counts = new Map();
28
+ maxEntries;
29
+ constructor(maxEntries = 500) {
30
+ this.maxEntries = Math.max(1, maxEntries);
31
+ }
32
+ /** Retries already scheduled for this key. */
33
+ attempts(key) {
34
+ return this.counts.get(key) ?? 0;
35
+ }
36
+ /** Record one scheduled retry and return the new count. */
37
+ record(key) {
38
+ const next = this.attempts(key) + 1;
39
+ this.counts.delete(key);
40
+ this.counts.set(key, next);
41
+ while (this.counts.size > this.maxEntries) {
42
+ const oldest = this.counts.keys().next();
43
+ if (oldest.done === true)
44
+ break;
45
+ this.counts.delete(oldest.value);
46
+ }
47
+ return next;
48
+ }
49
+ /** Forget one key, e.g. after the step completed. */
50
+ forget(key) {
51
+ this.counts.delete(key);
52
+ }
53
+ /** Current entry count, for tests and diagnostics. */
54
+ get size() {
55
+ return this.counts.size;
56
+ }
57
+ }
58
+ /** Abortable delay; resolves false when the wait was cancelled. */
59
+ export function cancellableDelay(delayMs, signal) {
60
+ if (signal.aborted)
61
+ return Promise.resolve(false);
62
+ if (delayMs <= 0)
63
+ return Promise.resolve(true);
64
+ return new Promise((resolve) => {
65
+ const timer = setTimeout(() => {
66
+ signal.removeEventListener('abort', onAbort);
67
+ resolve(true);
68
+ }, delayMs);
69
+ function onAbort() {
70
+ clearTimeout(timer);
71
+ resolve(false);
72
+ }
73
+ signal.addEventListener('abort', onAbort, { once: true });
74
+ });
75
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Reasoning-effort declarations for hand-declared `llm-pi-ai` models.
3
+ *
4
+ * A custom provider model shows no effort picker because its materialized
5
+ * pi-ai descriptor carries no reasoning capability. The official fix is a
6
+ * per-model `reasoningEfforts` declaration (plus `compat.supportsReasoningEffort`)
7
+ * written into the `llm-pi-ai` settings section. Everything here is pure data
8
+ * shaping so it is testable without a browser or a settings provider.
9
+ *
10
+ * Official vocabulary (escalation order): off, minimal, low, medium, high,
11
+ * xhigh, max. Locked default (official-aligned): off/low/high/max with an
12
+ * identity wire mapping, matching the official deepseek adapter's
13
+ * Off/Low/High/Max ladder.
14
+ *
15
+ * @module dsh-llm-ctl/reasoning-efforts
16
+ */
17
+ /** Settings namespace owning hand-declared pi-ai routes. */
18
+ export declare const PI_AI_SETTINGS_NS = "llm-pi-ai";
19
+ /** Thinking levels in escalation order. */
20
+ export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
21
+ /** One selectable thinking level. */
22
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
23
+ /** Official-aligned default ladder: Off/Low/High/Max. */
24
+ export declare const DEFAULT_EFFORT_LEVELS: readonly ThinkingLevel[];
25
+ /** Advanced levels hidden behind the disclosure, off by default. */
26
+ export declare const ADVANCED_EFFORT_LEVELS: readonly ThinkingLevel[];
27
+ /** Wire spelling per level; identity mapping is the standard OpenAI spelling. */
28
+ export type EffortWireMap = Partial<Record<ThinkingLevel, string | null>>;
29
+ /**
30
+ * Build the default `reasoningEfforts` dict: every default level maps to
31
+ * itself, `off` included (explicit off switch, official-aligned).
32
+ *
33
+ * @param levels - levels to offer; defaults to the official ladder.
34
+ * @returns the dict to store on the model entry.
35
+ */
36
+ export declare function defaultReasoningEfforts(levels?: readonly ThinkingLevel[]): Record<string, string>;
37
+ /**
38
+ * Validate a user-supplied effort dict against the official shape:
39
+ * non-empty, every key a known level, `off` may be string|null, every other
40
+ * declared level a non-empty string, and at least one level beyond `off`.
41
+ *
42
+ * @param value - candidate dict.
43
+ * @returns the failure reason, or undefined when valid.
44
+ */
45
+ export declare function validateReasoningEfforts(value: unknown): string | undefined;
46
+ /** One hand-declared pi-ai model entry (structural subset). */
47
+ export interface PiAiModelEntry {
48
+ id: string;
49
+ reasoningEfforts?: unknown;
50
+ compat?: {
51
+ supportsReasoningEffort?: unknown;
52
+ } & Record<string, unknown>;
53
+ [key: string]: unknown;
54
+ }
55
+ /** Resolved per-model effort facts for the settings UI. */
56
+ export interface ModelEffortFacts {
57
+ provider: string;
58
+ model: string;
59
+ /** True when the settings namespace is `llm-pi-ai` (the only writable family). */
60
+ writableNs: boolean;
61
+ /** True when the model came from an explicit `models` list (vs catalog/override). */
62
+ declared: boolean;
63
+ /** Currently stored reasoningEfforts dict, when valid. */
64
+ stored?: Record<string, string | null>;
65
+ /** Currently offered levels (keys of a valid stored dict). */
66
+ levels: string[];
67
+ /** Currently offered by the live catalog (read-only display). */
68
+ liveLevels?: string[];
69
+ }
70
+ /**
71
+ * Summarize one model row for the effort editor.
72
+ *
73
+ * @param input - provider identity, settings namespace, stored entry, live catalog levels.
74
+ * @returns facts the UI renders from.
75
+ */
76
+ export declare function summarizeModelEffort(input: {
77
+ provider: string;
78
+ model: string;
79
+ settingsNs: string;
80
+ entry?: PiAiModelEntry | undefined;
81
+ liveLevels?: readonly string[] | undefined;
82
+ }): ModelEffortFacts;
83
+ /** Path-addressed settings op (mirrors the host settings shape). */
84
+ export type EffortPathOp = {
85
+ op: 'set';
86
+ path: readonly string[];
87
+ value: unknown;
88
+ } | {
89
+ op: 'unset';
90
+ path: readonly string[];
91
+ };
92
+ /**
93
+ * Build the settings ops that declare efforts for one model.
94
+ *
95
+ * Hand-declared routes (`settingsPath = ["providers", route]`) store the
96
+ * whole `models` array, so the op sets the full array with the entry merged.
97
+ * Catalog routes store per-model overrides under
98
+ * `providers.<route>.modelOverrides.<id>`, so the op targets the
99
+ * `reasoningEfforts` (and compat flag) paths directly.
100
+ *
101
+ * @param input - route identity, current models array (hand-declared only), target model, effort dict.
102
+ * @returns ordered ops for `settings.mutate`.
103
+ */
104
+ export declare function buildEffortOps(input: {
105
+ provider: string;
106
+ models: readonly PiAiModelEntry[] | undefined;
107
+ model: string;
108
+ efforts: Record<string, string | null>;
109
+ }): EffortPathOp[];
110
+ /**
111
+ * Build the settings ops that reset efforts for one model (back to inherit).
112
+ *
113
+ * @param input - route identity, hand-declared models array, target model.
114
+ * @returns ordered ops for `settings.mutate`.
115
+ */
116
+ export declare function buildEffortResetOps(input: {
117
+ provider: string;
118
+ models: readonly PiAiModelEntry[] | undefined;
119
+ model: string;
120
+ }): EffortPathOp[];