dsh-autotier 0.2.2 → 0.2.5
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/AGENTS.md +52 -23
- package/CHANGELOG.md +44 -0
- package/{README.es.md → README-es.md} +94 -13
- package/{README.hi.md → README-hi.md} +93 -14
- package/{README.pt.md → README-pt.md} +96 -16
- package/{README.zh.md → README-zh.md} +83 -11
- package/README.md +110 -18
- package/cordis.patch.yml +2 -2
- package/lib/client.js +72 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +233 -66
- package/lib/typert.host.js +1 -1
- package/lib/types/client/TierSettingsCard.d.ts +14 -12
- package/lib/types/client/TierSettingsCard.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +5 -4
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/remote.d.ts +12 -12
- package/lib/types/config.d.ts +20 -9
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts +11 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/judge.d.ts +17 -0
- package/lib/types/judge.d.ts.map +1 -1
- package/lib/types/preflight.d.ts +21 -1
- package/lib/types/preflight.d.ts.map +1 -1
- package/lib/types/routing.d.ts.map +1 -1
- package/lib/types/schema.d.ts +44 -2
- package/lib/types/schema.d.ts.map +1 -1
- package/lib/types/selection.d.ts +36 -10
- package/lib/types/selection.d.ts.map +1 -1
- package/lib/types/service.d.ts +16 -9
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +12 -12
- package/lib/types/wire.d.ts +24 -24
- package/lib/types/wire.d.ts.map +1 -1
- package/lib/{wire-BpKSOHgo.js → wire-BxsY8BiN.js} +22 -20
- package/package.json +62 -56
- package/src/client/TierSettingsCard.tsx +25 -12
- package/src/client/index.ts +29 -16
- package/src/config.ts +70 -12
- package/src/index.ts +23 -19
- package/src/judge.ts +30 -2
- package/src/preflight.ts +68 -1
- package/src/routing.ts +11 -0
- package/src/schema.ts +48 -9
- package/src/selection.ts +54 -20
- package/src/service.ts +20 -18
- package/src/settings.ts +80 -0
- package/src/wire.ts +19 -20
package/src/config.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module dsh-autotier/config
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { isVolatile, type Volatile } from '@deepseek-ai/cosmokit'
|
|
10
11
|
import {
|
|
11
12
|
COST_MODES,
|
|
12
13
|
EFFORT_IDS,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
ScenarioToggles,
|
|
27
28
|
TierConfig,
|
|
28
29
|
VisionConfig,
|
|
30
|
+
VolatileConfig,
|
|
29
31
|
} from './schema.ts'
|
|
30
32
|
|
|
31
33
|
export { Config } from './schema.ts'
|
|
@@ -39,8 +41,52 @@ export type {
|
|
|
39
41
|
ScenarioToggles,
|
|
40
42
|
TierConfig,
|
|
41
43
|
VisionConfig,
|
|
44
|
+
VolatileConfig,
|
|
42
45
|
} from './schema.ts'
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Read one config field that may arrive as a live reference or as plain data.
|
|
49
|
+
* The Loader hands `apply` a `VolatileConfig` (every section is a reference);
|
|
50
|
+
* a programmatic mount, a test, or `scripts/` hands plain data. Both faces feed
|
|
51
|
+
* the same resolver, so neither can drift from the other.
|
|
52
|
+
*
|
|
53
|
+
* `Volatile.get()` answers a deeply-readonly snapshot. The resolvers below read
|
|
54
|
+
* these values and then produce their own frozen output, so the read-only view
|
|
55
|
+
* is exactly the contract wanted; the cast re-widens it to the mutable field
|
|
56
|
+
* type so those signatures do not have to carry `readonly` everywhere.
|
|
57
|
+
*
|
|
58
|
+
* @param value - the field as handed to the plugin.
|
|
59
|
+
* @returns the current snapshot value.
|
|
60
|
+
*/
|
|
61
|
+
function unwrap<T>(value: Volatile<T> | T): T | undefined {
|
|
62
|
+
return isVolatile(value) ? (value.get() as T | undefined) : value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the plain-data config out of either accepted face.
|
|
67
|
+
*
|
|
68
|
+
* `exactOptionalPropertyTypes` is on, so an absent section is omitted rather
|
|
69
|
+
* than set to `undefined`: the resolvers below treat "key absent" and "key
|
|
70
|
+
* undefined" alike, but only the omitted form is assignable to `Config`.
|
|
71
|
+
*
|
|
72
|
+
* @param raw - the plugin configuration as received.
|
|
73
|
+
* @returns the plain-data config every resolver below reads.
|
|
74
|
+
*/
|
|
75
|
+
function plain(raw: Config | VolatileConfig | undefined): Config {
|
|
76
|
+
if (raw === undefined) return {}
|
|
77
|
+
const tiers = unwrap(raw.tiers)
|
|
78
|
+
const intent = unwrap(raw.intent)
|
|
79
|
+
const guard = unwrap(raw.guard)
|
|
80
|
+
const escalation = unwrap(raw.escalation)
|
|
81
|
+
return {
|
|
82
|
+
...tiers === undefined ? {} : { tiers },
|
|
83
|
+
...intent === undefined ? {} : { intent },
|
|
84
|
+
...guard === undefined ? {} : { guard },
|
|
85
|
+
...escalation === undefined ? {} : { escalation },
|
|
86
|
+
...raw.routingMode === undefined ? {} : { routingMode: raw.routingMode },
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
/** One resolved fallback landing. */
|
|
45
91
|
export interface ResolvedFallbackEntry {
|
|
46
92
|
provider: string
|
|
@@ -314,12 +360,23 @@ function effectiveLanding(tier: ResolvedTierConfig): string {
|
|
|
314
360
|
* Resolve raw config to the frozen runtime policy, re-judging every default,
|
|
315
361
|
* bound and cross-field requirement.
|
|
316
362
|
*
|
|
317
|
-
*
|
|
363
|
+
* The cross-field judgement is the whole reason this module exists, and it is
|
|
364
|
+
* NOT expressed in the Schemastery schema: the host validates a form write
|
|
365
|
+
* against the schema alone, so a violation this function catches (a strong and
|
|
366
|
+
* a cheap tier landing on the same route, `hysteresis.toCheap >= toStrong`, a
|
|
367
|
+
* rule with neither a pattern nor a tool) would otherwise persist and only fail
|
|
368
|
+
* later. Every actor that can change the configuration therefore routes through
|
|
369
|
+
* here — the Loader at mount, and `settings.ts` on each `loader/volatile-update`
|
|
370
|
+
* — and refuses the change instead of storing something unroutable.
|
|
371
|
+
*
|
|
372
|
+
* @param raw - raw loader config; `undefined` for a bare row. Accepts both the
|
|
373
|
+
* Loader's `VolatileConfig` face and the plain-data `Config` face.
|
|
318
374
|
* @returns the frozen resolved config.
|
|
319
375
|
* @throws {Error} when a value is out of bounds or a cross-field requirement fails.
|
|
320
376
|
*/
|
|
321
|
-
export function resolveConfig(raw: Config | undefined): ResolvedConfig {
|
|
322
|
-
const
|
|
377
|
+
export function resolveConfig(raw: Config | VolatileConfig | undefined): ResolvedConfig {
|
|
378
|
+
const source = plain(raw)
|
|
379
|
+
const tiers = source.tiers ?? {}
|
|
323
380
|
const strong = resolveTier('strong', tiers.strong, DEFAULT_STRONG)
|
|
324
381
|
const cheap = resolveTier('cheap', tiers.cheap, DEFAULT_CHEAP)
|
|
325
382
|
const strongTriple = effectiveLanding(strong)
|
|
@@ -336,23 +393,24 @@ export function resolveConfig(raw: Config | undefined): ResolvedConfig {
|
|
|
336
393
|
model: text('tiers.vision.model', tiers.vision?.model, DEFAULT_VISION.model),
|
|
337
394
|
},
|
|
338
395
|
},
|
|
339
|
-
intent: resolveIntent(
|
|
340
|
-
guard: resolveGuard(
|
|
341
|
-
escalation: resolveEscalation(
|
|
342
|
-
routingMode: member('routingMode',
|
|
396
|
+
intent: resolveIntent(source.intent),
|
|
397
|
+
guard: resolveGuard(source.guard),
|
|
398
|
+
escalation: resolveEscalation(source.escalation),
|
|
399
|
+
routingMode: member('routingMode', source.routingMode, 'auto', ROUTING_MODES),
|
|
343
400
|
}
|
|
344
401
|
return deepFreeze(resolved)
|
|
345
402
|
}
|
|
346
403
|
|
|
347
404
|
/**
|
|
348
|
-
* Judge a configuration without keeping the resolved value.
|
|
349
|
-
* save-time hook the `autotier` settings namespace registers, so a user write
|
|
350
|
-
* that violates a cross-field requirement is refused at the write instead of
|
|
351
|
-
* silently disabling the plugin.
|
|
405
|
+
* Judge a configuration without keeping the resolved value.
|
|
352
406
|
*
|
|
353
407
|
* @param value - the configuration to judge.
|
|
354
408
|
* @throws {Error} when the configuration is invalid.
|
|
409
|
+
* @deprecated The host no longer offers a `settings.register` validate hook, so
|
|
410
|
+
* this is no longer wired as a save-time gate. {@link resolveConfig} is the
|
|
411
|
+
* single judge and is called directly at mount and on every live update; this
|
|
412
|
+
* alias remains exported because it is part of the plugin's published surface.
|
|
355
413
|
*/
|
|
356
|
-
export function validateConfig(value: Config): void {
|
|
414
|
+
export function validateConfig(value: Config | VolatileConfig): void {
|
|
357
415
|
resolveConfig(value)
|
|
358
416
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,16 +18,18 @@
|
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
19
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
20
20
|
import { registerTierCommand } from './command.ts'
|
|
21
|
-
import {
|
|
21
|
+
import { resolveConfig, type Config as AutotierConfig, type VolatileConfig } from './config.ts'
|
|
22
22
|
import { registerGuardHook } from './guard.ts'
|
|
23
23
|
import { AutotierRouter } from './routing.ts'
|
|
24
|
+
import { assertTierDefaultsInCatalog } from './preflight.ts'
|
|
24
25
|
import { AutotierService } from './service.ts'
|
|
26
|
+
import { bindSettings } from './settings.ts'
|
|
25
27
|
import { AgentStateStore, registerTierProjection } from './state.ts'
|
|
26
28
|
import { TierRemoteService } from './tier-remote.ts'
|
|
27
29
|
import { registerTierTools } from './tools.ts'
|
|
28
30
|
|
|
29
31
|
export { Config, resolveConfig, validateConfig } from './config.ts'
|
|
30
|
-
export type { Config as AutotierConfig, ResolvedConfig } from './config.ts'
|
|
32
|
+
export type { Config as AutotierConfig, ResolvedConfig, VolatileConfig } from './config.ts'
|
|
31
33
|
export type {
|
|
32
34
|
AutotierStatus,
|
|
33
35
|
CostMode,
|
|
@@ -126,27 +128,29 @@ export const name = 'dsh-autotier'
|
|
|
126
128
|
export const inject = ['settings', 'llm', 'tools', 'commands', 'sessions']
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
|
-
* Mount the plugin: judge the configuration,
|
|
130
|
-
*
|
|
131
|
-
*
|
|
131
|
+
* Mount the plugin: judge the configuration, claim the settings page policy,
|
|
132
|
+
* publish the `ctx.autotier` service, and wire the routing listeners, the
|
|
133
|
+
* `/tier` command and the two read-only tools.
|
|
132
134
|
*
|
|
133
135
|
* @param ctx - the plugin context.
|
|
134
|
-
* @param config - the
|
|
135
|
-
*
|
|
136
|
+
* @param config - the Loader's volatile row configuration. Also accepts the
|
|
137
|
+
* plain-data `AutotierConfig` face, so a programmatic mount or a test can
|
|
138
|
+
* pass literal values.
|
|
139
|
+
* @throws {Error} when the configuration fails the cross-field judgement, or
|
|
140
|
+
* when a default tier model id is absent from a new-generation host catalogue.
|
|
136
141
|
*/
|
|
137
|
-
export function apply(ctx: Context, config: AutotierConfig = {}): void {
|
|
138
|
-
// Resolve first so a bad row fails at mount, before any
|
|
139
|
-
//
|
|
142
|
+
export async function apply(ctx: Context, config: AutotierConfig | VolatileConfig = {}): Promise<void> {
|
|
143
|
+
// Resolve first so a bad row fails at mount, before any service is published
|
|
144
|
+
// (fail loud, and leave no half-mounted state behind).
|
|
140
145
|
const resolved = resolveConfig(config)
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
})
|
|
149
|
-
const service = new AutotierService(ctx, { scope, config: resolved })
|
|
146
|
+
// Mount-time catalogue membership gate: on the 0.1.6 catalogue generation a
|
|
147
|
+
// default tier id outside the catalogue fails the mount loudly instead of
|
|
148
|
+
// silently degrading to a text-only passthrough (see assertTierDefaultsInCatalog).
|
|
149
|
+
await assertTierDefaultsInCatalog(ctx, resolved.tiers)
|
|
150
|
+
const service = new AutotierService(ctx, { config: resolved })
|
|
151
|
+
// Settings: claim this instance's page policy and follow live edits. Replaces
|
|
152
|
+
// the removed `ctx.settings.register('autotier', Config, { base, validate })`.
|
|
153
|
+
bindSettings({ ctx, config: config as VolatileConfig, service })
|
|
150
154
|
registerTierProjection(ctx)
|
|
151
155
|
const states = new AgentStateStore()
|
|
152
156
|
new AutotierRouter({ ctx, service, states })
|
package/src/judge.ts
CHANGED
|
@@ -8,10 +8,31 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
BlockAssembler,
|
|
13
|
+
boundContextSummary,
|
|
14
|
+
createUserMessage,
|
|
15
|
+
type ContextFormed,
|
|
16
|
+
type GenerateOptions,
|
|
17
|
+
} from '@deepseek-ai/dsh-llm'
|
|
12
18
|
import type { ResolvedConfig } from './config.ts'
|
|
13
19
|
import type { Scenario, TierId } from './types.ts'
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* This plugin's own message-source kind.
|
|
23
|
+
*
|
|
24
|
+
* The harness has no shared catch-all `plugin` kind: `MessageSourceMap` is a
|
|
25
|
+
* merge-extensible sum type, each producer declares its own `kind` in its own
|
|
26
|
+
* module, and the session format's physical-row admission rejects the retired
|
|
27
|
+
* `kind === 'plugin'` wrapper outright (`session-format-v3-to-v4`
|
|
28
|
+
* `assertV4SourceRowAdmission`, which nothing at runtime can bypass).
|
|
29
|
+
*/
|
|
30
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
31
|
+
interface MessageSourceMap {
|
|
32
|
+
'dsh-autotier': { kind: 'dsh-autotier' } & ContextFormed
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
/** The label vocabulary the judge is asked to choose from. */
|
|
16
37
|
export const JUDGE_LABELS: readonly { readonly label: string; readonly scenario: Scenario }[] = [
|
|
17
38
|
{ label: 'coding', scenario: 'coding' },
|
|
@@ -117,7 +138,14 @@ export async function runJudge(
|
|
|
117
138
|
model: route.model,
|
|
118
139
|
messages: [createUserMessage({
|
|
119
140
|
content: [{ type: 'text', text: prompt }],
|
|
120
|
-
|
|
141
|
+
// A producer-owned kind with a `notice` form: the one-line account is
|
|
142
|
+
// committed to the durable log, so a reader can tell why an extra model
|
|
143
|
+
// call happened without expanding the row.
|
|
144
|
+
source: {
|
|
145
|
+
kind: 'dsh-autotier',
|
|
146
|
+
form: 'notice',
|
|
147
|
+
summary: boundContextSummary(`autotier judge: classify intent (${route.provider}/${route.model})`),
|
|
148
|
+
},
|
|
121
149
|
})],
|
|
122
150
|
temperature: config.intent.judge.temperature,
|
|
123
151
|
maxTokens: config.intent.judge.maxTokens,
|
package/src/preflight.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
-
import type { ResolvedTierConfig } from './config.ts'
|
|
17
|
+
import type { ResolvedConfig, ResolvedTierConfig } from './config.ts'
|
|
18
18
|
import type { TierRoute } from './types.ts'
|
|
19
19
|
|
|
20
20
|
/** One cached model capability answer. */
|
|
@@ -103,3 +103,70 @@ export class RoutePreflight {
|
|
|
103
103
|
return safe
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mount-time default-model catalogue membership check.
|
|
109
|
+
*
|
|
110
|
+
* The 0.1.6 line removed the deepseek v4-flash* catalogue ids and introduced
|
|
111
|
+
* `deepseek-flash` (image-capable). On that catalogue generation a default
|
|
112
|
+
* tier id that is not in the host catalogue must fail the mount loudly —
|
|
113
|
+
* silently degrading to a text-only passthrough is exactly the N7 regression
|
|
114
|
+
* this check guards against.
|
|
115
|
+
*
|
|
116
|
+
* Generation gate: hosts whose catalogue predates the new vocabulary (e.g.
|
|
117
|
+
* `0.1.2-rc.1`, where none of the default ids exist) keep the accepted M2
|
|
118
|
+
* degradation (unlisted id ⇒ text-only) and skip the check with one
|
|
119
|
+
* documented log line, so mounting on the old compat rows is preserved.
|
|
120
|
+
*
|
|
121
|
+
* @param ctx - the plugin context.
|
|
122
|
+
* @param tiers - the resolved tier landings to verify.
|
|
123
|
+
* @throws {Error} when the catalogue is the new generation and a default
|
|
124
|
+
* model id is not present in it.
|
|
125
|
+
*/
|
|
126
|
+
export async function assertTierDefaultsInCatalog(ctx: Context, tiers: ResolvedConfig['tiers']): Promise<void> {
|
|
127
|
+
const provider = 'deepseek-official'
|
|
128
|
+
const llm = ctx.llm
|
|
129
|
+
// Minimal llm faces (scripted harnesses, pared-down compositions) lack the
|
|
130
|
+
// catalogue seam; they keep the accepted degraded behavior and skip.
|
|
131
|
+
if (llm === undefined || typeof llm.listProviders !== 'function' || typeof llm.resolveModelInfo !== 'function') {
|
|
132
|
+
ctx.logger.info('dsh-autotier: the llm catalogue seam is unavailable; skipping the default-model catalogue check')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const registered = llm.listProviders().some(entry => entry.id === provider)
|
|
136
|
+
if (!registered) {
|
|
137
|
+
ctx.logger.info('dsh-autotier: provider "%s" is not registered; skipping the default-model catalogue check', provider)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const defaults = [
|
|
141
|
+
['strong', tiers.strong.model],
|
|
142
|
+
['cheap', tiers.cheap.model],
|
|
143
|
+
['vision', tiers.vision.model],
|
|
144
|
+
] as const
|
|
145
|
+
// New-generation probe: the catalogue is the 0.1.6 vocabulary when any of
|
|
146
|
+
// the default ids resolves; an old catalogue resolves none of them.
|
|
147
|
+
let newGeneration = false
|
|
148
|
+
for (const [, model] of defaults) {
|
|
149
|
+
try {
|
|
150
|
+
await llm.resolveModelInfo(provider, model)
|
|
151
|
+
newGeneration = true
|
|
152
|
+
break
|
|
153
|
+
} catch {
|
|
154
|
+
// Old catalogue or absent id: keep probing the other defaults.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!newGeneration) {
|
|
158
|
+
ctx.logger.info(
|
|
159
|
+
'dsh-autotier: the host catalogue predates the deepseek-flash vocabulary; skipping the default-model catalogue check (unlisted ids degrade to text-only on this host)',
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
for (const [tier, model] of defaults) {
|
|
164
|
+
try {
|
|
165
|
+
await llm.resolveModelInfo(provider, model)
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`dsh-autotier: the default ${tier} model "${provider}/${model}" is not in the host catalogue; refusing to mount with a silent text-only landing`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/routing.ts
CHANGED
|
@@ -146,6 +146,17 @@ export class AutotierRouter {
|
|
|
146
146
|
this.ctx.on('agent/error', payload => this.onAgentError(payload.agent, payload.error))
|
|
147
147
|
this.ctx.on('agent/request-error', (payload, next) => this.onRequestError(payload.agent, payload.provider, payload.failure, next))
|
|
148
148
|
this.ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
|
|
149
|
+
// Backfill the per-agent state at creation so the first request never pays
|
|
150
|
+
// the lazy-init path on a hot turn; the listener is synchronous and a
|
|
151
|
+
// failure here is contained (the lazy path still exists as the fallback).
|
|
152
|
+
this.ctx.on('agent/created', payload => {
|
|
153
|
+
try {
|
|
154
|
+
this.states.for(payload.agent)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.ctx.logger.warn('dsh-autotier: could not backfill routing state for the created agent (%o)', error)
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
})
|
|
149
160
|
this.ctx.effect(() => () => {
|
|
150
161
|
this.disposed = true
|
|
151
162
|
this.lifetime.abort()
|
package/src/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @module dsh-autotier/schema
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { Volatile } from '@deepseek-ai/cosmokit'
|
|
11
12
|
import z from '@deepseek-ai/schemastery'
|
|
12
13
|
import {
|
|
13
14
|
COST_MODES,
|
|
@@ -105,6 +106,11 @@ export interface EscalationConfig {
|
|
|
105
106
|
* Raw (possibly partial) plugin configuration. Every field is optional because
|
|
106
107
|
* the resolver supplies the defaults; {@link resolveConfig} turns it into the
|
|
107
108
|
* fully-resolved {@link ResolvedConfig}.
|
|
109
|
+
*
|
|
110
|
+
* This is the plain-data face of the row: a caller that builds a config in
|
|
111
|
+
* process (a test, a programmatic mount) passes this shape. The Loader hands
|
|
112
|
+
* `apply` the {@link VolatileConfig} face below, because every top-level field
|
|
113
|
+
* of {@link Config} is declared `.volatile()`.
|
|
108
114
|
*/
|
|
109
115
|
export interface Config {
|
|
110
116
|
tiers?: { strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig }
|
|
@@ -114,6 +120,31 @@ export interface Config {
|
|
|
114
120
|
routingMode?: RoutingMode
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
/**
|
|
124
|
+
* What `apply` actually receives from the Loader. Each top-level field is a
|
|
125
|
+
* `Volatile` reference rather than a value, because the host's settings forms
|
|
126
|
+
* write through `loader/volatile-update` and a consumer must re-read with
|
|
127
|
+
* `.get()`.
|
|
128
|
+
*
|
|
129
|
+
* `.volatile()` is only legal on a fixed object path, so it sits on each
|
|
130
|
+
* top-level field — never inside `intent.rules` or `tiers.*.fallback`. Marking
|
|
131
|
+
* the whole section keeps every nested key live while staying inside that rule:
|
|
132
|
+
* a form edit of `intent.judge.maxTokens` replaces the `intent` snapshot, and
|
|
133
|
+
* the routing policy re-resolves from the new one.
|
|
134
|
+
*
|
|
135
|
+
* `routingMode` is deliberately NOT volatile: it is the composition default,
|
|
136
|
+
* and the live per-session switch is `RouteState.override` (written by `/tier`
|
|
137
|
+
* and the Remote `setMode`). Keeping it plain leaves that surface authoritative
|
|
138
|
+
* instead of introducing a second, document-backed way to change the mode.
|
|
139
|
+
*/
|
|
140
|
+
export interface VolatileConfig {
|
|
141
|
+
tiers: Volatile<{ strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig } | undefined>
|
|
142
|
+
intent: Volatile<IntentConfig | undefined>
|
|
143
|
+
guard: Volatile<GuardConfig | undefined>
|
|
144
|
+
escalation: Volatile<EscalationConfig | undefined>
|
|
145
|
+
routingMode: RoutingMode
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
|
|
118
149
|
/** The default strong tier: the catalog's quality-critical model at high effort. */
|
|
119
150
|
export const DEFAULT_STRONG = {
|
|
@@ -126,7 +157,7 @@ export const DEFAULT_STRONG = {
|
|
|
126
157
|
/** The default cheap tier: the catalog's routine/parallel model at low effort. */
|
|
127
158
|
export const DEFAULT_CHEAP = {
|
|
128
159
|
provider: 'deepseek-official',
|
|
129
|
-
model: 'deepseek-
|
|
160
|
+
model: 'deepseek-flash',
|
|
130
161
|
effort: 'low' as const,
|
|
131
162
|
followSession: true,
|
|
132
163
|
}
|
|
@@ -134,7 +165,7 @@ export const DEFAULT_CHEAP = {
|
|
|
134
165
|
/** The default vision landing: the catalog's only image-capable model. */
|
|
135
166
|
export const DEFAULT_VISION = {
|
|
136
167
|
provider: 'deepseek-official',
|
|
137
|
-
model: 'deepseek-
|
|
168
|
+
model: 'deepseek-flash',
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
/**
|
|
@@ -167,20 +198,28 @@ const cheapTier = z.object({
|
|
|
167
198
|
})).default([]),
|
|
168
199
|
})
|
|
169
200
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Schemastery schema: the loader validates and fills defaults before `apply`.
|
|
203
|
+
*
|
|
204
|
+
* Each top-level section is `.volatile()` so a host settings-form write reaches
|
|
205
|
+
* a running plugin through `loader/volatile-update`. A section that owns no
|
|
206
|
+
* live-switchable field (`routingMode`) stays plain: `.volatile()` is an opt-in
|
|
207
|
+
* that adds a form-editable surface, and inventing one for a field the plugin
|
|
208
|
+
* already switches per session would give the same behaviour two owners.
|
|
209
|
+
*/
|
|
210
|
+
export const Config: z<Config, VolatileConfig> = z.object({
|
|
172
211
|
tiers: z.object({
|
|
173
212
|
strong: strongTier.default({ ...DEFAULT_STRONG, fallback: [] }),
|
|
174
213
|
cheap: cheapTier.default({ ...DEFAULT_CHEAP, fallback: [] }),
|
|
175
214
|
vision: z.object({
|
|
176
215
|
provider: z.string().default('deepseek-official'),
|
|
177
|
-
model: z.string().default('deepseek-
|
|
216
|
+
model: z.string().default('deepseek-flash'),
|
|
178
217
|
}).default({ ...DEFAULT_VISION }),
|
|
179
218
|
}).default({
|
|
180
219
|
strong: { ...DEFAULT_STRONG, fallback: [] },
|
|
181
220
|
cheap: { ...DEFAULT_CHEAP, fallback: [] },
|
|
182
221
|
vision: { ...DEFAULT_VISION },
|
|
183
|
-
}),
|
|
222
|
+
}).volatile(),
|
|
184
223
|
intent: z.object({
|
|
185
224
|
ruleThreshold: z.number().min(0.000001).max(1).default(0.7),
|
|
186
225
|
attemptBand: z.object({
|
|
@@ -263,7 +302,7 @@ export const Config: z<Config> = z.object({
|
|
|
263
302
|
multimodal: true,
|
|
264
303
|
},
|
|
265
304
|
costMode: 'balanced',
|
|
266
|
-
}),
|
|
305
|
+
}).volatile(),
|
|
267
306
|
guard: z.object({
|
|
268
307
|
enabled: z.boolean().default(true),
|
|
269
308
|
tiers: z.array(z.union(['cheap'])).default(['cheap']),
|
|
@@ -276,7 +315,7 @@ export const Config: z<Config> = z.object({
|
|
|
276
315
|
whitelist: [],
|
|
277
316
|
protectedPaths: ['.dsh', 'AGENTS.md', 'package.json', '.github/workflows'],
|
|
278
317
|
interopDefend: 'auto',
|
|
279
|
-
}),
|
|
318
|
+
}).volatile(),
|
|
280
319
|
escalation: z.object({
|
|
281
320
|
threshold: z.number().step(1).min(1).max(100).default(2),
|
|
282
321
|
windowMs: z.number().min(1).max(86_400_000).default(60_000),
|
|
@@ -289,7 +328,7 @@ export const Config: z<Config> = z.object({
|
|
|
289
328
|
ttlMs: 180_000,
|
|
290
329
|
fallbackTtlMs: 300_000,
|
|
291
330
|
signature: true,
|
|
292
|
-
}),
|
|
331
|
+
}).volatile(),
|
|
293
332
|
routingMode: z.union([...ROUTING_MODES]).default('auto'),
|
|
294
333
|
})
|
|
295
334
|
|
package/src/selection.ts
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session-selection synchronisation
|
|
2
|
+
* Session-selection synchronisation.
|
|
3
3
|
*
|
|
4
4
|
* Two harness facts drive this module:
|
|
5
5
|
*
|
|
6
6
|
* 1. The GUI's model picker reads and writes the `agent-default-model`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* configuration, so a router that changes the tier must mirror the change
|
|
8
|
+
* there — otherwise the picker shows a model nobody is using. The mirror is
|
|
9
|
+
* best-effort: without `agentDefaultModel` it degrades to a no-op.
|
|
10
|
+
* 2. A user's explicit choice must win. When the documented selection changes
|
|
11
|
+
* to a value this module did not write, every live session switches to
|
|
12
|
+
* `delegated` (routing stops) until `/tier auto`.
|
|
13
|
+
*
|
|
14
|
+
* Fact 2 used to ride the settings service's `settings/updated` commit event.
|
|
15
|
+
* That event no longer exists: the `0.1.6`-generation host replaced the
|
|
16
|
+
* settings *provider* with `SettingsForms` (a schema→form projector), so a
|
|
17
|
+
* plugin can no longer observe another plugin's document. What survives is the
|
|
18
|
+
* `agentDefaultModel` service itself, which now answers `currentSelection()` by
|
|
19
|
+
* reading its own live `Volatile` fields.
|
|
20
|
+
*
|
|
21
|
+
* Detection is therefore a comparison rather than a subscription: {@link
|
|
22
|
+
* SelectionSync.observeExternalSelection} freshly reads the documented selection
|
|
23
|
+
* and delegates when it differs from the value this module last wrote. It runs
|
|
24
|
+
* on the path that would otherwise overwrite the user — immediately before a
|
|
25
|
+
* tier is mirrored — so a user's pick can never be silently replaced by a
|
|
26
|
+
* routing decision, which is the property the old subscription protected.
|
|
14
27
|
*
|
|
15
28
|
* @module dsh-autotier/selection
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
31
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
32
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
20
|
-
import type {} from '@deepseek-ai/dsh-settings'
|
|
21
33
|
import type { AgentStateStore } from './state.ts'
|
|
22
34
|
import type { TierRoute } from './types.ts'
|
|
23
35
|
|
|
24
36
|
/** The subset of `ctx.agentDefaultModel` this module uses. */
|
|
25
37
|
interface DefaultModelService {
|
|
38
|
+
/** Read the currently documented selection. */
|
|
39
|
+
currentSelection(): { provider: string; model: string; reasoningEffort?: string }
|
|
26
40
|
saveSelection(next: { provider: string; model: string; reasoningEffort?: string }): Promise<void>
|
|
27
41
|
}
|
|
28
42
|
|
|
@@ -61,15 +75,6 @@ export class SelectionSync {
|
|
|
61
75
|
constructor(options: SelectionSyncOptions) {
|
|
62
76
|
this.ctx = options.ctx
|
|
63
77
|
this.states = options.states
|
|
64
|
-
this.ctx.on('settings/updated', (ns, next) => {
|
|
65
|
-
if (String(ns) !== 'agent-default-model') return
|
|
66
|
-
const key = selectionKey(next)
|
|
67
|
-
if (this.selfWrite === key) {
|
|
68
|
-
this.selfWrite = undefined
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
this.delegateLiveSessions(key)
|
|
72
|
-
})
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
/** The optional default-model service. */
|
|
@@ -78,13 +83,42 @@ export class SelectionSync {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
86
|
+
* Read the documented selection and delegate every live session when it is
|
|
87
|
+
* no longer the value this module wrote.
|
|
88
|
+
*
|
|
89
|
+
* Before this module's first write nothing is delegated, whatever the
|
|
90
|
+
* document holds: at that point "not ours" describes the composition default
|
|
91
|
+
* and any selection the user made before the plugin ever routed, and neither
|
|
92
|
+
* is an override of a routing decision. Once this module owns a write, any
|
|
93
|
+
* divergence is by construction somebody else's edit.
|
|
94
|
+
*/
|
|
95
|
+
observeExternalSelection(): void {
|
|
96
|
+
if (this.selfWrite === undefined) return
|
|
97
|
+
const service = this.defaultModel
|
|
98
|
+
if (service === undefined) return
|
|
99
|
+
let current: unknown
|
|
100
|
+
try {
|
|
101
|
+
current = service.currentSelection()
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.ctx.logger.debug('dsh-autotier: could not read the default-model selection: %o', error)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const key = selectionKey(current)
|
|
107
|
+
if (key === this.selfWrite) return
|
|
108
|
+
this.delegateLiveSessions(key)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mirror one applied landing into the default-model document. An external
|
|
113
|
+
* change to that document is honoured first, so routing never overwrites the
|
|
114
|
+
* model the user picked. Repeated landings with the same value are skipped,
|
|
115
|
+
* so a long cheap run writes once.
|
|
83
116
|
* @param route - the landing actually applied.
|
|
84
117
|
*/
|
|
85
118
|
noteRoute(route: TierRoute): void {
|
|
86
119
|
const service = this.defaultModel
|
|
87
120
|
if (service === undefined) return
|
|
121
|
+
this.observeExternalSelection()
|
|
88
122
|
const selection = {
|
|
89
123
|
provider: route.provider,
|
|
90
124
|
model: route.model,
|
package/src/service.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
12
|
-
import type {
|
|
13
|
-
import { resolveConfig, type Config, type ResolvedConfig } from './config.ts'
|
|
12
|
+
import type { ResolvedConfig } from './config.ts'
|
|
14
13
|
import { compileRules, PosteriorTable, type CompiledRule } from './intent.ts'
|
|
15
14
|
import type { AutotierStatus, EffortId, TierRoute } from './types.ts'
|
|
16
15
|
|
|
@@ -23,9 +22,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
23
22
|
|
|
24
23
|
/** Dependencies the service needs from the plugin's `apply`. */
|
|
25
24
|
export interface AutotierServiceOptions {
|
|
26
|
-
/** The
|
|
27
|
-
scope: SettingsScope<Config>
|
|
28
|
-
/** The configuration resolved at mount time (the composition base layer). */
|
|
25
|
+
/** The configuration resolved at mount time (the composition layer). */
|
|
29
26
|
config: ResolvedConfig
|
|
30
27
|
}
|
|
31
28
|
|
|
@@ -40,30 +37,35 @@ function routeOf(tier: { provider: string; model: string; effort: EffortId; foll
|
|
|
40
37
|
* plugin unloading removes the service with every listener it owns.
|
|
41
38
|
*/
|
|
42
39
|
export class AutotierService extends Service {
|
|
43
|
-
private readonly scope: SettingsScope<Config>
|
|
44
40
|
private readonly posteriorTable = new PosteriorTable()
|
|
45
41
|
private resolved: ResolvedConfig
|
|
46
42
|
private compiled: CompiledRule[]
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
* Register the service as `ctx.autotier
|
|
50
|
-
* namespace.
|
|
45
|
+
* Register the service as `ctx.autotier`.
|
|
51
46
|
* @param ctx - the owning plugin context.
|
|
52
|
-
* @param options - the
|
|
47
|
+
* @param options - the mount-time configuration.
|
|
53
48
|
*/
|
|
54
49
|
constructor(ctx: Context, options: AutotierServiceOptions) {
|
|
55
50
|
super(ctx, 'autotier')
|
|
56
|
-
this.scope = options.scope
|
|
57
51
|
this.resolved = options.config
|
|
58
52
|
this.compiled = compileRules(options.config.intent.rules)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Adopt a configuration a live settings write produced. The caller has
|
|
57
|
+
* already judged it through {@link resolveConfig}, so this only swaps the
|
|
58
|
+
* policy and recompiles the rule table.
|
|
59
|
+
*
|
|
60
|
+
* The whole policy is replaced, never merged: a settings form submits the
|
|
61
|
+
* complete Config, so a field the user cleared must fall back to its schema
|
|
62
|
+
* default rather than keep the previous live value.
|
|
63
|
+
*
|
|
64
|
+
* @param config - the newly resolved configuration.
|
|
65
|
+
*/
|
|
66
|
+
reconfigure(config: ResolvedConfig): void {
|
|
67
|
+
this.resolved = config
|
|
68
|
+
this.compiled = compileRules(config.intent.rules)
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** The live resolved configuration. */
|