Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@
"pattern": "^\\d+(?:\\.\\d+)?%$"
}
]
},
"modelLimits": {
"description": "Model-specific context limits with optional wildcard patterns (exact match first, then most specific wildcard). Examples: \"openai/gpt-5\", \"*/zen-1\", \"ollama/*\", \"*sonnet*\"",
"type": "object",
"additionalProperties": {
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^\\d+(?:\\.\\d+)?%$"
}
]
}
}
}
},
Expand Down
43 changes: 38 additions & 5 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ToolSettings {
nudgeFrequency: number
protectedTools: string[]
contextLimit: number | `${number}%`
modelLimits?: Record<string, number | `${number}%`>
}

export interface Tools {
Expand Down Expand Up @@ -107,6 +108,7 @@ export const VALID_CONFIG_KEYS = new Set([
"tools.settings.nudgeFrequency",
"tools.settings.protectedTools",
"tools.settings.contextLimit",
"tools.settings.modelLimits",
"tools.distill",
"tools.distill.permission",
"tools.distill.showDistillation",
Expand Down Expand Up @@ -136,6 +138,12 @@ function getConfigKeyPaths(obj: Record<string, any>, prefix = ""): string[] {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
keys.push(fullKey)

// modelLimits is a dynamic map keyed by model ID; do not recurse into arbitrary IDs.
if (fullKey === "tools.settings.modelLimits") {
continue
}

if (obj[key] && typeof obj[key] === "object" && !Array.isArray(obj[key])) {
keys.push(...getConfigKeyPaths(obj[key], fullKey))
}
Expand All @@ -156,7 +164,7 @@ interface ValidationError {
actual: string
}

function validateConfigTypes(config: Record<string, any>): ValidationError[] {
export function validateConfigTypes(config: Record<string, any>): ValidationError[] {
const errors: ValidationError[] = []

// Top-level validators
Expand Down Expand Up @@ -303,9 +311,32 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
})
}
}
}
if (tools.distill) {
if (tools.distill.permission !== undefined) {
if (tools.settings.modelLimits !== undefined) {
if (
typeof tools.settings.modelLimits !== "object" ||
Array.isArray(tools.settings.modelLimits)
) {
errors.push({
key: "tools.settings.modelLimits",
expected: "Record<string, number | ${number}%>",
actual: typeof tools.settings.modelLimits,
})
} else {
for (const [modelId, limit] of Object.entries(tools.settings.modelLimits)) {
const isValidNumber = typeof limit === "number"
const isPercentString =
typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit)
if (!isValidNumber && !isPercentString) {
errors.push({
key: `tools.settings.modelLimits.${modelId}`,
expected: 'number | "${number}%"',
actual: JSON.stringify(limit),
})
}
}
}
}
if (tools.distill?.permission !== undefined) {
const validValues = ["ask", "allow", "deny"]
if (!validValues.includes(tools.distill.permission)) {
errors.push({
Expand All @@ -316,7 +347,7 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
}
}
if (
tools.distill.showDistillation !== undefined &&
tools.distill?.showDistillation !== undefined &&
typeof tools.distill.showDistillation !== "boolean"
) {
errors.push({
Expand Down Expand Up @@ -684,6 +715,7 @@ function mergeTools(
]),
],
contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit,
modelLimits: override.settings?.modelLimits ?? base.settings.modelLimits,
},
distill: {
permission: override.distill?.permission ?? base.distill.permission,
Expand Down Expand Up @@ -724,6 +756,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
settings: {
...config.tools.settings,
protectedTools: [...config.tools.settings.protectedTools],
modelLimits: { ...config.tools.settings.modelLimits },
},
distill: { ...config.tools.distill },
compress: { ...config.tools.compress },
Expand Down
78 changes: 70 additions & 8 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,48 @@ function parsePercentageString(value: string, total: number): number | undefined
return Math.round((clampedPercent / 100) * total)
}

const escapeRegex = (value: string): string => {
return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
}

const wildcardPatternToRegex = (pattern: string): RegExp => {
const escapedPattern = escapeRegex(pattern)
const regexPattern = escapedPattern.replace(/\*/g, ".*")
return new RegExp(`^${regexPattern}$`)
}

const wildcardSpecificity = (pattern: string): number => {
return pattern.replace(/\*/g, "").length
}

export const findModelLimit = (
modelId: string,
modelLimits: Record<string, number | `${number}%`>,
): number | `${number}%` | undefined => {
const exactMatch = modelLimits[modelId]
if (exactMatch !== undefined) {
return exactMatch
}

const wildcardMatches = Object.entries(modelLimits)
.filter(([pattern]) => pattern.includes("*"))
.filter(([pattern]) => wildcardPatternToRegex(pattern).test(modelId))

if (wildcardMatches.length === 0) {
return undefined
}

wildcardMatches.sort(([leftPattern], [rightPattern]) => {
const specificityDiff = wildcardSpecificity(rightPattern) - wildcardSpecificity(leftPattern)
if (specificityDiff !== 0) {
return specificityDiff
}
return leftPattern.localeCompare(rightPattern)
})

return wildcardMatches[0][1]
}

// XML wrappers
export const wrapPrunableTools = (content: string): string => {
return `<prunable-tools>
Expand Down Expand Up @@ -66,21 +108,41 @@ Context management was just performed. Do NOT use the ${toolName} again. A fresh
</context-info>`
}

const resolveContextLimit = (config: PluginConfig, state: SessionState): number | undefined => {
const configLimit = config.tools.settings.contextLimit
const resolveContextLimit = (
config: PluginConfig,
state: SessionState,
messages: WithParts[],
): number | undefined => {
const { settings } = config.tools
const { modelLimits, contextLimit } = settings

if (modelLimits) {
const userMsg = getLastUserMessage(messages)
const modelId = userMsg ? (userMsg.info as UserMessage).model.modelID : undefined
const limit = modelId !== undefined ? findModelLimit(modelId, modelLimits) : undefined

if (limit !== undefined) {
if (typeof limit === "string" && limit.endsWith("%")) {
if (state.modelContextLimit === undefined) {
return undefined
}
return parsePercentageString(limit, state.modelContextLimit)
}
return typeof limit === "number" ? limit : undefined
}
}

if (typeof configLimit === "string") {
if (configLimit.endsWith("%")) {
if (typeof contextLimit === "string") {
if (contextLimit.endsWith("%")) {
if (state.modelContextLimit === undefined) {
return undefined
}
return parsePercentageString(configLimit, state.modelContextLimit)
return parsePercentageString(contextLimit, state.modelContextLimit)
}

return undefined
}

return configLimit
return contextLimit
}

const shouldInjectCompressNudge = (
Expand All @@ -92,7 +154,7 @@ const shouldInjectCompressNudge = (
return false
}

const contextLimit = resolveContextLimit(config, state)
const contextLimit = resolveContextLimit(config, state, messages)
if (contextLimit === undefined) {
return false
}
Expand Down
158 changes: 158 additions & 0 deletions tests/config-model-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import assert from "node:assert"
import { describe, it } from "node:test"
import { getInvalidConfigKeys, validateConfigTypes } from "../lib/config"

function createConfig(modelLimits?: Record<string, number | string>) {
return {
enabled: true,
debug: false,
pruneNotification: "minimal",
pruneNotificationType: "chat",
commands: {
enabled: true,
protectedTools: [],
},
turnProtection: {
enabled: false,
turns: 0,
},
protectedFilePatterns: [],
tools: {
settings: {
nudgeEnabled: true,
nudgeFrequency: 5,
protectedTools: [],
contextLimit: "60%",
...(modelLimits !== undefined ? { modelLimits } : {}),
},
distill: {
permission: "allow",
showDistillation: false,
},
compress: {
permission: "deny",
showCompression: false,
},
prune: {
permission: "allow",
},
},
strategies: {
deduplication: {
enabled: true,
protectedTools: [],
},
supersedeWrites: {
enabled: true,
},
purgeErrors: {
enabled: true,
turns: 4,
protectedTools: [],
},
},
}
}

describe("Config Validation - modelLimits", () => {
it("accepts valid modelLimits configuration", () => {
const config = createConfig({
"anthropic/claude-3.5-sonnet": "70%",
"anthropic/claude-3-opus": 150000,
"gpt-4": "80%",
})

const errors = validateConfigTypes(config)
assert.strictEqual(errors.length, 0)
})

it("rejects invalid modelLimits string value", () => {
const config = createConfig({
"anthropic/claude-3.5-sonnet": "invalid",
})

const errors = validateConfigTypes(config)
assert.ok(
errors.some(
(error) => error.key === "tools.settings.modelLimits.anthropic/claude-3.5-sonnet",
),
)
})

it("rejects modelLimits when not an object", () => {
const config = createConfig()
;(config.tools.settings as any).modelLimits = "not-an-object"

const errors = validateConfigTypes(config)
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits"))
})

it("works without modelLimits", () => {
const config = createConfig()

const errors = validateConfigTypes(config)
assert.strictEqual(errors.length, 0)
})

it("rejects malformed percentage strings", () => {
const config = createConfig({
model1: "abc%",
model2: "50 %",
model3: "%50",
model4: "50.5.5%",
})

const errors = validateConfigTypes(config)
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model1"))
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model2"))
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model3"))
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model4"))
})

it("rejects strings without percent suffix", () => {
const config = createConfig({ model: "50" })

const errors = validateConfigTypes(config)
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model"))
})

it("rejects empty strings", () => {
const config = createConfig({ model: "" })

const errors = validateConfigTypes(config)
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits.model"))
})

it("accepts boundary percentages and numbers", () => {
const config = createConfig({
p0: "0%",
p100: "100%",
n0: 0,
negative: -50000,
above100: "150%",
decimal: "50.5%",
huge: 1000000000000,
})

const errors = validateConfigTypes(config)
assert.strictEqual(errors.length, 0)
})

it("rejects modelLimits arrays", () => {
const config = createConfig()
;(config.tools.settings as any).modelLimits = ["not-an-object"]

const errors = validateConfigTypes(config)
assert.ok(errors.some((error) => error.key === "tools.settings.modelLimits"))
})

it("does not flag model-specific keys as unknown config keys", () => {
const config = createConfig({
"anthropic/claude-3.5-sonnet": "70%",
"openai/gpt-4o": 120000,
})

const invalidKeys = getInvalidConfigKeys(config)
assert.strictEqual(invalidKeys.length, 0)
})
})
Loading