dsh-autotier 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/AGENTS.md +93 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +201 -0
- package/README.es.md +247 -0
- package/README.hi.md +241 -0
- package/README.md +245 -0
- package/README.pt.md +246 -0
- package/README.zh.md +221 -0
- package/SECURITY.md +55 -0
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/cordis.patch.yml +125 -0
- package/docs/preset-row.md +61 -0
- package/docs/supporting-lanes.md +45 -0
- package/lib/index.js +2848 -0
- package/lib/types/command.d.ts +17 -0
- package/lib/types/command.d.ts.map +1 -0
- package/lib/types/config.d.ts +94 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/guard-rules.d.ts +97 -0
- package/lib/types/guard-rules.d.ts.map +1 -0
- package/lib/types/guard.d.ts +70 -0
- package/lib/types/guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +60 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/intent.d.ts +179 -0
- package/lib/types/intent.d.ts.map +1 -0
- package/lib/types/judge.d.ts +50 -0
- package/lib/types/judge.d.ts.map +1 -0
- package/lib/types/policy.d.ts +109 -0
- package/lib/types/policy.d.ts.map +1 -0
- package/lib/types/routing.d.ts +135 -0
- package/lib/types/routing.d.ts.map +1 -0
- package/lib/types/schema.d.ts +134 -0
- package/lib/types/schema.d.ts.map +1 -0
- package/lib/types/service.d.ts +67 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +46 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/tiers.d.ts +103 -0
- package/lib/types/tiers.d.ts.map +1 -0
- package/lib/types/tools.d.ts +26 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/types.d.ts +96 -0
- package/lib/types/types.d.ts.map +1 -0
- package/package.json +179 -0
- package/src/command.ts +73 -0
- package/src/config.ts +358 -0
- package/src/guard-rules.ts +303 -0
- package/src/guard.ts +285 -0
- package/src/index.ts +149 -0
- package/src/intent.ts +484 -0
- package/src/judge.ts +150 -0
- package/src/policy.ts +246 -0
- package/src/routing.ts +575 -0
- package/src/schema.ts +295 -0
- package/src/service.ts +131 -0
- package/src/state.ts +134 -0
- package/src/tiers.ts +212 -0
- package/src/tools.ts +128 -0
- package/src/types.ts +120 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The raw (possibly partial) configuration surface of dsh-autotier: the
|
|
3
|
+
* Schemastery schema the Loader validates and the settings UI renders, plus the
|
|
4
|
+
* interfaces it resolves to. The judgement that turns a raw config into a
|
|
5
|
+
* resolved one lives in `config.ts`, so this module stays free of executable
|
|
6
|
+
* logic (a schema module must not mix function values into its declarations).
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-autotier/schema
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import {
|
|
13
|
+
COST_MODES,
|
|
14
|
+
EFFORT_IDS,
|
|
15
|
+
ROUTING_MODES,
|
|
16
|
+
type CostMode,
|
|
17
|
+
type EffortId,
|
|
18
|
+
type RoutingMode,
|
|
19
|
+
type Scenario,
|
|
20
|
+
} from './types.ts'
|
|
21
|
+
|
|
22
|
+
/** One fallback landing in a tier's chain (provider/model only; effort follows the target tier). */
|
|
23
|
+
export interface FallbackEntry {
|
|
24
|
+
provider?: string
|
|
25
|
+
model?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One tier's landing plus its fallback chain. */
|
|
29
|
+
export interface TierConfig {
|
|
30
|
+
provider?: string
|
|
31
|
+
model?: string
|
|
32
|
+
effort?: EffortId
|
|
33
|
+
followSession?: boolean
|
|
34
|
+
fallback?: FallbackEntry[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The image-capable landing used when a turn carries images. */
|
|
38
|
+
export interface VisionConfig {
|
|
39
|
+
provider?: string
|
|
40
|
+
model?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One declarative intent rule: highest priority match wins. */
|
|
44
|
+
export interface IntentRule {
|
|
45
|
+
id?: string
|
|
46
|
+
when?: { patterns?: string[]; tools?: string[]; cwd?: string }
|
|
47
|
+
tier?: 'cheap' | 'strong'
|
|
48
|
+
priority?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Low-confidence judge (a cheap model classifies intent only). */
|
|
52
|
+
export interface JudgeConfig {
|
|
53
|
+
enabled?: boolean
|
|
54
|
+
/** Empty = pick the first catalog model whose id contains `flash`. */
|
|
55
|
+
model?: string
|
|
56
|
+
temperature?: number
|
|
57
|
+
maxTokens?: number
|
|
58
|
+
cooldownMs?: number
|
|
59
|
+
timeoutMs?: number
|
|
60
|
+
/** Consecutive judge failures after which this turn skips the judge. */
|
|
61
|
+
unavailableSkip?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-scenario switches; a disabled scenario never routes itself. */
|
|
65
|
+
export type ScenarioToggles = { [K in Scenario]?: boolean }
|
|
66
|
+
|
|
67
|
+
/** Intent classification and arbitration. */
|
|
68
|
+
export interface IntentConfig {
|
|
69
|
+
/** Confidence at or above which the rule layer decides without the judge. */
|
|
70
|
+
ruleThreshold?: number
|
|
71
|
+
/** Attempt-first middle band; disabled until calibration lands. */
|
|
72
|
+
attemptBand?: { enabled?: boolean; tauLow?: number }
|
|
73
|
+
/** Application-side double threshold that stops tier flapping. */
|
|
74
|
+
hysteresis?: { toStrong?: number; toCheap?: number }
|
|
75
|
+
rules?: IntentRule[]
|
|
76
|
+
judge?: JudgeConfig
|
|
77
|
+
scenarios?: ScenarioToggles
|
|
78
|
+
costMode?: CostMode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** High-risk guard switches. */
|
|
82
|
+
export interface GuardConfig {
|
|
83
|
+
enabled?: boolean
|
|
84
|
+
/** Tiers whose execution the guard protects (only `cheap` is meaningful). */
|
|
85
|
+
tiers?: ('cheap')[]
|
|
86
|
+
/** Command/tool names or path prefixes that never trip the guard. */
|
|
87
|
+
whitelist?: string[]
|
|
88
|
+
/** Self-modification surfaces that force strong-tier review. */
|
|
89
|
+
protectedPaths?: string[]
|
|
90
|
+
/** Relationship with dsh-defend: `auto` audits coexistence, `none` stays silent. */
|
|
91
|
+
interopDefend?: 'auto' | 'none'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Failure escalation and TTL fallback. */
|
|
95
|
+
export interface EscalationConfig {
|
|
96
|
+
threshold?: number
|
|
97
|
+
windowMs?: number
|
|
98
|
+
ttlMs?: number
|
|
99
|
+
fallbackTtlMs?: number
|
|
100
|
+
/** Count same-signature recurrences instead of every failure. */
|
|
101
|
+
signature?: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Raw (possibly partial) plugin configuration. Every field is optional because
|
|
106
|
+
* the resolver supplies the defaults; {@link resolveConfig} turns it into the
|
|
107
|
+
* fully-resolved {@link ResolvedConfig}.
|
|
108
|
+
*/
|
|
109
|
+
export interface Config {
|
|
110
|
+
tiers?: { strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig }
|
|
111
|
+
intent?: IntentConfig
|
|
112
|
+
guard?: GuardConfig
|
|
113
|
+
escalation?: EscalationConfig
|
|
114
|
+
routingMode?: RoutingMode
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
/** The default strong tier: the catalog's quality-critical model at high effort. */
|
|
119
|
+
export const DEFAULT_STRONG = {
|
|
120
|
+
provider: 'deepseek-official',
|
|
121
|
+
model: 'deepseek-v4-pro',
|
|
122
|
+
effort: 'high' as const,
|
|
123
|
+
followSession: false,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The default cheap tier: the catalog's routine/parallel model at low effort. */
|
|
127
|
+
export const DEFAULT_CHEAP = {
|
|
128
|
+
provider: 'deepseek-official',
|
|
129
|
+
model: 'deepseek-v4-flash',
|
|
130
|
+
effort: 'low' as const,
|
|
131
|
+
followSession: true,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The default vision landing: the catalog's only image-capable model. */
|
|
135
|
+
export const DEFAULT_VISION = {
|
|
136
|
+
provider: 'deepseek-official',
|
|
137
|
+
model: 'deepseek-v4-flash-vision-exp',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One tier schema per tier, so a partially-specified tier gets the same
|
|
142
|
+
* per-field defaults as the whole-object default (a shared schema would make
|
|
143
|
+
* `Config({ tiers: { cheap: { model } } })` disagree with `resolveConfig` on
|
|
144
|
+
* `followSession` and `effort`).
|
|
145
|
+
*/
|
|
146
|
+
const strongTier = z.object({
|
|
147
|
+
provider: z.string().default(DEFAULT_STRONG.provider),
|
|
148
|
+
model: z.string().default(DEFAULT_STRONG.model),
|
|
149
|
+
// Adapter-owned vocabulary: off | low | high | max. A value outside this set
|
|
150
|
+
// is a dead configuration (every request fails with UNSUPPORTED_REASONING_EFFORT).
|
|
151
|
+
effort: z.union([...EFFORT_IDS]).default(DEFAULT_STRONG.effort),
|
|
152
|
+
followSession: z.boolean().default(DEFAULT_STRONG.followSession),
|
|
153
|
+
fallback: z.array(z.object({
|
|
154
|
+
provider: z.string().default('deepseek-official'),
|
|
155
|
+
model: z.string().default(''),
|
|
156
|
+
})).default([]),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const cheapTier = z.object({
|
|
160
|
+
provider: z.string().default(DEFAULT_CHEAP.provider),
|
|
161
|
+
model: z.string().default(DEFAULT_CHEAP.model),
|
|
162
|
+
effort: z.union([...EFFORT_IDS]).default(DEFAULT_CHEAP.effort),
|
|
163
|
+
followSession: z.boolean().default(DEFAULT_CHEAP.followSession),
|
|
164
|
+
fallback: z.array(z.object({
|
|
165
|
+
provider: z.string().default('deepseek-official'),
|
|
166
|
+
model: z.string().default(''),
|
|
167
|
+
})).default([]),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/** Schemastery schema: the loader validates and fills defaults before `apply`. */
|
|
171
|
+
export const Config: z<Config> = z.object({
|
|
172
|
+
tiers: z.object({
|
|
173
|
+
strong: strongTier.default({ ...DEFAULT_STRONG, fallback: [] }),
|
|
174
|
+
cheap: cheapTier.default({ ...DEFAULT_CHEAP, fallback: [] }),
|
|
175
|
+
vision: z.object({
|
|
176
|
+
provider: z.string().default('deepseek-official'),
|
|
177
|
+
model: z.string().default('deepseek-v4-flash-vision-exp'),
|
|
178
|
+
}).default({ ...DEFAULT_VISION }),
|
|
179
|
+
}).default({
|
|
180
|
+
strong: { ...DEFAULT_STRONG, fallback: [] },
|
|
181
|
+
cheap: { ...DEFAULT_CHEAP, fallback: [] },
|
|
182
|
+
vision: { ...DEFAULT_VISION },
|
|
183
|
+
}),
|
|
184
|
+
intent: z.object({
|
|
185
|
+
ruleThreshold: z.number().min(0.000001).max(1).default(0.7),
|
|
186
|
+
attemptBand: z.object({
|
|
187
|
+
enabled: z.boolean().default(false),
|
|
188
|
+
tauLow: z.number().min(0).max(1).default(0.45),
|
|
189
|
+
}).default({ enabled: false, tauLow: 0.45 }),
|
|
190
|
+
hysteresis: z.object({
|
|
191
|
+
toStrong: z.number().min(0).max(1).default(0.8),
|
|
192
|
+
toCheap: z.number().min(0).max(1).default(0.6),
|
|
193
|
+
}).default({ toStrong: 0.8, toCheap: 0.6 }),
|
|
194
|
+
rules: z.array(z.object({
|
|
195
|
+
id: z.string().default(''),
|
|
196
|
+
when: z.object({
|
|
197
|
+
patterns: z.array(z.string()).default([]),
|
|
198
|
+
tools: z.array(z.string()).default([]),
|
|
199
|
+
cwd: z.string().default(''),
|
|
200
|
+
}).default({ patterns: [], tools: [], cwd: '' }),
|
|
201
|
+
tier: z.union(['cheap', 'strong']).default('strong'),
|
|
202
|
+
priority: z.number().default(0),
|
|
203
|
+
})).default([]),
|
|
204
|
+
judge: z.object({
|
|
205
|
+
enabled: z.boolean().default(true),
|
|
206
|
+
model: z.string().default(''),
|
|
207
|
+
temperature: z.number().min(0).max(2).default(0),
|
|
208
|
+
maxTokens: z.number().step(1).min(1).max(4_096).default(16),
|
|
209
|
+
cooldownMs: z.number().min(0).max(3_600_000).default(30_000),
|
|
210
|
+
timeoutMs: z.number().min(1).max(120_000).default(2_000),
|
|
211
|
+
unavailableSkip: z.number().step(1).min(0).max(100).default(2),
|
|
212
|
+
}).default({
|
|
213
|
+
enabled: true,
|
|
214
|
+
model: '',
|
|
215
|
+
temperature: 0,
|
|
216
|
+
maxTokens: 16,
|
|
217
|
+
cooldownMs: 30_000,
|
|
218
|
+
timeoutMs: 2_000,
|
|
219
|
+
unavailableSkip: 2,
|
|
220
|
+
}),
|
|
221
|
+
scenarios: z.object({
|
|
222
|
+
coding: z.boolean().default(true),
|
|
223
|
+
review: z.boolean().default(true),
|
|
224
|
+
planning: z.boolean().default(true),
|
|
225
|
+
retrieval: z.boolean().default(true),
|
|
226
|
+
batch: z.boolean().default(true),
|
|
227
|
+
daily: z.boolean().default(true),
|
|
228
|
+
longText: z.boolean().default(true),
|
|
229
|
+
multimodal: z.boolean().default(true),
|
|
230
|
+
}).default({
|
|
231
|
+
coding: true,
|
|
232
|
+
review: true,
|
|
233
|
+
planning: true,
|
|
234
|
+
retrieval: true,
|
|
235
|
+
batch: true,
|
|
236
|
+
daily: true,
|
|
237
|
+
longText: true,
|
|
238
|
+
multimodal: true,
|
|
239
|
+
}),
|
|
240
|
+
costMode: z.union([...COST_MODES]).default('balanced'),
|
|
241
|
+
}).default({
|
|
242
|
+
ruleThreshold: 0.7,
|
|
243
|
+
attemptBand: { enabled: false, tauLow: 0.45 },
|
|
244
|
+
hysteresis: { toStrong: 0.8, toCheap: 0.6 },
|
|
245
|
+
rules: [],
|
|
246
|
+
judge: {
|
|
247
|
+
enabled: true,
|
|
248
|
+
model: '',
|
|
249
|
+
temperature: 0,
|
|
250
|
+
maxTokens: 16,
|
|
251
|
+
cooldownMs: 30_000,
|
|
252
|
+
timeoutMs: 2_000,
|
|
253
|
+
unavailableSkip: 2,
|
|
254
|
+
},
|
|
255
|
+
scenarios: {
|
|
256
|
+
coding: true,
|
|
257
|
+
review: true,
|
|
258
|
+
planning: true,
|
|
259
|
+
retrieval: true,
|
|
260
|
+
batch: true,
|
|
261
|
+
daily: true,
|
|
262
|
+
longText: true,
|
|
263
|
+
multimodal: true,
|
|
264
|
+
},
|
|
265
|
+
costMode: 'balanced',
|
|
266
|
+
}),
|
|
267
|
+
guard: z.object({
|
|
268
|
+
enabled: z.boolean().default(true),
|
|
269
|
+
tiers: z.array(z.union(['cheap'])).default(['cheap']),
|
|
270
|
+
whitelist: z.array(z.string()).default([]),
|
|
271
|
+
protectedPaths: z.array(z.string()).default(['.dsh', 'AGENTS.md', 'package.json', '.github/workflows']),
|
|
272
|
+
interopDefend: z.union(['auto', 'none']).default('auto'),
|
|
273
|
+
}).default({
|
|
274
|
+
enabled: true,
|
|
275
|
+
tiers: ['cheap'],
|
|
276
|
+
whitelist: [],
|
|
277
|
+
protectedPaths: ['.dsh', 'AGENTS.md', 'package.json', '.github/workflows'],
|
|
278
|
+
interopDefend: 'auto',
|
|
279
|
+
}),
|
|
280
|
+
escalation: z.object({
|
|
281
|
+
threshold: z.number().step(1).min(1).max(100).default(2),
|
|
282
|
+
windowMs: z.number().min(1).max(86_400_000).default(60_000),
|
|
283
|
+
ttlMs: z.number().min(1).max(86_400_000).default(180_000),
|
|
284
|
+
fallbackTtlMs: z.number().min(1).max(86_400_000).default(300_000),
|
|
285
|
+
signature: z.boolean().default(true),
|
|
286
|
+
}).default({
|
|
287
|
+
threshold: 2,
|
|
288
|
+
windowMs: 60_000,
|
|
289
|
+
ttlMs: 180_000,
|
|
290
|
+
fallbackTtlMs: 300_000,
|
|
291
|
+
signature: true,
|
|
292
|
+
}),
|
|
293
|
+
routingMode: z.union([...ROUTING_MODES]).default('auto'),
|
|
294
|
+
})
|
|
295
|
+
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.autotier`: the Service Provider for autotier's public read surface. The
|
|
3
|
+
* service owns the live resolved configuration, the compiled rule table and the
|
|
4
|
+
* fingerprint posteriors, and serves the status snapshot that `/tier status`,
|
|
5
|
+
* the `tier_status` tool and any third-party consumer read. Routing decisions
|
|
6
|
+
* themselves live in `policy.ts`/`routing.ts`; this class is the stable contract
|
|
7
|
+
* other plugins may depend on.
|
|
8
|
+
* @module dsh-autotier/service
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
13
|
+
import { resolveConfig, type Config, type ResolvedConfig } from './config.ts'
|
|
14
|
+
import { compileRules, PosteriorTable, type CompiledRule } from './intent.ts'
|
|
15
|
+
import type { AutotierStatus, EffortId, TierRoute } from './types.ts'
|
|
16
|
+
|
|
17
|
+
declare module '@deepseek-ai/cordis' {
|
|
18
|
+
interface Context {
|
|
19
|
+
/** The autotier routing service (absent when the plugin is not composed). */
|
|
20
|
+
autotier: AutotierService
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Dependencies the service needs from the plugin's `apply`. */
|
|
25
|
+
export interface AutotierServiceOptions {
|
|
26
|
+
/** The live settings scope; its value is re-resolved on every committed change. */
|
|
27
|
+
scope: SettingsScope<Config>
|
|
28
|
+
/** The configuration resolved at mount time (the composition base layer). */
|
|
29
|
+
config: ResolvedConfig
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Project one resolved tier config onto the public route shape. */
|
|
33
|
+
function routeOf(tier: { provider: string; model: string; effort: EffortId; followSession: boolean }): TierRoute {
|
|
34
|
+
if (tier.followSession) return { provider: tier.provider, model: tier.model }
|
|
35
|
+
return { provider: tier.provider, model: tier.model, effort: tier.effort }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Service Provider for `ctx.autotier`. Registration rides the owning fiber: the
|
|
40
|
+
* plugin unloading removes the service with every listener it owns.
|
|
41
|
+
*/
|
|
42
|
+
export class AutotierService extends Service {
|
|
43
|
+
private readonly scope: SettingsScope<Config>
|
|
44
|
+
private readonly posteriorTable = new PosteriorTable()
|
|
45
|
+
private resolved: ResolvedConfig
|
|
46
|
+
private compiled: CompiledRule[]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register the service as `ctx.autotier` and start following the settings
|
|
50
|
+
* namespace.
|
|
51
|
+
* @param ctx - the owning plugin context.
|
|
52
|
+
* @param options - the live settings scope and the mount-time configuration.
|
|
53
|
+
*/
|
|
54
|
+
constructor(ctx: Context, options: AutotierServiceOptions) {
|
|
55
|
+
super(ctx, 'autotier')
|
|
56
|
+
this.scope = options.scope
|
|
57
|
+
this.resolved = options.config
|
|
58
|
+
this.compiled = compileRules(options.config.intent.rules)
|
|
59
|
+
ctx.effect(() => this.scope.watch((next) => {
|
|
60
|
+
// A committed settings write replaces the whole resolved policy. A value
|
|
61
|
+
// the schema accepted but the cross-field judge rejects keeps the last
|
|
62
|
+
// good policy: settings.register's validate hook already refused the
|
|
63
|
+
// write, so reaching here with an invalid value is impossible.
|
|
64
|
+
this.resolved = resolveConfig(next)
|
|
65
|
+
this.compiled = compileRules(this.resolved.intent.rules)
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The live resolved configuration. */
|
|
70
|
+
config(): ResolvedConfig {
|
|
71
|
+
return this.resolved
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The compiled declarative rule table, ordered by descending priority. */
|
|
75
|
+
rules(): readonly CompiledRule[] {
|
|
76
|
+
return this.compiled
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The per-fingerprint win-rate posteriors. */
|
|
80
|
+
posteriors(): PosteriorTable {
|
|
81
|
+
return this.posteriorTable
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The registered provider/model catalog, as the minimal serializable subset a
|
|
86
|
+
* configuration UI needs. Never hardcoded: it reads the live `ctx.llm`
|
|
87
|
+
* registry, so a model the adapter does not advertise cannot be selected.
|
|
88
|
+
* @returns one entry per registered provider with its models.
|
|
89
|
+
*/
|
|
90
|
+
async catalog(): Promise<{ provider: string; models: { id: string; name: string; inputModalities: readonly string[] }[] }[]> {
|
|
91
|
+
const entries: { provider: string; models: { id: string; name: string; inputModalities: readonly string[] }[] }[] = []
|
|
92
|
+
for (const provider of this.ctx.llm.listProviders()) {
|
|
93
|
+
let models: readonly { id: string; name: string; inputModalities?: readonly string[] }[] = []
|
|
94
|
+
try {
|
|
95
|
+
models = await this.ctx.llm.listModels(provider.id)
|
|
96
|
+
} catch {
|
|
97
|
+
models = []
|
|
98
|
+
}
|
|
99
|
+
entries.push({
|
|
100
|
+
provider: provider.id,
|
|
101
|
+
models: models.map(model => ({
|
|
102
|
+
id: model.id,
|
|
103
|
+
name: model.name,
|
|
104
|
+
inputModalities: model.inputModalities ?? ['text'],
|
|
105
|
+
})),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return entries
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Read-only status snapshot. */
|
|
112
|
+
status(): AutotierStatus {
|
|
113
|
+
const config = this.resolved
|
|
114
|
+
return {
|
|
115
|
+
mode: config.routingMode,
|
|
116
|
+
tiers: {
|
|
117
|
+
strong: routeOf(config.tiers.strong),
|
|
118
|
+
cheap: routeOf(config.tiers.cheap),
|
|
119
|
+
vision: { provider: config.tiers.vision.provider, model: config.tiers.vision.model },
|
|
120
|
+
},
|
|
121
|
+
guard: { enabled: config.guard.enabled, tiers: config.guard.tiers },
|
|
122
|
+
escalation: {
|
|
123
|
+
threshold: config.escalation.threshold,
|
|
124
|
+
windowMs: config.escalation.windowMs,
|
|
125
|
+
ttlMs: config.escalation.ttlMs,
|
|
126
|
+
fallbackTtlMs: config.escalation.fallbackTtlMs,
|
|
127
|
+
signature: config.escalation.signature,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent routing state and the replayable tier projection.
|
|
3
|
+
*
|
|
4
|
+
* Two different kinds of state live here, on purpose:
|
|
5
|
+
*
|
|
6
|
+
* - **Runtime state** (escalation counters, fallback position, judge cooldown,
|
|
7
|
+
* the per-input decision cache, the hysteresis anchor) is mutable and NOT
|
|
8
|
+
* derivable from the session log, so it lives in a `WeakMap<Agent, RouteState>`.
|
|
9
|
+
* `ctx.sessionProjections` is a read-only fold registry — it exposes
|
|
10
|
+
* `register`/`stateOf`/`snapshot` and has no setter — so runtime state cannot
|
|
11
|
+
* live there; the earlier design note that said otherwise is corrected here.
|
|
12
|
+
* - **Derived state** (which route the last request actually used, and whether
|
|
13
|
+
* plan mode is active) IS a pure fold over `request/header` and `plan/mode`,
|
|
14
|
+
* so it is registered as a host+wire projection key. That makes the effective
|
|
15
|
+
* tier replayable from the log and visible to clients.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-autotier/state
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
22
|
+
// Type-only import: pulls the `plan/mode` SessionEventMap augmentation the host
|
|
23
|
+
// plan-mode package declares. Erased at runtime, so the peer stays optional.
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-plan-mode'
|
|
25
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
26
|
+
import { createRouteState, type RouteState } from './policy.ts'
|
|
27
|
+
|
|
28
|
+
/** The projection key autotier owns. */
|
|
29
|
+
export const TIER_PROJECTION_KEY = 'autotier'
|
|
30
|
+
|
|
31
|
+
/** Host fold state for the tier projection (plain JSON by contract). */
|
|
32
|
+
export interface TierProjectionState {
|
|
33
|
+
provider: string
|
|
34
|
+
model: string
|
|
35
|
+
/** Empty string = the route carries no explicit effort. */
|
|
36
|
+
effort: string
|
|
37
|
+
plan: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The subset of `ctx.sessionProjections` this plugin uses. Typed structurally
|
|
42
|
+
* on purpose: the service is an optional peer, so the plugin must not depend on
|
|
43
|
+
* its declaration-merged key table at type level (a consumer without the peer
|
|
44
|
+
* would fail to type-check). The runtime contract is the same.
|
|
45
|
+
*/
|
|
46
|
+
interface ProjectionRegistryLike {
|
|
47
|
+
register(definition: {
|
|
48
|
+
key: string
|
|
49
|
+
stateVersion: number
|
|
50
|
+
stateSchema: { parse(value: unknown): unknown }
|
|
51
|
+
init: (header: unknown, inheritedEventCount: number) => TierProjectionState
|
|
52
|
+
apply: (state: TierProjectionState, event: SessionEvent) => TierProjectionState
|
|
53
|
+
wire: {
|
|
54
|
+
viewSchema: { parse(value: unknown): unknown }
|
|
55
|
+
view(state: TierProjectionState): TierProjectionState
|
|
56
|
+
}
|
|
57
|
+
}): () => void
|
|
58
|
+
stateOf(session: Session, key: string): TierProjectionState | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Minimal zod-compatible schemas for the projection state and wire view. */
|
|
62
|
+
const projectionSchema = {
|
|
63
|
+
parse(value: unknown): TierProjectionState {
|
|
64
|
+
const record = (value ?? {}) as Record<string, unknown>
|
|
65
|
+
return {
|
|
66
|
+
provider: typeof record.provider === 'string' ? record.provider : '',
|
|
67
|
+
model: typeof record.model === 'string' ? record.model : '',
|
|
68
|
+
effort: typeof record.effort === 'string' ? record.effort : '',
|
|
69
|
+
plan: record.plan === true,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register the tier projection when the registry is composed.
|
|
76
|
+
* @param ctx - the plugin context; the registration rides its fiber.
|
|
77
|
+
* @returns the registration disposer, or undefined when the registry is absent.
|
|
78
|
+
*/
|
|
79
|
+
export function registerTierProjection(ctx: Context): (() => void) | undefined {
|
|
80
|
+
const registry = ctx.get('sessionProjections') as unknown as ProjectionRegistryLike | undefined
|
|
81
|
+
if (registry === undefined) {
|
|
82
|
+
ctx.logger.warn('dsh-autotier: sessionProjections is absent; the replayable tier projection is disabled')
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
return registry.register({
|
|
86
|
+
key: TIER_PROJECTION_KEY,
|
|
87
|
+
stateVersion: 1,
|
|
88
|
+
stateSchema: projectionSchema,
|
|
89
|
+
init: () => ({ provider: '', model: '', effort: '', plan: false }),
|
|
90
|
+
apply: (state, event) => {
|
|
91
|
+
if (event.type === 'plan/mode') {
|
|
92
|
+
const plan = (event.data as { active?: unknown }).active === true
|
|
93
|
+
return plan === state.plan ? state : { ...state, plan }
|
|
94
|
+
}
|
|
95
|
+
if (event.type !== 'request/header') return state
|
|
96
|
+
const config = (event.data as { header?: { config?: { provider?: unknown; model?: unknown; reasoningEffort?: unknown } } })
|
|
97
|
+
.header?.config
|
|
98
|
+
if (config === undefined) return state
|
|
99
|
+
const next: TierProjectionState = {
|
|
100
|
+
provider: typeof config.provider === 'string' ? config.provider : '',
|
|
101
|
+
model: typeof config.model === 'string' ? config.model : '',
|
|
102
|
+
effort: typeof config.reasoningEffort === 'string' ? config.reasoningEffort : '',
|
|
103
|
+
plan: state.plan,
|
|
104
|
+
}
|
|
105
|
+
const same = next.provider === state.provider && next.model === state.model
|
|
106
|
+
&& next.effort === state.effort && next.plan === state.plan
|
|
107
|
+
return same ? state : next
|
|
108
|
+
},
|
|
109
|
+
wire: {
|
|
110
|
+
viewSchema: projectionSchema,
|
|
111
|
+
view: state => state,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The per-agent runtime state store. */
|
|
117
|
+
export class AgentStateStore {
|
|
118
|
+
private readonly states = new WeakMap<Agent, RouteState>()
|
|
119
|
+
|
|
120
|
+
/** The state for one agent, created on first use. */
|
|
121
|
+
for(agent: Agent): RouteState {
|
|
122
|
+
let state = this.states.get(agent)
|
|
123
|
+
if (state === undefined) {
|
|
124
|
+
state = createRouteState()
|
|
125
|
+
this.states.set(agent, state)
|
|
126
|
+
}
|
|
127
|
+
return state
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether one agent already has state (diagnostics). */
|
|
131
|
+
has(agent: Agent): boolean {
|
|
132
|
+
return this.states.has(agent)
|
|
133
|
+
}
|
|
134
|
+
}
|