fix: validate compaction config pairs
This commit is contained in:
@@ -1059,7 +1059,7 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti
|
|||||||
|
|
||||||
```ts config-catalog
|
```ts config-catalog
|
||||||
/** Token-meter plugin configuration; the fixed estimator has no settings. */
|
/** Token-meter plugin configuration; the fixed estimator has no settings. */
|
||||||
export type TokenMeterConfig = object
|
export type TokenMeterConfig = Record<string, never>
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts)
|
Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts)
|
||||||
|
|||||||
@@ -248,17 +248,29 @@ function validatePolicy(
|
|||||||
assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries)
|
assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries)
|
||||||
}
|
}
|
||||||
|
|
||||||
const summarizationProvider = config.summarizationProvider
|
validateSummarizationPair(config, name)
|
||||||
const summarizationModel = config.summarizationModel
|
}
|
||||||
if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') {
|
|
||||||
|
/** Require one scope to omit, clear, or replace the summarization target as a pair. */
|
||||||
|
function validateSummarizationPair(
|
||||||
|
config: CompactPolicyConfig | Record<string, unknown>,
|
||||||
|
name: string,
|
||||||
|
): void {
|
||||||
|
const provider = config.summarizationProvider
|
||||||
|
const model = config.summarizationModel
|
||||||
|
if (provider !== undefined && typeof provider !== 'string') {
|
||||||
throw new Error(`${name}.summarizationProvider must be a string`)
|
throw new Error(`${name}.summarizationProvider must be a string`)
|
||||||
}
|
}
|
||||||
if (summarizationModel !== undefined && typeof summarizationModel !== 'string') {
|
if (model !== undefined && typeof model !== 'string') {
|
||||||
throw new Error(`${name}.summarizationModel must be a string`)
|
throw new Error(`${name}.summarizationModel must be a string`)
|
||||||
}
|
}
|
||||||
if (((summarizationProvider ?? '').length === 0)
|
if (provider === undefined && model === undefined) return
|
||||||
!== ((summarizationModel ?? '').length === 0)) {
|
if (provider === undefined || model === undefined
|
||||||
throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`)
|
|| (provider.length === 0) !== (model.length === 0)) {
|
||||||
|
throw new Error(
|
||||||
|
`${name}: summarizationProvider and summarizationModel must be set together `
|
||||||
|
+ 'as an empty or non-empty pair',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,41 @@ describe('compact configuration and defaults', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('inherits, clears, and replaces the summarization target as a pair', () => {
|
||||||
|
const config = resolveConfig({
|
||||||
|
summarizationProvider: 'default-provider',
|
||||||
|
summarizationModel: 'default-model',
|
||||||
|
modelPolicies: [
|
||||||
|
{ provider: 'inherit-provider', model: MODEL },
|
||||||
|
{
|
||||||
|
provider: 'clear-provider',
|
||||||
|
model: MODEL,
|
||||||
|
summarizationProvider: '',
|
||||||
|
summarizationModel: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'replace-provider',
|
||||||
|
model: MODEL,
|
||||||
|
summarizationProvider: 'replacement-provider',
|
||||||
|
summarizationModel: 'replacement-model',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL }))
|
||||||
|
.toMatchObject({
|
||||||
|
summarizationProvider: 'default-provider',
|
||||||
|
summarizationModel: 'default-model',
|
||||||
|
})
|
||||||
|
expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL }))
|
||||||
|
.toMatchObject({ summarizationProvider: '', summarizationModel: '' })
|
||||||
|
expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL }))
|
||||||
|
.toMatchObject({
|
||||||
|
summarizationProvider: 'replacement-provider',
|
||||||
|
summarizationModel: 'replacement-model',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('validates common values and pressure-policy invariants', () => {
|
it('validates common values and pressure-policy invariants', () => {
|
||||||
const bad = [
|
const bad = [
|
||||||
[{ maxTokens: 0 }, /maxTokens/],
|
[{ maxTokens: 0 }, /maxTokens/],
|
||||||
@@ -316,8 +351,10 @@ describe('compact configuration and defaults', () => {
|
|||||||
[{ auto: 'yes' }, /auto must be a boolean/],
|
[{ auto: 'yes' }, /auto must be a boolean/],
|
||||||
[{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
|
[{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
|
||||||
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
||||||
[{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/],
|
[{ summarizationProvider: MODEL }, /must be set together/],
|
||||||
[{ summarizationModel: MODEL }, /must both be empty or both be non-empty/],
|
[{ summarizationModel: MODEL }, /must be set together/],
|
||||||
|
[{ summarizationProvider: '' }, /must be set together/],
|
||||||
|
[{ summarizationModel: '' }, /must be set together/],
|
||||||
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
||||||
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
||||||
[{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/],
|
[{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/],
|
||||||
@@ -333,6 +370,16 @@ describe('compact configuration and defaults', () => {
|
|||||||
[{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/],
|
[{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/],
|
||||||
[{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/],
|
[{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/],
|
||||||
[{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/],
|
[{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/],
|
||||||
|
[{
|
||||||
|
summarizationProvider: 'default-provider',
|
||||||
|
summarizationModel: 'default-model',
|
||||||
|
modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }],
|
||||||
|
}, /modelPolicies\[0\].*must be set together/],
|
||||||
|
[{
|
||||||
|
summarizationProvider: 'default-provider',
|
||||||
|
summarizationModel: 'default-model',
|
||||||
|
modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }],
|
||||||
|
}, /modelPolicies\[0\].*must be set together/],
|
||||||
[{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/],
|
[{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/],
|
||||||
[
|
[
|
||||||
{ modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] },
|
{ modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] },
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
|||||||
auto: true,
|
auto: true,
|
||||||
thresholdRatio: 0.5,
|
thresholdRatio: 0.5,
|
||||||
retainTokens: 50,
|
retainTokens: 50,
|
||||||
summarizationModel: '',
|
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
compactionRetries: 1,
|
compactionRetries: 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('real Loader composition', () => {
|
|||||||
context = new Context()
|
context = new Context()
|
||||||
await expect(context.plugin(TokenMeterService, {
|
await expect(context.plugin(TokenMeterService, {
|
||||||
contextWindow: 4096,
|
contextWindow: 4096,
|
||||||
})).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/)
|
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||||
@@ -110,4 +110,19 @@ describe('real Loader composition', () => {
|
|||||||
}],
|
}],
|
||||||
})).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/)
|
})).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
|
||||||
|
context = new Context()
|
||||||
|
await context.plugin(LlmService)
|
||||||
|
await context.plugin(TokenMeterService)
|
||||||
|
await expect(context.plugin(BasicCompactService, {
|
||||||
|
summarizationProvider: 'default-provider',
|
||||||
|
summarizationModel: 'default-model',
|
||||||
|
modelPolicies: [{
|
||||||
|
provider: 'test-provider',
|
||||||
|
model: 'test-model',
|
||||||
|
summarizationModel: '',
|
||||||
|
}],
|
||||||
|
})).rejects.toThrow(/modelPolicies\[0\].*must be set together/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ declare module 'cordis' {
|
|||||||
|
|
||||||
/** Replay owner for one service-wide estimator and isolated per-session folds. */
|
/** Replay owner for one service-wide estimator and isolated per-session folds. */
|
||||||
export class TokenMeterService extends Service {
|
export class TokenMeterService extends Service {
|
||||||
static Config: z<TokenMeterConfig> = z.object({})
|
// Schemastery preserves untrusted loader keys on an empty object schema;
|
||||||
|
// the public type excludes settings while validateConfigKeys rejects them.
|
||||||
|
static Config: z<TokenMeterConfig> = z.object({}) as unknown as z<TokenMeterConfig>
|
||||||
|
|
||||||
private readonly states = new WeakMap<Session, ReplayState>()
|
private readonly states = new WeakMap<Session, ReplayState>()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
/** Token-meter plugin configuration; the fixed estimator has no settings. */
|
/** Token-meter plugin configuration; the fixed estimator has no settings. */
|
||||||
export type TokenMeterConfig = object
|
export type TokenMeterConfig = Record<string, never>
|
||||||
|
|
||||||
/** The baseline from which a signed surface delta produces current pressure. */
|
/** The baseline from which a signed surface delta produces current pressure. */
|
||||||
export type TokenMeasurementBaseline =
|
export type TokenMeasurementBaseline =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||||
@@ -87,10 +87,15 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TokenMeterService configuration and registration', () => {
|
describe('TokenMeterService configuration and registration', () => {
|
||||||
|
it('exposes an empty public configuration type', () => {
|
||||||
|
expectTypeOf<{}>().toExtend<TokenMeterConfig>()
|
||||||
|
expectTypeOf<{ contextWindow: number }>().not.toExtend<TokenMeterConfig>()
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['models', 'contextWindow', 'contextWidow'])(
|
it.each(['models', 'contextWindow', 'contextWidow'])(
|
||||||
'rejects stale or unknown top-level config key %s',
|
'rejects stale or unknown top-level config key %s',
|
||||||
(key) => {
|
(key) => {
|
||||||
expect(() => meter({ [key]: {} }))
|
expect(() => meter({ [key]: {} } as unknown as TokenMeterConfig))
|
||||||
.toThrow(`TokenMeterConfig: unknown key "${key}"`)
|
.toThrow(`TokenMeterConfig: unknown key "${key}"`)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user