Skip to content

Commit 387c335

Browse files
committed
feat: add model-specific context limits via modelLimits
Introduce toolSettings.modelLimits to allow per-model context limits as absolute token values or percentages. Update schema validation, config merging/cloning, and context limit resolution so model-specific values override the global contextLimit when available. Example: ```json { "toolSettings": { "contextLimit": "60%", "modelLimits": { "opencode/dax-1": "40%", "opencode/zen-3": 120000, "ollama/*": "25%", "*dcp*": 125000 } } } ```
1 parent 7883615 commit 387c335

3 files changed

Lines changed: 123 additions & 13 deletions

File tree

dcp.schema.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@
121121
"pattern": "^\\d+(?:\\.\\d+)?%$"
122122
}
123123
]
124+
},
125+
"modelLimits": {
126+
"description": "Model-specific context limits with optional wildcard patterns (exact match first, then most specific wildcard). Examples: \"openai/gpt-5\", \"*/zen-1\", \"ollama/*\", \"*sonnet*\"",
127+
"type": "object",
128+
"additionalProperties": {
129+
"oneOf": [
130+
{
131+
"type": "number"
132+
},
133+
{
134+
"type": "string",
135+
"pattern": "^\\d+(?:\\.\\d+)?%$"
136+
}
137+
]
138+
}
124139
}
125140
}
126141
},

lib/config.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface ToolSettings {
2828
nudgeFrequency: number
2929
protectedTools: string[]
3030
contextLimit: number | `${number}%`
31+
modelLimits?: Record<string, number | `${number}%`>
3132
}
3233

3334
export interface Tools {
@@ -107,6 +108,7 @@ export const VALID_CONFIG_KEYS = new Set([
107108
"tools.settings.nudgeFrequency",
108109
"tools.settings.protectedTools",
109110
"tools.settings.contextLimit",
111+
"tools.settings.modelLimits",
110112
"tools.distill",
111113
"tools.distill.permission",
112114
"tools.distill.showDistillation",
@@ -136,6 +138,12 @@ function getConfigKeyPaths(obj: Record<string, any>, prefix = ""): string[] {
136138
for (const key of Object.keys(obj)) {
137139
const fullKey = prefix ? `${prefix}.${key}` : key
138140
keys.push(fullKey)
141+
142+
// modelLimits is a dynamic map keyed by model ID; do not recurse into arbitrary IDs.
143+
if (fullKey === "tools.settings.modelLimits") {
144+
continue
145+
}
146+
139147
if (obj[key] && typeof obj[key] === "object" && !Array.isArray(obj[key])) {
140148
keys.push(...getConfigKeyPaths(obj[key], fullKey))
141149
}
@@ -156,7 +164,7 @@ interface ValidationError {
156164
actual: string
157165
}
158166

159-
function validateConfigTypes(config: Record<string, any>): ValidationError[] {
167+
export function validateConfigTypes(config: Record<string, any>): ValidationError[] {
160168
const errors: ValidationError[] = []
161169

162170
// Top-level validators
@@ -303,9 +311,32 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
303311
})
304312
}
305313
}
306-
}
307-
if (tools.distill) {
308-
if (tools.distill.permission !== undefined) {
314+
if (tools.settings.modelLimits !== undefined) {
315+
if (
316+
typeof tools.settings.modelLimits !== "object" ||
317+
Array.isArray(tools.settings.modelLimits)
318+
) {
319+
errors.push({
320+
key: "tools.settings.modelLimits",
321+
expected: "Record<string, number | ${number}%>",
322+
actual: typeof tools.settings.modelLimits,
323+
})
324+
} else {
325+
for (const [modelId, limit] of Object.entries(tools.settings.modelLimits)) {
326+
const isValidNumber = typeof limit === "number"
327+
const isPercentString =
328+
typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit)
329+
if (!isValidNumber && !isPercentString) {
330+
errors.push({
331+
key: `tools.settings.modelLimits.${modelId}`,
332+
expected: 'number | "${number}%"',
333+
actual: JSON.stringify(limit),
334+
})
335+
}
336+
}
337+
}
338+
}
339+
if (tools.distill?.permission !== undefined) {
309340
const validValues = ["ask", "allow", "deny"]
310341
if (!validValues.includes(tools.distill.permission)) {
311342
errors.push({
@@ -316,7 +347,7 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
316347
}
317348
}
318349
if (
319-
tools.distill.showDistillation !== undefined &&
350+
tools.distill?.showDistillation !== undefined &&
320351
typeof tools.distill.showDistillation !== "boolean"
321352
) {
322353
errors.push({
@@ -684,6 +715,7 @@ function mergeTools(
684715
]),
685716
],
686717
contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit,
718+
modelLimits: override.settings?.modelLimits ?? base.settings.modelLimits,
687719
},
688720
distill: {
689721
permission: override.distill?.permission ?? base.distill.permission,
@@ -724,6 +756,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
724756
settings: {
725757
...config.tools.settings,
726758
protectedTools: [...config.tools.settings.protectedTools],
759+
modelLimits: { ...config.tools.settings.modelLimits },
727760
},
728761
distill: { ...config.tools.distill },
729762
compress: { ...config.tools.compress },

lib/messages/inject.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,48 @@ function parsePercentageString(value: string, total: number): number | undefined
2727
return Math.round((clampedPercent / 100) * total)
2828
}
2929

30+
const escapeRegex = (value: string): string => {
31+
return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
32+
}
33+
34+
const wildcardPatternToRegex = (pattern: string): RegExp => {
35+
const escapedPattern = escapeRegex(pattern)
36+
const regexPattern = escapedPattern.replace(/\*/g, ".*")
37+
return new RegExp(`^${regexPattern}$`)
38+
}
39+
40+
const wildcardSpecificity = (pattern: string): number => {
41+
return pattern.replace(/\*/g, "").length
42+
}
43+
44+
export const findModelLimit = (
45+
modelId: string,
46+
modelLimits: Record<string, number | `${number}%`>,
47+
): number | `${number}%` | undefined => {
48+
const exactMatch = modelLimits[modelId]
49+
if (exactMatch !== undefined) {
50+
return exactMatch
51+
}
52+
53+
const wildcardMatches = Object.entries(modelLimits)
54+
.filter(([pattern]) => pattern.includes("*"))
55+
.filter(([pattern]) => wildcardPatternToRegex(pattern).test(modelId))
56+
57+
if (wildcardMatches.length === 0) {
58+
return undefined
59+
}
60+
61+
wildcardMatches.sort(([leftPattern], [rightPattern]) => {
62+
const specificityDiff = wildcardSpecificity(rightPattern) - wildcardSpecificity(leftPattern)
63+
if (specificityDiff !== 0) {
64+
return specificityDiff
65+
}
66+
return leftPattern.localeCompare(rightPattern)
67+
})
68+
69+
return wildcardMatches[0][1]
70+
}
71+
3072
// XML wrappers
3173
export const wrapPrunableTools = (content: string): string => {
3274
return `<prunable-tools>
@@ -66,21 +108,41 @@ Context management was just performed. Do NOT use the ${toolName} again. A fresh
66108
</context-info>`
67109
}
68110

69-
const resolveContextLimit = (config: PluginConfig, state: SessionState): number | undefined => {
70-
const configLimit = config.tools.settings.contextLimit
111+
const resolveContextLimit = (
112+
config: PluginConfig,
113+
state: SessionState,
114+
messages: WithParts[],
115+
): number | undefined => {
116+
const { settings } = config.tools
117+
const { modelLimits, contextLimit } = settings
118+
119+
if (modelLimits) {
120+
const userMsg = getLastUserMessage(messages)
121+
const modelId = userMsg ? (userMsg.info as UserMessage).model.modelID : undefined
122+
const limit = modelId !== undefined ? findModelLimit(modelId, modelLimits) : undefined
123+
124+
if (limit !== undefined) {
125+
if (typeof limit === "string" && limit.endsWith("%")) {
126+
if (state.modelContextLimit === undefined) {
127+
return undefined
128+
}
129+
return parsePercentageString(limit, state.modelContextLimit)
130+
}
131+
return typeof limit === "number" ? limit : undefined
132+
}
133+
}
71134

72-
if (typeof configLimit === "string") {
73-
if (configLimit.endsWith("%")) {
135+
if (typeof contextLimit === "string") {
136+
if (contextLimit.endsWith("%")) {
74137
if (state.modelContextLimit === undefined) {
75138
return undefined
76139
}
77-
return parsePercentageString(configLimit, state.modelContextLimit)
140+
return parsePercentageString(contextLimit, state.modelContextLimit)
78141
}
79-
80142
return undefined
81143
}
82144

83-
return configLimit
145+
return contextLimit
84146
}
85147

86148
const shouldInjectCompressNudge = (
@@ -92,7 +154,7 @@ const shouldInjectCompressNudge = (
92154
return false
93155
}
94156

95-
const contextLimit = resolveContextLimit(config, state)
157+
const contextLimit = resolveContextLimit(config, state, messages)
96158
if (contextLimit === undefined) {
97159
return false
98160
}

0 commit comments

Comments
 (0)