delimit-cli 3.12.1 → 3.13.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/README.md +44 -18
- package/bin/delimit-cli.js +189 -61
- package/gateway/ai/rate_limiter.py +568 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@ Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, govern
|
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://glama.ai/mcp/servers/delimit-ai/delimit-mcp-server)
|
|
9
9
|
|
|
10
|
+
<p align="center">
|
|
11
|
+
<img src="docs/demo.gif" alt="Delimit detecting breaking API changes" width="700">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
10
14
|
Persistent ledger, API governance, security orchestration, and multi-model deliberation — all shared across Claude Code, Codex, Cursor, and Gemini CLI.
|
|
11
15
|
|
|
12
16
|
---
|
|
@@ -111,24 +115,46 @@ When installed into your AI coding assistant, Delimit provides tools across two
|
|
|
111
115
|
|
|
112
116
|
---
|
|
113
117
|
|
|
114
|
-
## What
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
|
121
|
-
|
|
122
|
-
|
|
|
123
|
-
|
|
|
124
|
-
|
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
## What It Detects
|
|
119
|
+
|
|
120
|
+
27 change types (17 breaking, 10 non-breaking) -- deterministic rules, not AI inference. Same input always produces the same result.
|
|
121
|
+
|
|
122
|
+
### Breaking Changes
|
|
123
|
+
|
|
124
|
+
| # | Change Type | Example |
|
|
125
|
+
|---|-------------|---------|
|
|
126
|
+
| 1 | `endpoint_removed` | `DELETE /users/{id}` removed entirely |
|
|
127
|
+
| 2 | `method_removed` | `PATCH /orders` no longer exists |
|
|
128
|
+
| 3 | `required_param_added` | New required header on `GET /items` |
|
|
129
|
+
| 4 | `param_removed` | `sort` query parameter removed |
|
|
130
|
+
| 5 | `response_removed` | `200 OK` response dropped |
|
|
131
|
+
| 6 | `required_field_added` | Request body now requires `tenant_id` |
|
|
132
|
+
| 7 | `field_removed` | `email` dropped from response object |
|
|
133
|
+
| 8 | `type_changed` | `id` went from `string` to `integer` |
|
|
134
|
+
| 9 | `format_changed` | `date-time` changed to `date` |
|
|
135
|
+
| 10 | `enum_value_removed` | `status: "pending"` no longer valid |
|
|
136
|
+
| 11 | `param_type_changed` | Query param `limit` changed from `integer` to `string` |
|
|
137
|
+
| 12 | `param_required_changed` | `filter` param became required |
|
|
138
|
+
| 13 | `response_type_changed` | Response `data` changed from `array` to `object` |
|
|
139
|
+
| 14 | `security_removed` | OAuth2 security scheme removed |
|
|
140
|
+
| 15 | `security_scope_removed` | `write:pets` scope removed from OAuth2 |
|
|
141
|
+
| 16 | `max_length_decreased` | `name` maxLength reduced from 255 to 100 |
|
|
142
|
+
| 17 | `min_length_increased` | `code` minLength increased from 1 to 5 |
|
|
143
|
+
|
|
144
|
+
### Non-Breaking Changes
|
|
145
|
+
|
|
146
|
+
| # | Change Type | Example |
|
|
147
|
+
|---|-------------|---------|
|
|
148
|
+
| 18 | `endpoint_added` | New `POST /webhooks` endpoint |
|
|
149
|
+
| 19 | `method_added` | `PATCH /users/{id}` method added |
|
|
150
|
+
| 20 | `optional_param_added` | Optional `format` query param added |
|
|
151
|
+
| 21 | `response_added` | `201 Created` response added |
|
|
152
|
+
| 22 | `optional_field_added` | Optional `nickname` field added to response |
|
|
153
|
+
| 23 | `enum_value_added` | `status: "archived"` value added |
|
|
154
|
+
| 24 | `description_changed` | Updated description for `/health` endpoint |
|
|
155
|
+
| 25 | `security_added` | API key security scheme added |
|
|
156
|
+
| 26 | `deprecated_added` | `GET /v1/users` marked as deprecated |
|
|
157
|
+
| 27 | `default_changed` | Default value for `page_size` changed from 10 to 20 |
|
|
132
158
|
|
|
133
159
|
---
|
|
134
160
|
|
package/bin/delimit-cli.js
CHANGED
|
@@ -947,29 +947,59 @@ rules:
|
|
|
947
947
|
`,
|
|
948
948
|
};
|
|
949
949
|
|
|
950
|
-
// Init command —
|
|
950
|
+
// Init command — guided onboarding wizard (Consensus: Build Next 2026-03-27)
|
|
951
951
|
program
|
|
952
952
|
.command('init')
|
|
953
953
|
.description('Initialize Delimit API governance in this project')
|
|
954
|
-
.option('--preset <name>', 'Policy preset: strict, default, or relaxed'
|
|
954
|
+
.option('--preset <name>', 'Policy preset: strict, default, or relaxed')
|
|
955
|
+
.option('--yes', 'Skip prompts and use defaults')
|
|
955
956
|
.action(async (options) => {
|
|
957
|
+
const startTime = Date.now();
|
|
956
958
|
const configDir = path.join(process.cwd(), '.delimit');
|
|
957
959
|
const policyFile = path.join(configDir, 'policies.yml');
|
|
958
960
|
|
|
961
|
+
console.log(chalk.bold('\n Delimit — API Governance Setup\n'));
|
|
962
|
+
|
|
959
963
|
if (fs.existsSync(policyFile)) {
|
|
960
|
-
console.log(chalk.yellow('Already initialized — .delimit/policies.yml exists'));
|
|
964
|
+
console.log(chalk.yellow(' Already initialized — .delimit/policies.yml exists'));
|
|
965
|
+
console.log(` Run ${chalk.bold('delimit lint')} to check your API.\n`);
|
|
961
966
|
return;
|
|
962
967
|
}
|
|
963
968
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
+
// Step 1: Detect project type
|
|
970
|
+
console.log(chalk.gray(' Scanning project...'));
|
|
971
|
+
const projectDir = process.cwd();
|
|
972
|
+
const projectName = path.basename(projectDir);
|
|
973
|
+
let framework = 'unknown';
|
|
974
|
+
let frameworkLabel = '';
|
|
969
975
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
976
|
+
// Check for common frameworks
|
|
977
|
+
const pkgJsonPath = path.join(projectDir, 'package.json');
|
|
978
|
+
const pyprojectPath = path.join(projectDir, 'pyproject.toml');
|
|
979
|
+
const requirementsPath = path.join(projectDir, 'requirements.txt');
|
|
980
|
+
|
|
981
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
982
|
+
try {
|
|
983
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
984
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
985
|
+
if (allDeps['@nestjs/core']) { framework = 'nestjs'; frameworkLabel = 'NestJS'; }
|
|
986
|
+
else if (allDeps['express']) { framework = 'express'; frameworkLabel = 'Express'; }
|
|
987
|
+
else if (allDeps['fastify']) { framework = 'fastify'; frameworkLabel = 'Fastify'; }
|
|
988
|
+
else if (allDeps['hono']) { framework = 'hono'; frameworkLabel = 'Hono'; }
|
|
989
|
+
else if (allDeps['next']) { framework = 'nextjs'; frameworkLabel = 'Next.js'; }
|
|
990
|
+
} catch {}
|
|
991
|
+
}
|
|
992
|
+
if (framework === 'unknown') {
|
|
993
|
+
const pyFiles = [pyprojectPath, requirementsPath, path.join(projectDir, 'setup.py')];
|
|
994
|
+
for (const f of pyFiles) {
|
|
995
|
+
if (fs.existsSync(f)) {
|
|
996
|
+
const content = fs.readFileSync(f, 'utf-8').toLowerCase();
|
|
997
|
+
if (content.includes('fastapi')) { framework = 'fastapi'; frameworkLabel = 'FastAPI'; break; }
|
|
998
|
+
if (content.includes('django')) { framework = 'django'; frameworkLabel = 'Django'; break; }
|
|
999
|
+
if (content.includes('flask')) { framework = 'flask'; frameworkLabel = 'Flask'; break; }
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
973
1003
|
|
|
974
1004
|
// Auto-detect OpenAPI spec files
|
|
975
1005
|
const specPatterns = [
|
|
@@ -982,45 +1012,89 @@ program
|
|
|
982
1012
|
'api/openapi.yaml', 'api/openapi.json',
|
|
983
1013
|
'contrib/openapi.json',
|
|
984
1014
|
];
|
|
985
|
-
const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(
|
|
1015
|
+
const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(projectDir, p)));
|
|
1016
|
+
const specPath = foundSpecs.length > 0 ? foundSpecs[0] : null;
|
|
1017
|
+
|
|
1018
|
+
// Check for CI
|
|
1019
|
+
const hasGitHub = fs.existsSync(path.join(projectDir, '.github'));
|
|
1020
|
+
const hasGitLabCI = fs.existsSync(path.join(projectDir, '.gitlab-ci.yml'));
|
|
1021
|
+
const ciProvider = hasGitHub ? 'github' : hasGitLabCI ? 'gitlab' : 'none';
|
|
1022
|
+
|
|
1023
|
+
// Display detection results
|
|
1024
|
+
console.log(` Project: ${chalk.bold(projectName)}`);
|
|
1025
|
+
if (frameworkLabel) console.log(` Framework: ${chalk.bold(frameworkLabel)}`);
|
|
1026
|
+
if (specPath) console.log(` Spec: ${chalk.bold(specPath)}`);
|
|
1027
|
+
else if (['fastapi', 'nestjs', 'express'].includes(framework))
|
|
1028
|
+
console.log(` Spec: ${chalk.gray('none found')} (Zero-Spec Mode available for ${frameworkLabel})`);
|
|
1029
|
+
else console.log(` Spec: ${chalk.gray('none found')}`);
|
|
1030
|
+
if (ciProvider !== 'none') console.log(` CI: ${chalk.bold(ciProvider === 'github' ? 'GitHub Actions' : 'GitLab CI')}`);
|
|
1031
|
+
console.log('');
|
|
986
1032
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1033
|
+
// Step 2: Choose preset
|
|
1034
|
+
let preset = options.preset ? options.preset.toLowerCase() : null;
|
|
1035
|
+
if (!preset && !options.yes) {
|
|
1036
|
+
// Suggest preset based on project signals
|
|
1037
|
+
let defaultPreset = 'default';
|
|
1038
|
+
if (specPath) {
|
|
1039
|
+
// If they have a checked-in spec, they probably care about stability
|
|
1040
|
+
try {
|
|
1041
|
+
const specContent = fs.readFileSync(path.join(projectDir, specPath), 'utf-8');
|
|
1042
|
+
if (specContent.includes('/v2') || specContent.includes('/v3')) defaultPreset = 'strict';
|
|
1043
|
+
} catch {}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
const answers = await inquirer.prompt([{
|
|
1048
|
+
type: 'list',
|
|
1049
|
+
name: 'preset',
|
|
1050
|
+
message: 'Policy preset:',
|
|
1051
|
+
choices: [
|
|
1052
|
+
{ name: 'strict — Block all breaking changes (public APIs, payment systems)', value: 'strict' },
|
|
1053
|
+
{ name: 'default — Balanced for most teams (block critical, warn on others)', value: 'default' },
|
|
1054
|
+
{ name: 'relaxed — Warnings only (internal APIs, early-stage projects)', value: 'relaxed' },
|
|
1055
|
+
],
|
|
1056
|
+
default: defaultPreset,
|
|
1057
|
+
}]);
|
|
1058
|
+
preset = answers.preset;
|
|
1059
|
+
} catch {
|
|
1060
|
+
preset = defaultPreset;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (!preset) preset = 'default';
|
|
1064
|
+
|
|
1065
|
+
if (!POLICY_PRESETS[preset]) {
|
|
1066
|
+
console.log(chalk.red(` Unknown preset "${preset}". Choose: strict, default, or relaxed`));
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Step 3: Create policy file
|
|
1071
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
1072
|
+
fs.writeFileSync(policyFile, POLICY_PRESETS[preset]);
|
|
1073
|
+
console.log(chalk.green(` Created .delimit/policies.yml (${preset})`));
|
|
1015
1074
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1075
|
+
// Step 4: Add GitHub Action workflow if spec found + GitHub CI
|
|
1076
|
+
if (specPath && ciProvider === 'github') {
|
|
1077
|
+
const workflowDir = path.join(projectDir, '.github', 'workflows');
|
|
1018
1078
|
const workflowFile = path.join(workflowDir, 'api-governance.yml');
|
|
1019
1079
|
|
|
1020
1080
|
if (!fs.existsSync(workflowFile)) {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1081
|
+
let writeWorkflow = true;
|
|
1082
|
+
if (!options.yes) {
|
|
1083
|
+
try {
|
|
1084
|
+
const ans = await inquirer.prompt([{
|
|
1085
|
+
type: 'confirm',
|
|
1086
|
+
name: 'addWorkflow',
|
|
1087
|
+
message: 'Add GitHub Action for PR governance?',
|
|
1088
|
+
default: true,
|
|
1089
|
+
}]);
|
|
1090
|
+
writeWorkflow = ans.addWorkflow;
|
|
1091
|
+
} catch {}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (writeWorkflow) {
|
|
1095
|
+
try {
|
|
1096
|
+
fs.mkdirSync(workflowDir, { recursive: true });
|
|
1097
|
+
const workflowContent = `name: API Governance
|
|
1024
1098
|
on:
|
|
1025
1099
|
pull_request:
|
|
1026
1100
|
paths:
|
|
@@ -1045,27 +1119,81 @@ jobs:
|
|
|
1045
1119
|
new_spec: ${specPath}
|
|
1046
1120
|
mode: advisory
|
|
1047
1121
|
`;
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1122
|
+
fs.writeFileSync(workflowFile, workflowContent);
|
|
1123
|
+
console.log(chalk.green(' Created .github/workflows/api-governance.yml'));
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
console.log(chalk.yellow(` Could not write workflow: ${err.message}`));
|
|
1126
|
+
}
|
|
1053
1127
|
}
|
|
1054
1128
|
} else {
|
|
1055
|
-
console.log(chalk.
|
|
1129
|
+
console.log(chalk.gray(' .github/workflows/api-governance.yml already exists'));
|
|
1056
1130
|
}
|
|
1057
|
-
} else {
|
|
1058
|
-
console.log(' No OpenAPI spec file detected.');
|
|
1059
|
-
console.log(` Delimit also supports ${chalk.bold('Zero-Spec Mode')} — run ${chalk.bold('delimit lint')} in a FastAPI/NestJS/Express project.`);
|
|
1060
|
-
console.log('');
|
|
1061
1131
|
}
|
|
1062
1132
|
|
|
1063
|
-
|
|
1064
|
-
console.log(
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1133
|
+
// Step 5: Run first lint to show immediate value
|
|
1134
|
+
console.log('');
|
|
1135
|
+
if (specPath) {
|
|
1136
|
+
console.log(chalk.bold(' Running first lint...'));
|
|
1137
|
+
try {
|
|
1138
|
+
const result = apiEngine.lint(
|
|
1139
|
+
path.join(projectDir, specPath),
|
|
1140
|
+
path.join(projectDir, specPath),
|
|
1141
|
+
{ policy: preset }
|
|
1142
|
+
);
|
|
1143
|
+
if (result && result.summary) {
|
|
1144
|
+
const s = result.summary;
|
|
1145
|
+
const breaking = s.breaking || 0;
|
|
1146
|
+
const warnings = s.warnings || 0;
|
|
1147
|
+
const safe = s.safe || s.non_breaking || 0;
|
|
1148
|
+
if (breaking === 0 && warnings === 0) {
|
|
1149
|
+
console.log(chalk.green(' PASS — No breaking changes detected'));
|
|
1150
|
+
} else if (breaking > 0) {
|
|
1151
|
+
console.log(chalk.red(` FAIL — ${breaking} breaking change(s), ${warnings} warning(s)`));
|
|
1152
|
+
} else {
|
|
1153
|
+
console.log(chalk.yellow(` WARN — ${warnings} warning(s)`));
|
|
1154
|
+
}
|
|
1155
|
+
if (result.paths_analyzed) {
|
|
1156
|
+
console.log(chalk.gray(` Analyzed ${result.paths_analyzed} endpoint(s)`));
|
|
1157
|
+
}
|
|
1158
|
+
} else {
|
|
1159
|
+
console.log(chalk.green(' Spec validated successfully'));
|
|
1160
|
+
}
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
// Lint comparing same file = no changes, which is expected
|
|
1163
|
+
console.log(chalk.green(' Spec validated — baseline set'));
|
|
1164
|
+
}
|
|
1165
|
+
} else if (['fastapi', 'nestjs', 'express'].includes(framework)) {
|
|
1166
|
+
console.log(chalk.bold(' Running Zero-Spec lint...'));
|
|
1167
|
+
try {
|
|
1168
|
+
const zeroResult = apiEngine.zeroSpec(projectDir);
|
|
1169
|
+
if (zeroResult && zeroResult.success) {
|
|
1170
|
+
console.log(chalk.green(` Extracted: ${zeroResult.paths_count} paths, ${zeroResult.schemas_count} schemas`));
|
|
1171
|
+
// Save baseline
|
|
1172
|
+
const baselinePath = path.join(configDir, 'baseline.yaml');
|
|
1173
|
+
if (!fs.existsSync(baselinePath)) {
|
|
1174
|
+
fs.writeFileSync(baselinePath, yaml.dump(zeroResult.spec));
|
|
1175
|
+
console.log(chalk.green(' Saved baseline to .delimit/baseline.yaml'));
|
|
1176
|
+
}
|
|
1177
|
+
} else {
|
|
1178
|
+
console.log(chalk.gray(' Zero-Spec extraction skipped — run `delimit lint` manually'));
|
|
1179
|
+
}
|
|
1180
|
+
} catch {
|
|
1181
|
+
console.log(chalk.gray(' Zero-Spec extraction skipped — run `delimit lint` manually'));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Summary
|
|
1186
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
1187
|
+
console.log(chalk.bold(`\n Setup complete in ${elapsed}s\n`));
|
|
1188
|
+
console.log(' Next steps:');
|
|
1189
|
+
if (specPath) {
|
|
1190
|
+
console.log(` ${chalk.bold('delimit lint')} ${specPath} ${specPath} — lint on every PR`);
|
|
1191
|
+
} else {
|
|
1192
|
+
console.log(` ${chalk.bold('delimit lint')} — zero-spec mode`);
|
|
1193
|
+
}
|
|
1194
|
+
console.log(` ${chalk.bold('delimit doctor')} — verify setup`);
|
|
1195
|
+
console.log(` ${chalk.bold('delimit explain')} — human-readable report`);
|
|
1196
|
+
console.log('');
|
|
1069
1197
|
});
|
|
1070
1198
|
|
|
1071
1199
|
// Doctor command — verify setup is correct
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delimit MCP Rate Limiter — per-tool call limits and session cost controls.
|
|
3
|
+
|
|
4
|
+
Provides sliding-window rate limiting and cumulative cost tracking for all
|
|
5
|
+
MCP tools. Designed to prevent runaway agent loops from burning through
|
|
6
|
+
expensive API calls.
|
|
7
|
+
|
|
8
|
+
Configuration:
|
|
9
|
+
~/.delimit/rate_limits.yml — per-tool overrides
|
|
10
|
+
Defaults: 100 calls/hr (free), 20 calls/hr (Pro), 5 calls/hr (deliberation)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from ai.rate_limiter import limiter
|
|
14
|
+
|
|
15
|
+
block = limiter.check("delimit_lint")
|
|
16
|
+
if block:
|
|
17
|
+
return block # contains error message + wait hint
|
|
18
|
+
# ... execute tool ...
|
|
19
|
+
limiter.record("delimit_lint", cost=0.001)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("delimit.rate_limiter")
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Tool tier classification
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# Tools that invoke multi-model deliberation (most expensive)
|
|
34
|
+
DELIBERATION_TOOLS = frozenset({
|
|
35
|
+
"delimit_deliberate",
|
|
36
|
+
"delimit_security_deliberate",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
# Pro tools that do significant computation but aren't deliberation
|
|
40
|
+
# Mirrors the PRO_TOOLS set from ai/license.py
|
|
41
|
+
PRO_TOOLS = frozenset({
|
|
42
|
+
"delimit_gov_evaluate", "delimit_gov_policy", "delimit_gov_run",
|
|
43
|
+
"delimit_gov_verify", "delimit_gov_new_task",
|
|
44
|
+
"delimit_os_plan", "delimit_os_status", "delimit_os_gates",
|
|
45
|
+
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
46
|
+
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
|
|
47
|
+
"delimit_deploy_site", "delimit_deploy_npm",
|
|
48
|
+
"delimit_memory_search",
|
|
49
|
+
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
50
|
+
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
51
|
+
"delimit_models",
|
|
52
|
+
"delimit_security_ingest",
|
|
53
|
+
"delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
|
|
54
|
+
"delimit_release_plan", "delimit_release_status", "delimit_release_sync",
|
|
55
|
+
"delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
|
|
56
|
+
"delimit_social_post", "delimit_social_generate", "delimit_social_history",
|
|
57
|
+
"delimit_repo_analyze", "delimit_repo_config_audit",
|
|
58
|
+
"delimit_repo_config_validate", "delimit_repo_diagnose",
|
|
59
|
+
"delimit_test_coverage",
|
|
60
|
+
"delimit_screen_record", "delimit_screenshot",
|
|
61
|
+
"delimit_notify",
|
|
62
|
+
"delimit_agent_dispatch", "delimit_agent_status",
|
|
63
|
+
"delimit_agent_complete", "delimit_agent_handoff",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Per-tool cost estimates (USD). Tools not listed default to 0.
|
|
67
|
+
DEFAULT_COST_ESTIMATES: Dict[str, float] = {
|
|
68
|
+
# Deliberation — multiple LLM calls
|
|
69
|
+
"delimit_deliberate": 0.01,
|
|
70
|
+
"delimit_security_deliberate": 0.01,
|
|
71
|
+
# Lint / diff / semver — local computation, minimal cost
|
|
72
|
+
"delimit_lint": 0.001,
|
|
73
|
+
"delimit_diff": 0.001,
|
|
74
|
+
"delimit_semver": 0.001,
|
|
75
|
+
# Deploy actions — infrastructure cost
|
|
76
|
+
"delimit_deploy_publish": 0.005,
|
|
77
|
+
"delimit_deploy_build": 0.003,
|
|
78
|
+
"delimit_deploy_site": 0.005,
|
|
79
|
+
"delimit_deploy_npm": 0.005,
|
|
80
|
+
# Agent dispatch — orchestrates sub-agents
|
|
81
|
+
"delimit_agent_dispatch": 0.008,
|
|
82
|
+
# Social posting — API calls to external services
|
|
83
|
+
"delimit_social_post": 0.002,
|
|
84
|
+
"delimit_social_generate": 0.003,
|
|
85
|
+
# Screen recording / screenshots — browser automation
|
|
86
|
+
"delimit_screen_record": 0.005,
|
|
87
|
+
"delimit_screenshot": 0.002,
|
|
88
|
+
# Everything else is effectively free (local computation)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Default hourly limits by tier
|
|
92
|
+
DEFAULT_LIMIT_FREE = 100
|
|
93
|
+
DEFAULT_LIMIT_PRO = 20
|
|
94
|
+
DEFAULT_LIMIT_DELIBERATION = 5
|
|
95
|
+
|
|
96
|
+
# Session cost cap
|
|
97
|
+
DEFAULT_SESSION_COST_CAP = 5.0
|
|
98
|
+
|
|
99
|
+
# Warning threshold (fraction of limit used before emitting a warning)
|
|
100
|
+
WARNING_THRESHOLD = 0.80
|
|
101
|
+
|
|
102
|
+
# Sliding window duration in seconds (1 hour)
|
|
103
|
+
WINDOW_SECONDS = 3600
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _classify_tool(tool_name: str) -> str:
|
|
107
|
+
"""Return the tier for a tool: 'deliberation', 'pro', or 'free'."""
|
|
108
|
+
if tool_name in DELIBERATION_TOOLS:
|
|
109
|
+
return "deliberation"
|
|
110
|
+
if tool_name in PRO_TOOLS:
|
|
111
|
+
return "pro"
|
|
112
|
+
return "free"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _default_limit_for(tool_name: str) -> int:
|
|
116
|
+
"""Return the default hourly call limit based on tool tier."""
|
|
117
|
+
tier = _classify_tool(tool_name)
|
|
118
|
+
if tier == "deliberation":
|
|
119
|
+
return DEFAULT_LIMIT_DELIBERATION
|
|
120
|
+
if tier == "pro":
|
|
121
|
+
return DEFAULT_LIMIT_PRO
|
|
122
|
+
return DEFAULT_LIMIT_FREE
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
126
|
+
"""Load rate limit overrides from YAML config.
|
|
127
|
+
|
|
128
|
+
Returns a dict with optional keys:
|
|
129
|
+
session_cost_cap: float
|
|
130
|
+
tools: {tool_name: {limit: int, cost: float}}
|
|
131
|
+
tiers: {free: int, pro: int, deliberation: int}
|
|
132
|
+
"""
|
|
133
|
+
if config_path is None:
|
|
134
|
+
config_path = Path.home() / ".delimit" / "rate_limits.yml"
|
|
135
|
+
|
|
136
|
+
if not config_path.exists():
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Use PyYAML if available; fall back to a simple parser
|
|
141
|
+
try:
|
|
142
|
+
import yaml
|
|
143
|
+
with open(config_path) as f:
|
|
144
|
+
data = yaml.safe_load(f)
|
|
145
|
+
return data if isinstance(data, dict) else {}
|
|
146
|
+
except ImportError:
|
|
147
|
+
return _parse_simple_yaml(config_path)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.warning("Failed to load rate_limits.yml: %s", exc)
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_simple_yaml(path: Path) -> Dict[str, Any]:
|
|
154
|
+
"""Minimal YAML-subset parser for flat key-value and one level of nesting.
|
|
155
|
+
|
|
156
|
+
Handles the structure we actually emit in the default config file without
|
|
157
|
+
requiring PyYAML as a hard dependency.
|
|
158
|
+
"""
|
|
159
|
+
result: Dict[str, Any] = {}
|
|
160
|
+
current_section: Optional[str] = None
|
|
161
|
+
current_dict: Dict[str, Any] = {}
|
|
162
|
+
|
|
163
|
+
for raw_line in path.read_text().splitlines():
|
|
164
|
+
# Strip comments
|
|
165
|
+
line = raw_line.split("#")[0].rstrip()
|
|
166
|
+
if not line or not line.strip():
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
indent = len(line) - len(line.lstrip())
|
|
170
|
+
stripped = line.strip()
|
|
171
|
+
|
|
172
|
+
if stripped.startswith("-"):
|
|
173
|
+
continue # skip list items for now
|
|
174
|
+
|
|
175
|
+
if ":" not in stripped:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
key, _, val = stripped.partition(":")
|
|
179
|
+
key = key.strip()
|
|
180
|
+
val = val.strip()
|
|
181
|
+
|
|
182
|
+
if indent == 0:
|
|
183
|
+
# Top-level key
|
|
184
|
+
if current_section and current_dict:
|
|
185
|
+
result[current_section] = current_dict
|
|
186
|
+
current_dict = {}
|
|
187
|
+
if val:
|
|
188
|
+
result[key] = _coerce_value(val)
|
|
189
|
+
current_section = None
|
|
190
|
+
else:
|
|
191
|
+
current_section = key
|
|
192
|
+
current_dict = {}
|
|
193
|
+
elif indent >= 2 and current_section:
|
|
194
|
+
if val:
|
|
195
|
+
current_dict[key] = _coerce_value(val)
|
|
196
|
+
else:
|
|
197
|
+
# Nested dict (two levels deep) — store sub-dict
|
|
198
|
+
current_dict[key] = {}
|
|
199
|
+
|
|
200
|
+
if current_section and current_dict:
|
|
201
|
+
result[current_section] = current_dict
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _coerce_value(val: str) -> Any:
|
|
207
|
+
"""Coerce a YAML scalar string to int, float, or str."""
|
|
208
|
+
if not val:
|
|
209
|
+
return val
|
|
210
|
+
# Remove quotes
|
|
211
|
+
if (val.startswith('"') and val.endswith('"')) or \
|
|
212
|
+
(val.startswith("'") and val.endswith("'")):
|
|
213
|
+
return val[1:-1]
|
|
214
|
+
try:
|
|
215
|
+
return int(val)
|
|
216
|
+
except ValueError:
|
|
217
|
+
pass
|
|
218
|
+
try:
|
|
219
|
+
return float(val)
|
|
220
|
+
except ValueError:
|
|
221
|
+
pass
|
|
222
|
+
if val.lower() in ("true", "yes"):
|
|
223
|
+
return True
|
|
224
|
+
if val.lower() in ("false", "no"):
|
|
225
|
+
return False
|
|
226
|
+
return val
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RateLimiter:
|
|
230
|
+
"""Per-tool sliding-window rate limiter with session cost tracking.
|
|
231
|
+
|
|
232
|
+
Thread-safety: NOT thread-safe. MCP servers are single-threaded per
|
|
233
|
+
session, so this is fine. If that changes, add a lock.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
237
|
+
# {tool_name: [timestamp, timestamp, ...]} — sorted ascending
|
|
238
|
+
self._calls: Dict[str, List[float]] = {}
|
|
239
|
+
# {tool_name: float} — cumulative cost per tool this session
|
|
240
|
+
self._costs: Dict[str, float] = {}
|
|
241
|
+
# Total session cost
|
|
242
|
+
self._session_cost: float = 0.0
|
|
243
|
+
# Session start time
|
|
244
|
+
self._session_start: float = time.time()
|
|
245
|
+
# Load config
|
|
246
|
+
self._config = _load_config(config_path)
|
|
247
|
+
self._custom_limits: Dict[str, int] = {}
|
|
248
|
+
self._load_custom_limits()
|
|
249
|
+
|
|
250
|
+
def _load_custom_limits(self) -> None:
|
|
251
|
+
"""Extract per-tool limit overrides from the config."""
|
|
252
|
+
# Tier-level overrides
|
|
253
|
+
tiers = self._config.get("tiers", {})
|
|
254
|
+
if isinstance(tiers, dict):
|
|
255
|
+
self._tier_overrides = {
|
|
256
|
+
"free": int(tiers["free"]) if "free" in tiers else None,
|
|
257
|
+
"pro": int(tiers["pro"]) if "pro" in tiers else None,
|
|
258
|
+
"deliberation": int(tiers["deliberation"]) if "deliberation" in tiers else None,
|
|
259
|
+
}
|
|
260
|
+
else:
|
|
261
|
+
self._tier_overrides = {}
|
|
262
|
+
|
|
263
|
+
# Per-tool overrides
|
|
264
|
+
tools = self._config.get("tools", {})
|
|
265
|
+
if isinstance(tools, dict):
|
|
266
|
+
for tool_name, settings in tools.items():
|
|
267
|
+
if isinstance(settings, dict) and "limit" in settings:
|
|
268
|
+
self._custom_limits[tool_name] = int(settings["limit"])
|
|
269
|
+
elif isinstance(settings, (int, float)):
|
|
270
|
+
self._custom_limits[tool_name] = int(settings)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def session_cost_cap(self) -> float:
|
|
274
|
+
"""The maximum cost allowed per session."""
|
|
275
|
+
cap = self._config.get("session_cost_cap")
|
|
276
|
+
if cap is not None:
|
|
277
|
+
try:
|
|
278
|
+
return float(cap)
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
pass
|
|
281
|
+
return DEFAULT_SESSION_COST_CAP
|
|
282
|
+
|
|
283
|
+
def _get_limit(self, tool_name: str) -> int:
|
|
284
|
+
"""Resolve the effective hourly limit for a tool."""
|
|
285
|
+
# Per-tool override takes priority
|
|
286
|
+
if tool_name in self._custom_limits:
|
|
287
|
+
return self._custom_limits[tool_name]
|
|
288
|
+
|
|
289
|
+
# Tier override
|
|
290
|
+
tier = _classify_tool(tool_name)
|
|
291
|
+
tier_override = getattr(self, "_tier_overrides", {}).get(tier)
|
|
292
|
+
if tier_override is not None:
|
|
293
|
+
return tier_override
|
|
294
|
+
|
|
295
|
+
return _default_limit_for(tool_name)
|
|
296
|
+
|
|
297
|
+
def _get_cost_estimate(self, tool_name: str) -> float:
|
|
298
|
+
"""Return the estimated cost per call for a tool."""
|
|
299
|
+
# Config override
|
|
300
|
+
tools = self._config.get("tools", {})
|
|
301
|
+
if isinstance(tools, dict) and tool_name in tools:
|
|
302
|
+
settings = tools[tool_name]
|
|
303
|
+
if isinstance(settings, dict) and "cost" in settings:
|
|
304
|
+
return float(settings["cost"])
|
|
305
|
+
|
|
306
|
+
return DEFAULT_COST_ESTIMATES.get(tool_name, 0.0)
|
|
307
|
+
|
|
308
|
+
def _prune_window(self, tool_name: str, now: float) -> List[float]:
|
|
309
|
+
"""Remove call timestamps outside the sliding window, return remaining."""
|
|
310
|
+
if tool_name not in self._calls:
|
|
311
|
+
return []
|
|
312
|
+
cutoff = now - WINDOW_SECONDS
|
|
313
|
+
calls = self._calls[tool_name]
|
|
314
|
+
# Binary-ish prune: find first index >= cutoff
|
|
315
|
+
pruned = [t for t in calls if t >= cutoff]
|
|
316
|
+
self._calls[tool_name] = pruned
|
|
317
|
+
return pruned
|
|
318
|
+
|
|
319
|
+
def check(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
|
320
|
+
"""Check if calling tool_name is allowed right now.
|
|
321
|
+
|
|
322
|
+
Returns None if the call is permitted.
|
|
323
|
+
Returns an error dict if the call should be blocked.
|
|
324
|
+
"""
|
|
325
|
+
now = time.time()
|
|
326
|
+
|
|
327
|
+
# 1. Session cost cap
|
|
328
|
+
if self._session_cost >= self.session_cost_cap:
|
|
329
|
+
return {
|
|
330
|
+
"error": "session_cost_exceeded",
|
|
331
|
+
"message": (
|
|
332
|
+
f"Session cost cap reached (${self._session_cost:.3f} / "
|
|
333
|
+
f"${self.session_cost_cap:.2f}). "
|
|
334
|
+
"To continue, increase the cap in ~/.delimit/rate_limits.yml "
|
|
335
|
+
"or call delimit_cost_controls to adjust."
|
|
336
|
+
),
|
|
337
|
+
"session_cost": round(self._session_cost, 4),
|
|
338
|
+
"session_cost_cap": self.session_cost_cap,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# 2. Per-tool rate limit
|
|
342
|
+
recent = self._prune_window(tool_name, now)
|
|
343
|
+
limit = self._get_limit(tool_name)
|
|
344
|
+
count = len(recent)
|
|
345
|
+
|
|
346
|
+
if count >= limit:
|
|
347
|
+
# Calculate when the oldest call in the window will expire
|
|
348
|
+
oldest = recent[0] if recent else now
|
|
349
|
+
wait_seconds = int(oldest + WINDOW_SECONDS - now) + 1
|
|
350
|
+
wait_minutes = max(1, wait_seconds // 60)
|
|
351
|
+
tier = _classify_tool(tool_name)
|
|
352
|
+
return {
|
|
353
|
+
"error": "rate_limit_exceeded",
|
|
354
|
+
"message": (
|
|
355
|
+
f"Rate limit exceeded for '{tool_name}': "
|
|
356
|
+
f"{count}/{limit} calls/hour ({tier} tier). "
|
|
357
|
+
f"Try again in ~{wait_minutes} minute(s), or increase the "
|
|
358
|
+
f"limit in ~/.delimit/rate_limits.yml"
|
|
359
|
+
),
|
|
360
|
+
"tool": tool_name,
|
|
361
|
+
"tier": tier,
|
|
362
|
+
"calls_used": count,
|
|
363
|
+
"calls_limit": limit,
|
|
364
|
+
"retry_after_seconds": wait_seconds,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# 3. Prospective cost check — would this call push us over?
|
|
368
|
+
estimated_cost = self._get_cost_estimate(tool_name)
|
|
369
|
+
if estimated_cost > 0 and (self._session_cost + estimated_cost) > self.session_cost_cap:
|
|
370
|
+
return {
|
|
371
|
+
"error": "session_cost_would_exceed",
|
|
372
|
+
"message": (
|
|
373
|
+
f"Executing '{tool_name}' (~${estimated_cost:.4f}) would "
|
|
374
|
+
f"exceed the session cost cap "
|
|
375
|
+
f"(${self._session_cost:.3f} + ${estimated_cost:.4f} > "
|
|
376
|
+
f"${self.session_cost_cap:.2f}). "
|
|
377
|
+
"Increase session_cost_cap in ~/.delimit/rate_limits.yml "
|
|
378
|
+
"or call delimit_cost_controls to adjust."
|
|
379
|
+
),
|
|
380
|
+
"tool": tool_name,
|
|
381
|
+
"estimated_cost": estimated_cost,
|
|
382
|
+
"session_cost": round(self._session_cost, 4),
|
|
383
|
+
"session_cost_cap": self.session_cost_cap,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# 4. Warning at 80% usage
|
|
387
|
+
if count >= int(limit * WARNING_THRESHOLD) and count < limit:
|
|
388
|
+
remaining = limit - count
|
|
389
|
+
logger.warning(
|
|
390
|
+
"Rate limit warning: '%s' at %d/%d calls/hour (%d remaining)",
|
|
391
|
+
tool_name, count, limit, remaining,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return None # Allowed
|
|
395
|
+
|
|
396
|
+
def record(self, tool_name: str, cost: Optional[float] = None) -> None:
|
|
397
|
+
"""Record a tool call and its cost.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
tool_name: The MCP tool that was called.
|
|
401
|
+
cost: Actual cost in USD. If None, uses the default estimate.
|
|
402
|
+
"""
|
|
403
|
+
now = time.time()
|
|
404
|
+
|
|
405
|
+
# Record timestamp
|
|
406
|
+
if tool_name not in self._calls:
|
|
407
|
+
self._calls[tool_name] = []
|
|
408
|
+
self._calls[tool_name].append(now)
|
|
409
|
+
|
|
410
|
+
# Record cost
|
|
411
|
+
if cost is None:
|
|
412
|
+
cost = self._get_cost_estimate(tool_name)
|
|
413
|
+
if cost > 0:
|
|
414
|
+
self._costs[tool_name] = self._costs.get(tool_name, 0.0) + cost
|
|
415
|
+
self._session_cost += cost
|
|
416
|
+
|
|
417
|
+
# Log periodic warnings
|
|
418
|
+
recent = self._prune_window(tool_name, now)
|
|
419
|
+
limit = self._get_limit(tool_name)
|
|
420
|
+
if len(recent) == int(limit * WARNING_THRESHOLD):
|
|
421
|
+
logger.warning(
|
|
422
|
+
"Rate limit 80%% reached for '%s': %d/%d calls this hour",
|
|
423
|
+
tool_name, len(recent), limit,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def get_usage(self) -> Dict[str, Any]:
|
|
427
|
+
"""Return current session usage summary.
|
|
428
|
+
|
|
429
|
+
Returns a dict with:
|
|
430
|
+
session_cost: total cost this session
|
|
431
|
+
session_cost_cap: the configured cap
|
|
432
|
+
session_duration_seconds: how long the session has been active
|
|
433
|
+
tools: {tool_name: {calls_this_hour, limit, cost, tier, remaining}}
|
|
434
|
+
"""
|
|
435
|
+
now = time.time()
|
|
436
|
+
tools_usage: Dict[str, Dict[str, Any]] = {}
|
|
437
|
+
|
|
438
|
+
# Collect all tools that have been called
|
|
439
|
+
all_tools = set(self._calls.keys()) | set(self._costs.keys())
|
|
440
|
+
|
|
441
|
+
for tool_name in sorted(all_tools):
|
|
442
|
+
recent = self._prune_window(tool_name, now)
|
|
443
|
+
limit = self._get_limit(tool_name)
|
|
444
|
+
count = len(recent)
|
|
445
|
+
tools_usage[tool_name] = {
|
|
446
|
+
"calls_this_hour": count,
|
|
447
|
+
"limit": limit,
|
|
448
|
+
"remaining": max(0, limit - count),
|
|
449
|
+
"cost_this_session": round(self._costs.get(tool_name, 0.0), 6),
|
|
450
|
+
"cost_per_call": self._get_cost_estimate(tool_name),
|
|
451
|
+
"tier": _classify_tool(tool_name),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
"session_cost": round(self._session_cost, 4),
|
|
456
|
+
"session_cost_cap": self.session_cost_cap,
|
|
457
|
+
"session_cost_remaining": round(
|
|
458
|
+
max(0, self.session_cost_cap - self._session_cost), 4
|
|
459
|
+
),
|
|
460
|
+
"session_duration_seconds": int(now - self._session_start),
|
|
461
|
+
"tools": tools_usage,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
def get_quota(self, tool_name: str) -> Dict[str, Any]:
|
|
465
|
+
"""Return quota info for a single tool."""
|
|
466
|
+
now = time.time()
|
|
467
|
+
recent = self._prune_window(tool_name, now)
|
|
468
|
+
limit = self._get_limit(tool_name)
|
|
469
|
+
count = len(recent)
|
|
470
|
+
return {
|
|
471
|
+
"tool": tool_name,
|
|
472
|
+
"tier": _classify_tool(tool_name),
|
|
473
|
+
"calls_this_hour": count,
|
|
474
|
+
"limit": limit,
|
|
475
|
+
"remaining": max(0, limit - count),
|
|
476
|
+
"cost_this_session": round(self._costs.get(tool_name, 0.0), 6),
|
|
477
|
+
"cost_per_call": self._get_cost_estimate(tool_name),
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
def set_limit(self, tool_name: str, limit: int) -> None:
|
|
481
|
+
"""Override the hourly limit for a tool (session-scoped, not persisted)."""
|
|
482
|
+
if limit < 0:
|
|
483
|
+
raise ValueError("Limit must be non-negative")
|
|
484
|
+
self._custom_limits[tool_name] = limit
|
|
485
|
+
logger.info("Rate limit for '%s' set to %d calls/hour", tool_name, limit)
|
|
486
|
+
|
|
487
|
+
def set_session_cost_cap(self, cap: float) -> None:
|
|
488
|
+
"""Override the session cost cap (session-scoped, not persisted)."""
|
|
489
|
+
if cap < 0:
|
|
490
|
+
raise ValueError("Cost cap must be non-negative")
|
|
491
|
+
self._config["session_cost_cap"] = cap
|
|
492
|
+
logger.info("Session cost cap set to $%.2f", cap)
|
|
493
|
+
|
|
494
|
+
def reset(self) -> None:
|
|
495
|
+
"""Reset all tracking state. Starts a fresh session."""
|
|
496
|
+
self._calls.clear()
|
|
497
|
+
self._costs.clear()
|
|
498
|
+
self._session_cost = 0.0
|
|
499
|
+
self._session_start = time.time()
|
|
500
|
+
logger.info("Rate limiter reset — new session started")
|
|
501
|
+
|
|
502
|
+
def reset_tool(self, tool_name: str) -> None:
|
|
503
|
+
"""Reset tracking for a single tool."""
|
|
504
|
+
self._calls.pop(tool_name, None)
|
|
505
|
+
cost = self._costs.pop(tool_name, 0.0)
|
|
506
|
+
self._session_cost = max(0, self._session_cost - cost)
|
|
507
|
+
logger.info("Rate limiter reset for '%s'", tool_name)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
# Module-level singleton
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
limiter = RateLimiter()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def create_cost_controls_response(
|
|
518
|
+
action: str = "status",
|
|
519
|
+
tool_name: str = "",
|
|
520
|
+
limit: Optional[int] = None,
|
|
521
|
+
cost_cap: Optional[float] = None,
|
|
522
|
+
) -> Dict[str, Any]:
|
|
523
|
+
"""Handler logic for the delimit_cost_controls MCP tool.
|
|
524
|
+
|
|
525
|
+
Actions:
|
|
526
|
+
status — show full session usage
|
|
527
|
+
quota — show quota for a specific tool
|
|
528
|
+
set — set a custom limit for a tool or session cost cap
|
|
529
|
+
reset — reset all tracking
|
|
530
|
+
"""
|
|
531
|
+
if action == "status":
|
|
532
|
+
return {
|
|
533
|
+
"status": "ok",
|
|
534
|
+
**limiter.get_usage(),
|
|
535
|
+
"hint": (
|
|
536
|
+
"Use action='quota' with tool_name to check a specific tool, "
|
|
537
|
+
"or action='set' to adjust limits."
|
|
538
|
+
),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if action == "quota":
|
|
542
|
+
if not tool_name:
|
|
543
|
+
return {"error": "tool_name is required for action='quota'"}
|
|
544
|
+
return {"status": "ok", **limiter.get_quota(tool_name)}
|
|
545
|
+
|
|
546
|
+
if action == "set":
|
|
547
|
+
changes = []
|
|
548
|
+
if tool_name and limit is not None:
|
|
549
|
+
limiter.set_limit(tool_name, limit)
|
|
550
|
+
changes.append(f"{tool_name} limit set to {limit}/hour")
|
|
551
|
+
if cost_cap is not None:
|
|
552
|
+
limiter.set_session_cost_cap(cost_cap)
|
|
553
|
+
changes.append(f"session cost cap set to ${cost_cap:.2f}")
|
|
554
|
+
if not changes:
|
|
555
|
+
return {
|
|
556
|
+
"error": "Provide tool_name+limit to set a tool limit, "
|
|
557
|
+
"or cost_cap to set the session cost cap."
|
|
558
|
+
}
|
|
559
|
+
return {"status": "ok", "changes": changes}
|
|
560
|
+
|
|
561
|
+
if action == "reset":
|
|
562
|
+
limiter.reset()
|
|
563
|
+
return {"status": "ok", "message": "All rate limit tracking reset."}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
"error": f"Unknown action '{action}'",
|
|
567
|
+
"valid_actions": ["status", "quota", "set", "reset"],
|
|
568
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delimit-cli",
|
|
3
3
|
"mcpName": "io.github.delimit-ai/delimit-mcp-server",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.13.0",
|
|
5
5
|
"description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|