1
0

config.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /**
  2. * Load-time validation and routed-model policy resolution for compact-basic.
  3. *
  4. * @module @deepseek-ai/dsh-compact-basic/config
  5. */
  6. import { deepFreeze } from '@deepseek-ai/dsh-llm'
  7. import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
  8. import type {
  9. BasicCompactConfig,
  10. CompactPolicyConfig,
  11. ModelCompactPolicyConfig,
  12. ResolvedCompactSpec,
  13. ResolvedConfig,
  14. ResolvedRetention,
  15. ResolvedTargetPolicy,
  16. } from './types.ts'
  17. /** Default request-pressure fraction for every routed model. */
  18. const DEFAULT_THRESHOLD_RATIO = 0.8
  19. /** Default verbatim-tail fraction for every routed model. */
  20. const DEFAULT_RETAIN_RATIO = 0.16
  21. /** Fields shared by top-level defaults and exact-target overrides. */
  22. const POLICY_CONFIG_KEYS = [
  23. 'thresholdRatio',
  24. 'retainRatio',
  25. 'retainTokens',
  26. 'summarizationProvider',
  27. 'summarizationModel',
  28. 'maxTokens',
  29. 'compactionRetries',
  30. 'maxOverflowRetries',
  31. ] as const
  32. /** Complete public top-level configuration key set. */
  33. const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
  34. ...POLICY_CONFIG_KEYS,
  35. 'modelPolicies',
  36. 'auto',
  37. ])
  38. /** Complete exact-target override key set. */
  39. const MODEL_POLICY_KEYS: ReadonlySet<string> = new Set([
  40. 'provider',
  41. 'model',
  42. ...POLICY_CONFIG_KEYS,
  43. ])
  44. /** Target-specific pressure configuration failure eligible for warning suppression. */
  45. export class TargetPressureConfigError extends Error {
  46. /**
  47. * @param targetKey - exact provider/model route used as the warning key.
  48. * @param message - actionable configuration failure detail.
  49. */
  50. constructor(readonly targetKey: string, message: string) {
  51. super(message)
  52. }
  53. }
  54. /**
  55. * Resolve and validate service defaults plus exact-target partial overrides.
  56. * @param config - untrusted plugin configuration after Loader normalization.
  57. * @returns detached immutable defaults and validated exact-target overrides.
  58. */
  59. export function resolveConfig(config: BasicCompactConfig = {}): ResolvedConfig {
  60. validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactConfig')
  61. validatePolicy(config, 'BasicCompactConfig')
  62. if (config.auto !== undefined && typeof config.auto !== 'boolean') {
  63. throw new Error('BasicCompactConfig: auto must be a boolean')
  64. }
  65. const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
  66. const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO })
  67. validateRatioRetention(thresholdRatio, retention, 'BasicCompactConfig')
  68. const modelPolicies = resolveModelPolicies(config.modelPolicies)
  69. for (const [index, policy] of modelPolicies.entries()) {
  70. validateRatioRetention(
  71. policy.thresholdRatio ?? thresholdRatio,
  72. resolveRetention(policy, retention),
  73. `BasicCompactConfig: modelPolicies[${index}]`,
  74. )
  75. }
  76. return deepFreeze({
  77. thresholdRatio,
  78. ...retention,
  79. summarizationProvider: config.summarizationProvider ?? '',
  80. summarizationModel: config.summarizationModel ?? '',
  81. maxTokens: config.maxTokens ?? 8192,
  82. compactionRetries: config.compactionRetries ?? 1,
  83. maxOverflowRetries: config.maxOverflowRetries ?? 1,
  84. modelPolicies,
  85. auto: config.auto ?? true,
  86. })
  87. }
  88. /**
  89. * Merge the exact provider/model override over the validated default policy.
  90. * @param config - validated service defaults and override table.
  91. * @param target - exact durable provider/model route to match.
  92. * @returns detached immutable policy before model-capacity scaling.
  93. */
  94. export function resolveTargetPolicy(
  95. config: ResolvedConfig,
  96. target: Pick<LlmCallConfig, 'provider' | 'model'>,
  97. ): ResolvedTargetPolicy {
  98. const override = config.modelPolicies.find(policy => (
  99. policy.provider === target.provider && policy.model === target.model
  100. ))
  101. const inheritedRetention: ResolvedRetention = config.retainTokens === undefined
  102. ? { retainRatio: config.retainRatio }
  103. : { retainTokens: config.retainTokens }
  104. return deepFreeze({
  105. target: { provider: target.provider, model: target.model },
  106. thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio,
  107. ...resolveRetention(override ?? {}, inheritedRetention),
  108. summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider,
  109. summarizationModel: override?.summarizationModel ?? config.summarizationModel,
  110. maxTokens: override?.maxTokens ?? config.maxTokens,
  111. compactionRetries: override?.compactionRetries ?? config.compactionRetries,
  112. maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries,
  113. })
  114. }
  115. /**
  116. * Scale one routed policy into concrete token budgets for its model capacity.
  117. * @param policy - merged policy for the exact routed target.
  118. * @param contextWindow - positive adapter-owned capacity for that target.
  119. * @returns detached immutable pressure and retention budgets.
  120. */
  121. export function resolveCompactSpec(
  122. policy: ResolvedTargetPolicy,
  123. contextWindow: number,
  124. ): ResolvedCompactSpec {
  125. const targetKey = `${policy.target.provider}/${policy.target.model}`
  126. if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
  127. throw new TargetPressureConfigError(
  128. targetKey,
  129. `BasicCompactConfig: contextWindow (${contextWindow}) must be a positive integer`,
  130. )
  131. }
  132. const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio)
  133. const retainTokens = policy.retainTokens === undefined
  134. ? Math.floor(contextWindow * policy.retainRatio)
  135. : policy.retainTokens
  136. if (retainTokens >= thresholdTokens) {
  137. throw new TargetPressureConfigError(
  138. targetKey,
  139. `BasicCompactConfig: ${policy.target.provider}/${policy.target.model} retainTokens `
  140. + `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
  141. )
  142. }
  143. return deepFreeze({
  144. target: { ...policy.target },
  145. contextWindow,
  146. thresholdRatio: policy.thresholdRatio,
  147. thresholdTokens,
  148. retainTokens,
  149. summarizationProvider: policy.summarizationProvider,
  150. summarizationModel: policy.summarizationModel,
  151. maxTokens: policy.maxTokens,
  152. compactionRetries: policy.compactionRetries,
  153. maxOverflowRetries: policy.maxOverflowRetries,
  154. })
  155. }
  156. /** Choose an explicit retention form or inherit the already-resolved fallback. */
  157. function resolveRetention(
  158. config: CompactPolicyConfig,
  159. fallback: ResolvedRetention,
  160. ): ResolvedRetention {
  161. if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens }
  162. if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio }
  163. return fallback
  164. }
  165. /** Reject a capacity-independent retention conflict at plugin load. */
  166. function validateRatioRetention(
  167. thresholdRatio: number,
  168. retention: ResolvedRetention,
  169. name: string,
  170. ): void {
  171. if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) {
  172. throw new Error(
  173. `${name}: retainRatio (${retention.retainRatio}) must be less than `
  174. + `the resolved thresholdRatio (${thresholdRatio})`,
  175. )
  176. }
  177. }
  178. /** Validate, detach, and reject duplicate exact-target policies. */
  179. function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] {
  180. if (configured === undefined) return []
  181. if (!Array.isArray(configured)) {
  182. throw new Error('BasicCompactConfig: modelPolicies must be an array')
  183. }
  184. const seen = new Set<string>()
  185. return configured.map((source: unknown, index) => {
  186. const name = `BasicCompactConfig: modelPolicies[${index}]`
  187. assertModelPolicy(source, name)
  188. const key = `${source.provider}\u0000${source.model}`
  189. if (seen.has(key)) {
  190. throw new Error(
  191. `BasicCompactConfig: duplicate model policy for ${source.provider}/${source.model}`,
  192. )
  193. }
  194. seen.add(key)
  195. return { ...source }
  196. })
  197. }
  198. /** Validate one untrusted exact-target override and narrow its public type. */
  199. function assertModelPolicy(
  200. source: unknown,
  201. name: string,
  202. ): asserts source is ModelCompactPolicyConfig {
  203. if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`)
  204. validateKeys(source, MODEL_POLICY_KEYS, name)
  205. assertNonEmptyString(`${name}.provider`, source.provider)
  206. assertNonEmptyString(`${name}.model`, source.model)
  207. validatePolicy(source, name)
  208. }
  209. /** Validate the fields common to defaults and exact-target partial overrides. */
  210. function validatePolicy(
  211. config: CompactPolicyConfig | Record<string, unknown>,
  212. name: string,
  213. ): void {
  214. const thresholdRatio = config.thresholdRatio
  215. const retainRatio = config.retainRatio
  216. const retainTokens = config.retainTokens
  217. const maxTokens = config.maxTokens
  218. const compactionRetries = config.compactionRetries
  219. const maxOverflowRetries = config.maxOverflowRetries
  220. if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio)
  221. if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio)
  222. if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens)
  223. if (retainRatio !== undefined && retainTokens !== undefined) {
  224. throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`)
  225. }
  226. if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens)
  227. if (compactionRetries !== undefined) {
  228. assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries)
  229. }
  230. if (maxOverflowRetries !== undefined) {
  231. assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries)
  232. }
  233. validateSummarizationPair(config, name)
  234. }
  235. /** Require one scope to omit, clear, or replace the summarization target as a pair. */
  236. function validateSummarizationPair(
  237. config: CompactPolicyConfig | Record<string, unknown>,
  238. name: string,
  239. ): void {
  240. const provider = config.summarizationProvider
  241. const model = config.summarizationModel
  242. if (provider !== undefined && typeof provider !== 'string') {
  243. throw new Error(`${name}.summarizationProvider must be a string`)
  244. }
  245. if (model !== undefined && typeof model !== 'string') {
  246. throw new Error(`${name}.summarizationModel must be a string`)
  247. }
  248. if (provider === undefined && model === undefined) return
  249. if (provider === undefined || model === undefined
  250. || (provider.length === 0) !== (model.length === 0)) {
  251. throw new Error(
  252. `${name}: summarizationProvider and summarizationModel must be set together `
  253. + 'as an empty or non-empty pair',
  254. )
  255. }
  256. }
  257. /** Reject stale or misspelled keys before defaults can hide them. */
  258. function validateKeys(config: object, keys: ReadonlySet<string>, name: string): void {
  259. for (const key of Object.keys(config)) {
  260. if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`)
  261. }
  262. }
  263. function isUnknownRecord(value: unknown): value is Record<string, unknown> {
  264. return typeof value === 'object' && value !== null && !Array.isArray(value)
  265. }
  266. function assertNonEmptyString(name: string, value: unknown): asserts value is string {
  267. if (typeof value !== 'string' || value.length === 0) {
  268. throw new Error(`${name} must be a non-empty string`)
  269. }
  270. }
  271. function assertPositiveInteger(name: string, value: unknown): asserts value is number {
  272. if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
  273. throw new Error(`${name} (${String(value)}) must be a positive integer`)
  274. }
  275. }
  276. function assertNonNegativeInteger(name: string, value: unknown): asserts value is number {
  277. if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
  278. throw new Error(`${name} (${String(value)}) must be a non-negative integer`)
  279. }
  280. }
  281. function assertRatio(name: string, value: unknown): asserts value is number {
  282. if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
  283. throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`)
  284. }
  285. }