security-mcp 1.1.0 → 1.1.2
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 +966 -193
- package/defaults/agent-run-schema.json +98 -0
- package/dist/ci/pr-gate.js +18 -1
- package/dist/cli/install.js +69 -2
- package/dist/cli/onboarding.js +82 -11
- package/dist/cli/update.js +83 -15
- package/dist/gate/checks/ai-redteam.js +83 -59
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/ci-pipeline.js +135 -0
- package/dist/gate/checks/crypto.js +91 -22
- package/dist/gate/checks/database.js +5 -1
- package/dist/gate/checks/dependencies.js +297 -2
- package/dist/gate/checks/dlp.js +6 -1
- package/dist/gate/checks/graphql.js +6 -1
- package/dist/gate/checks/k8s.js +229 -181
- package/dist/gate/checks/nuclei.js +133 -0
- package/dist/gate/checks/runtime.js +75 -8
- package/dist/gate/checks/scanners.js +8 -2
- package/dist/gate/diff.js +2 -0
- package/dist/gate/exceptions.js +6 -1
- package/dist/gate/policy.js +47 -4
- package/dist/gate/result.js +7 -1
- package/dist/mcp/audit-chain.js +253 -0
- package/dist/mcp/learning.js +228 -0
- package/dist/mcp/model-router.js +544 -0
- package/dist/mcp/orchestration.js +604 -0
- package/dist/mcp/server.js +160 -12
- package/dist/repo/search.js +5 -7
- package/dist/review/store.js +15 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +225 -0
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -0
- package/skills/anti-replay-tester/SKILL.md +195 -0
- package/skills/appsec-code-auditor/SKILL.md +86 -0
- package/skills/artifact-integrity-analyst/SKILL.md +68 -0
- package/skills/attack-navigator/SKILL.md +64 -0
- package/skills/auth-session-hacker/SKILL.md +87 -0
- package/skills/aws-penetration-tester/SKILL.md +60 -0
- package/skills/azure-penetration-tester/SKILL.md +64 -0
- package/skills/binary-auth-validator/SKILL.md +184 -0
- package/skills/bot-detection-specialist/SKILL.md +221 -0
- package/skills/business-logic-attacker/SKILL.md +76 -0
- package/skills/capec-code-mapper/SKILL.md +163 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
- package/skills/ciso-orchestrator/SKILL.md +165 -0
- package/skills/cloud-infra-specialist/SKILL.md +85 -0
- package/skills/compliance-gap-analyst/SKILL.md +77 -0
- package/skills/compliance-grc/SKILL.md +148 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
- package/skills/credential-stuffing-specialist/SKILL.md +192 -0
- package/skills/crypto-pki-specialist/SKILL.md +136 -0
- package/skills/csa-ccm-mapper/SKILL.md +178 -0
- package/skills/csf2-governance-mapper/SKILL.md +159 -0
- package/skills/deep-link-fuzzer/SKILL.md +195 -0
- package/skills/dependency-confusion-attacker/SKILL.md +78 -0
- package/skills/device-integrity-aggregator/SKILL.md +221 -0
- package/skills/dos-resilience-tester/SKILL.md +184 -0
- package/skills/dread-scorer/SKILL.md +157 -0
- package/skills/egress-policy-enforcer/SKILL.md +208 -0
- package/skills/evidence-collector/SKILL.md +86 -0
- package/skills/file-upload-attacker/SKILL.md +208 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -0
- package/skills/git-history-secret-scanner/SKILL.md +182 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
- package/skills/incident-responder/SKILL.md +192 -0
- package/skills/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/json-ambiguity-tester/SKILL.md +175 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/kill-switch-engineer/SKILL.md +205 -0
- package/skills/linddun-privacy-analyst/SKILL.md +196 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-binary-hardener/SKILL.md +199 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/mobile-webview-auditor/SKILL.md +200 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -0
- package/skills/multipart-abuse-tester/SKILL.md +146 -0
- package/skills/oauth-pkce-specialist/SKILL.md +191 -0
- package/skills/parser-exhaustion-tester/SKILL.md +177 -0
- package/skills/pentest-infra/SKILL.md +69 -0
- package/skills/pentest-social/SKILL.md +72 -0
- package/skills/pentest-team/SKILL.md +126 -0
- package/skills/pentest-web-api/SKILL.md +71 -0
- package/skills/privacy-flow-analyst/SKILL.md +70 -0
- package/skills/prompt-injection-specialist/SKILL.md +76 -0
- package/skills/quantum-migration-planner/SKILL.md +184 -0
- package/skills/rag-poisoning-specialist/SKILL.md +71 -0
- package/skills/registry-mirror-enforcer/SKILL.md +142 -0
- package/skills/rotation-validation-agent/SKILL.md +188 -0
- package/skills/samm-assessor/SKILL.md +168 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
- package/skills/senior-security-engineer/SKILL.md +42 -12
- package/skills/serialization-memory-attacker/SKILL.md +78 -0
- package/skills/session-timeout-tester/SKILL.md +197 -0
- package/skills/slsa-level3-enforcer/SKILL.md +185 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
- package/skills/ssrf-detection-validator/SKILL.md +229 -0
- package/skills/step-up-auth-enforcer/SKILL.md +176 -0
- package/skills/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -0
- package/skills/token-reuse-detector/SKILL.md +203 -0
- package/skills/trike-risk-modeler/SKILL.md +139 -0
- package/skills/unicode-homograph-tester/SKILL.md +179 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
- package/skills/webhook-security-tester/SKILL.md +184 -0
- package/skills/zero-trust-architect/SKILL.md +211 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Router — multi-provider smart routing with automatic failover and cost-based selection.
|
|
3
|
+
*
|
|
4
|
+
* Providers: Anthropic (Claude), OpenAI (GPT), Google (Gemini), Cohere, Local (Ollama/Llama).
|
|
5
|
+
*
|
|
6
|
+
* Routing logic:
|
|
7
|
+
* 1. Map task type to minimum capability tier (light | standard | advanced).
|
|
8
|
+
* 2. Collect all provider models meeting that capability floor.
|
|
9
|
+
* 3. Filter out providers whose circuit breaker is open (recent failures).
|
|
10
|
+
* 4. Sort candidates by combined input+output pricing — cheapest first.
|
|
11
|
+
* 5. Return cheapest healthy candidate.
|
|
12
|
+
* 6. If ALL providers are unhealthy, fall back best-effort (circuit ignored).
|
|
13
|
+
*
|
|
14
|
+
* Failover: provider-level circuit breaker opens after 3 consecutive failures,
|
|
15
|
+
* stays open for 60 seconds. Closed automatically after the cooldown expires.
|
|
16
|
+
*
|
|
17
|
+
* Budget circuit breaker: reads max_total_cost_usd from security-policy.json.
|
|
18
|
+
*
|
|
19
|
+
* Backward compatibility: ModelTier ("haiku" | "sonnet") is preserved for
|
|
20
|
+
* UsageRecord and existing callers. light → haiku, standard/advanced → sonnet.
|
|
21
|
+
*
|
|
22
|
+
* Usage and health state persist to:
|
|
23
|
+
* .mcp/memory/model-usage.json — token usage + spend
|
|
24
|
+
* .mcp/memory/provider-health.json — circuit breaker state
|
|
25
|
+
*/
|
|
26
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
|
|
33
|
+
export const SONNET_MODEL = "claude-sonnet-4-6";
|
|
34
|
+
const MEMORY_DIR = join(".mcp", "memory");
|
|
35
|
+
const USAGE_FILE = join(MEMORY_DIR, "model-usage.json");
|
|
36
|
+
const HEALTH_FILE = join(MEMORY_DIR, "provider-health.json");
|
|
37
|
+
const POLICY_FILE = join(".mcp", "policies", "security-policy.json");
|
|
38
|
+
const DEFAULT_BUDGET_USD = 5.0;
|
|
39
|
+
const CIRCUIT_BREAKER_THRESHOLD = 3; // failures before circuit opens
|
|
40
|
+
const CIRCUIT_BREAKER_COOLDOWN_MS = 60_000; // 60 seconds
|
|
41
|
+
/**
|
|
42
|
+
* Full model registry across all providers.
|
|
43
|
+
* Pricing sourced from public pricing pages (approximate, for routing decisions only).
|
|
44
|
+
* Local models cost $0 but require Ollama running at localhost:11434.
|
|
45
|
+
*/
|
|
46
|
+
export const MODEL_REGISTRY = [
|
|
47
|
+
// Anthropic — Claude
|
|
48
|
+
{
|
|
49
|
+
modelId: "claude-haiku-4-5-20251001",
|
|
50
|
+
provider: "anthropic",
|
|
51
|
+
capabilityTier: "light",
|
|
52
|
+
inputPer1M: 0.25,
|
|
53
|
+
outputPer1M: 1.25,
|
|
54
|
+
label: "Claude Haiku 4.5"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
modelId: "claude-sonnet-4-6",
|
|
58
|
+
provider: "anthropic",
|
|
59
|
+
capabilityTier: "standard",
|
|
60
|
+
inputPer1M: 3.0,
|
|
61
|
+
outputPer1M: 15.0,
|
|
62
|
+
label: "Claude Sonnet 4.6"
|
|
63
|
+
},
|
|
64
|
+
// OpenAI — GPT
|
|
65
|
+
{
|
|
66
|
+
modelId: "gpt-4o-mini",
|
|
67
|
+
provider: "openai",
|
|
68
|
+
capabilityTier: "light",
|
|
69
|
+
inputPer1M: 0.15,
|
|
70
|
+
outputPer1M: 0.60,
|
|
71
|
+
label: "GPT-4o Mini"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
modelId: "gpt-4o",
|
|
75
|
+
provider: "openai",
|
|
76
|
+
capabilityTier: "standard",
|
|
77
|
+
inputPer1M: 2.50,
|
|
78
|
+
outputPer1M: 10.0,
|
|
79
|
+
label: "GPT-4o"
|
|
80
|
+
},
|
|
81
|
+
// Google — Gemini
|
|
82
|
+
{
|
|
83
|
+
modelId: "gemini-1.5-flash",
|
|
84
|
+
provider: "google",
|
|
85
|
+
capabilityTier: "light",
|
|
86
|
+
inputPer1M: 0.075,
|
|
87
|
+
outputPer1M: 0.30,
|
|
88
|
+
label: "Gemini 1.5 Flash"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
modelId: "gemini-1.5-pro",
|
|
92
|
+
provider: "google",
|
|
93
|
+
capabilityTier: "standard",
|
|
94
|
+
inputPer1M: 1.25,
|
|
95
|
+
outputPer1M: 5.0,
|
|
96
|
+
label: "Gemini 1.5 Pro"
|
|
97
|
+
},
|
|
98
|
+
// Cohere — Command R
|
|
99
|
+
{
|
|
100
|
+
modelId: "command-r",
|
|
101
|
+
provider: "cohere",
|
|
102
|
+
capabilityTier: "light",
|
|
103
|
+
inputPer1M: 0.15,
|
|
104
|
+
outputPer1M: 0.60,
|
|
105
|
+
label: "Command R"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
modelId: "command-r-plus",
|
|
109
|
+
provider: "cohere",
|
|
110
|
+
capabilityTier: "standard",
|
|
111
|
+
inputPer1M: 2.50,
|
|
112
|
+
outputPer1M: 10.0,
|
|
113
|
+
label: "Command R+"
|
|
114
|
+
},
|
|
115
|
+
// Local — Ollama (zero cost, requires Ollama at localhost:11434)
|
|
116
|
+
{
|
|
117
|
+
modelId: "llama3",
|
|
118
|
+
provider: "local",
|
|
119
|
+
capabilityTier: "light",
|
|
120
|
+
inputPer1M: 0,
|
|
121
|
+
outputPer1M: 0,
|
|
122
|
+
label: "Llama 3 8B (local)",
|
|
123
|
+
baseUrl: "http://localhost:11434"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
modelId: "llama3:70b",
|
|
127
|
+
provider: "local",
|
|
128
|
+
capabilityTier: "standard",
|
|
129
|
+
inputPer1M: 0,
|
|
130
|
+
outputPer1M: 0,
|
|
131
|
+
label: "Llama 3 70B (local)",
|
|
132
|
+
baseUrl: "http://localhost:11434"
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
/** Minimum capability tier required per task. */
|
|
136
|
+
export const TASK_CAPABILITY_MAP = {
|
|
137
|
+
pattern_match: "light",
|
|
138
|
+
manifest_scan: "light",
|
|
139
|
+
evidence_collection: "light",
|
|
140
|
+
lockfile_parse: "light",
|
|
141
|
+
dlp_scan: "light",
|
|
142
|
+
config_read: "light",
|
|
143
|
+
dependency_scan: "light",
|
|
144
|
+
secret_scan: "light",
|
|
145
|
+
code_review: "standard",
|
|
146
|
+
remediation: "standard",
|
|
147
|
+
threat_model: "standard",
|
|
148
|
+
compliance_analysis: "standard",
|
|
149
|
+
exploit_chain: "standard",
|
|
150
|
+
ai_redteam: "standard",
|
|
151
|
+
pentest: "standard",
|
|
152
|
+
crypto_analysis: "standard",
|
|
153
|
+
auth_analysis: "standard",
|
|
154
|
+
incident_response: "standard",
|
|
155
|
+
risk_scoring: "standard",
|
|
156
|
+
report_generation: "standard"
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Legacy map — kept for backward compatibility with existing callers.
|
|
160
|
+
* Maps task type to ModelTier label.
|
|
161
|
+
*/
|
|
162
|
+
export const TASK_TIER_MAP = {
|
|
163
|
+
pattern_match: "haiku",
|
|
164
|
+
manifest_scan: "haiku",
|
|
165
|
+
evidence_collection: "haiku",
|
|
166
|
+
lockfile_parse: "haiku",
|
|
167
|
+
dlp_scan: "haiku",
|
|
168
|
+
config_read: "haiku",
|
|
169
|
+
dependency_scan: "haiku",
|
|
170
|
+
secret_scan: "haiku",
|
|
171
|
+
code_review: "sonnet",
|
|
172
|
+
remediation: "sonnet",
|
|
173
|
+
threat_model: "sonnet",
|
|
174
|
+
compliance_analysis: "sonnet",
|
|
175
|
+
exploit_chain: "sonnet",
|
|
176
|
+
ai_redteam: "sonnet",
|
|
177
|
+
pentest: "sonnet",
|
|
178
|
+
crypto_analysis: "sonnet",
|
|
179
|
+
auth_analysis: "sonnet",
|
|
180
|
+
incident_response: "sonnet",
|
|
181
|
+
risk_scoring: "sonnet",
|
|
182
|
+
report_generation: "sonnet"
|
|
183
|
+
};
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Storage helpers
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
async function ensureMemoryDir() {
|
|
188
|
+
await mkdir(MEMORY_DIR, { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
async function loadUsageStore() {
|
|
191
|
+
try {
|
|
192
|
+
const raw = await readFile(USAGE_FILE, "utf-8");
|
|
193
|
+
return JSON.parse(raw);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return { version: 1, updatedAt: new Date().toISOString(), totalSpentUsd: 0, records: [] };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function saveUsageStore(store) {
|
|
200
|
+
await ensureMemoryDir();
|
|
201
|
+
store.updatedAt = new Date().toISOString();
|
|
202
|
+
await writeFile(USAGE_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
|
|
203
|
+
}
|
|
204
|
+
async function loadHealthStore() {
|
|
205
|
+
try {
|
|
206
|
+
const raw = await readFile(HEALTH_FILE, "utf-8");
|
|
207
|
+
return JSON.parse(raw);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return { version: 1, updatedAt: new Date().toISOString(), providers: {} };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function saveHealthStore(store) {
|
|
214
|
+
await ensureMemoryDir();
|
|
215
|
+
store.updatedAt = new Date().toISOString();
|
|
216
|
+
await writeFile(HEALTH_FILE, JSON.stringify(store, null, 2) + "\n", "utf-8");
|
|
217
|
+
}
|
|
218
|
+
async function loadMaxBudget() {
|
|
219
|
+
try {
|
|
220
|
+
const raw = await readFile(POLICY_FILE, "utf-8");
|
|
221
|
+
const policy = JSON.parse(raw);
|
|
222
|
+
return policy.model_budget?.max_total_cost_usd ?? DEFAULT_BUDGET_USD;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return DEFAULT_BUDGET_USD;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function loadPreferredProviders() {
|
|
229
|
+
try {
|
|
230
|
+
const raw = await readFile(POLICY_FILE, "utf-8");
|
|
231
|
+
const policy = JSON.parse(raw);
|
|
232
|
+
return policy.model_budget?.preferred_providers ?? null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Circuit breaker helpers
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
function isCircuitOpen(state) {
|
|
242
|
+
if (!state)
|
|
243
|
+
return false;
|
|
244
|
+
if (!state.circuitOpenUntil)
|
|
245
|
+
return false;
|
|
246
|
+
return new Date(state.circuitOpenUntil) > new Date();
|
|
247
|
+
}
|
|
248
|
+
function capabilityTierRank(tier) {
|
|
249
|
+
return { light: 0, standard: 1, advanced: 2 }[tier];
|
|
250
|
+
}
|
|
251
|
+
function meetsCapabilityFloor(model, required) {
|
|
252
|
+
return capabilityTierRank(model.capabilityTier) >= capabilityTierRank(required);
|
|
253
|
+
}
|
|
254
|
+
function combinedCost(model) {
|
|
255
|
+
// Weighted: input 80%, output 20% — typical for security scan workloads.
|
|
256
|
+
return model.inputPer1M * 0.8 + model.outputPer1M * 0.2;
|
|
257
|
+
}
|
|
258
|
+
function legacyTier(capTier) {
|
|
259
|
+
return capTier === "light" ? "haiku" : "sonnet";
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Core routing function
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
/**
|
|
265
|
+
* Select the cheapest healthy model that meets the capability requirement for
|
|
266
|
+
* the given task type. Respects preferred_providers policy and circuit breakers.
|
|
267
|
+
*
|
|
268
|
+
* @param requiredTier Minimum capability tier for the task.
|
|
269
|
+
* @param health Current provider health store.
|
|
270
|
+
* @param preferred Optional ordered list of preferred providers.
|
|
271
|
+
* @returns [chosen model, failoverUsed]
|
|
272
|
+
*/
|
|
273
|
+
function selectModel(requiredTier, health, preferred) {
|
|
274
|
+
// Candidates: all models meeting the capability floor.
|
|
275
|
+
const candidates = MODEL_REGISTRY.filter((m) => meetsCapabilityFloor(m, requiredTier));
|
|
276
|
+
// Separate healthy vs. circuit-open providers.
|
|
277
|
+
const healthy = candidates.filter((m) => !isCircuitOpen(health.providers[m.provider]));
|
|
278
|
+
const pool = healthy.length > 0 ? healthy : candidates; // fallback: ignore circuit if all unhealthy
|
|
279
|
+
const failoverUsed = healthy.length > 0 && healthy.length < candidates.length;
|
|
280
|
+
// Apply preferred provider ordering if set in policy.
|
|
281
|
+
let sorted;
|
|
282
|
+
if (preferred && preferred.length > 0) {
|
|
283
|
+
// Among preferred providers first, then others; within each group sort by cost.
|
|
284
|
+
const preferredPool = pool.filter((m) => preferred.includes(m.provider));
|
|
285
|
+
const otherPool = pool.filter((m) => !preferred.includes(m.provider));
|
|
286
|
+
preferredPool.sort((a, b) => combinedCost(a) - combinedCost(b));
|
|
287
|
+
otherPool.sort((a, b) => combinedCost(a) - combinedCost(b));
|
|
288
|
+
sorted = [...preferredPool, ...otherPool];
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// Default: pure cost-based sort (cheapest first).
|
|
292
|
+
sorted = [...pool].sort((a, b) => combinedCost(a) - combinedCost(b));
|
|
293
|
+
}
|
|
294
|
+
// Should always have at least one candidate given the registry.
|
|
295
|
+
const chosen = sorted[0] ?? MODEL_REGISTRY.find((m) => m.provider === "anthropic" && m.capabilityTier === "standard");
|
|
296
|
+
return [chosen, failoverUsed];
|
|
297
|
+
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Public API
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
/**
|
|
302
|
+
* Return the recommended model for a given task type using multi-provider smart routing.
|
|
303
|
+
* Selects the cheapest healthy provider model meeting the capability requirement.
|
|
304
|
+
* Falls back to next-cheapest provider on circuit breaker open.
|
|
305
|
+
*/
|
|
306
|
+
export async function getModelForTask(taskType, _opts) {
|
|
307
|
+
const [store, health, maxBudget, preferred] = await Promise.all([
|
|
308
|
+
loadUsageStore(),
|
|
309
|
+
loadHealthStore(),
|
|
310
|
+
loadMaxBudget(),
|
|
311
|
+
loadPreferredProviders()
|
|
312
|
+
]);
|
|
313
|
+
const requiredTier = TASK_CAPABILITY_MAP[taskType];
|
|
314
|
+
const [chosen, failoverUsed] = selectModel(requiredTier, health, preferred);
|
|
315
|
+
const spent = store.totalSpentUsd;
|
|
316
|
+
const remaining = maxBudget - spent;
|
|
317
|
+
const utilizationPct = maxBudget > 0 ? (spent / maxBudget) * 100 : 0;
|
|
318
|
+
let budgetStatus;
|
|
319
|
+
if (remaining <= 0) {
|
|
320
|
+
budgetStatus = "exceeded";
|
|
321
|
+
}
|
|
322
|
+
else if (utilizationPct >= 80) {
|
|
323
|
+
budgetStatus = "warning";
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
budgetStatus = "ok";
|
|
327
|
+
}
|
|
328
|
+
const rationale = buildRationale(taskType, requiredTier, chosen, failoverUsed, preferred);
|
|
329
|
+
return {
|
|
330
|
+
model: chosen.modelId,
|
|
331
|
+
provider: chosen.provider,
|
|
332
|
+
tier: legacyTier(chosen.capabilityTier),
|
|
333
|
+
capabilityTier: chosen.capabilityTier,
|
|
334
|
+
taskType,
|
|
335
|
+
rationale,
|
|
336
|
+
estimatedInputCostPer1MTokens: chosen.inputPer1M,
|
|
337
|
+
estimatedOutputCostPer1MTokens: chosen.outputPer1M,
|
|
338
|
+
budgetStatus,
|
|
339
|
+
remainingBudgetUsd: maxBudget > 0 ? Math.max(0, remaining) : null,
|
|
340
|
+
failoverUsed,
|
|
341
|
+
...(chosen.baseUrl ? { baseUrl: chosen.baseUrl } : {})
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function buildRationale(taskType, required, chosen, failoverUsed, preferred) {
|
|
345
|
+
const costNote = chosen.inputPer1M === 0
|
|
346
|
+
? "free (local)"
|
|
347
|
+
: `$${chosen.inputPer1M}/$${chosen.outputPer1M} per 1M in/out`;
|
|
348
|
+
const prefNote = preferred ? ` (preferred: ${preferred.join(", ")})` : "";
|
|
349
|
+
const failNote = failoverUsed ? " [failover — primary provider circuit open]" : "";
|
|
350
|
+
return (`Task "${taskType}" requires "${required}" tier${prefNote}. ` +
|
|
351
|
+
`Selected ${chosen.label} (${chosen.provider}): ${costNote}, cheapest healthy match.${failNote}`);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Record actual token usage after a model call completes.
|
|
355
|
+
* Updates the running total and per-provider spend breakdown.
|
|
356
|
+
* Resets circuit breaker failure count for successful provider calls.
|
|
357
|
+
*/
|
|
358
|
+
export async function trackUsage(usage) {
|
|
359
|
+
const [store, health] = await Promise.all([loadUsageStore(), loadHealthStore()]);
|
|
360
|
+
const model = MODEL_REGISTRY.find((m) => m.modelId === usage.model);
|
|
361
|
+
const inputRate = model?.inputPer1M ?? (usage.tier === "haiku" ? 0.25 : 3.0);
|
|
362
|
+
const outputRate = model?.outputPer1M ?? (usage.tier === "haiku" ? 1.25 : 15.0);
|
|
363
|
+
const estimatedCost = (usage.inputTokens / 1_000_000) * inputRate +
|
|
364
|
+
(usage.outputTokens / 1_000_000) * outputRate;
|
|
365
|
+
const record = {
|
|
366
|
+
...usage,
|
|
367
|
+
estimatedCostUsd: estimatedCost,
|
|
368
|
+
timestamp: new Date().toISOString()
|
|
369
|
+
};
|
|
370
|
+
store.records.push(record);
|
|
371
|
+
store.totalSpentUsd = store.records.reduce((sum, r) => sum + r.estimatedCostUsd, 0);
|
|
372
|
+
if (store.records.length > 500) {
|
|
373
|
+
store.records = store.records.slice(-500);
|
|
374
|
+
}
|
|
375
|
+
// Successful call: reset consecutive failures for this provider.
|
|
376
|
+
const providerKey = usage.provider ?? "anthropic";
|
|
377
|
+
const providerState = health.providers[providerKey] ?? {
|
|
378
|
+
consecutiveFailures: 0,
|
|
379
|
+
lastFailureAt: null,
|
|
380
|
+
circuitOpenUntil: null,
|
|
381
|
+
totalCallsTracked: 0
|
|
382
|
+
};
|
|
383
|
+
providerState.consecutiveFailures = 0;
|
|
384
|
+
providerState.circuitOpenUntil = null;
|
|
385
|
+
providerState.totalCallsTracked = (providerState.totalCallsTracked ?? 0) + 1;
|
|
386
|
+
health.providers[providerKey] = providerState;
|
|
387
|
+
await Promise.all([saveUsageStore(store), saveHealthStore(health)]);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Record a provider failure (connection error, rate limit, auth failure).
|
|
391
|
+
* Opens circuit breaker after CIRCUIT_BREAKER_THRESHOLD consecutive failures.
|
|
392
|
+
*/
|
|
393
|
+
export async function recordProviderFailure(provider) {
|
|
394
|
+
const health = await loadHealthStore();
|
|
395
|
+
const now = new Date();
|
|
396
|
+
const state = health.providers[provider] ?? {
|
|
397
|
+
consecutiveFailures: 0,
|
|
398
|
+
lastFailureAt: null,
|
|
399
|
+
circuitOpenUntil: null,
|
|
400
|
+
totalCallsTracked: 0
|
|
401
|
+
};
|
|
402
|
+
state.consecutiveFailures += 1;
|
|
403
|
+
state.lastFailureAt = now.toISOString();
|
|
404
|
+
if (state.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
405
|
+
const openUntil = new Date(now.getTime() + CIRCUIT_BREAKER_COOLDOWN_MS);
|
|
406
|
+
state.circuitOpenUntil = openUntil.toISOString();
|
|
407
|
+
}
|
|
408
|
+
health.providers[provider] = state;
|
|
409
|
+
await saveHealthStore(health);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Return health status for all providers — circuit breaker state and call counts.
|
|
413
|
+
*/
|
|
414
|
+
export async function getProviderHealth() {
|
|
415
|
+
const [health, usageStore] = await Promise.all([loadHealthStore(), loadUsageStore()]);
|
|
416
|
+
const providers = ["anthropic", "openai", "google", "cohere", "local"];
|
|
417
|
+
return providers.map((p) => {
|
|
418
|
+
const state = health.providers[p];
|
|
419
|
+
const circuitOpen = isCircuitOpen(state);
|
|
420
|
+
const calls = usageStore.records.filter((r) => r.provider === p).length;
|
|
421
|
+
return {
|
|
422
|
+
provider: p,
|
|
423
|
+
healthy: !circuitOpen,
|
|
424
|
+
consecutiveFailures: state?.consecutiveFailures ?? 0,
|
|
425
|
+
lastFailureAt: state?.lastFailureAt ?? null,
|
|
426
|
+
circuitOpenUntil: state?.circuitOpenUntil ?? null,
|
|
427
|
+
totalCallsTracked: state?.totalCallsTracked ?? calls
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Manually reset (close) the circuit breaker for a provider.
|
|
433
|
+
*/
|
|
434
|
+
export async function resetProviderCircuit(provider) {
|
|
435
|
+
const health = await loadHealthStore();
|
|
436
|
+
if (health.providers[provider]) {
|
|
437
|
+
health.providers[provider].consecutiveFailures = 0;
|
|
438
|
+
health.providers[provider].circuitOpenUntil = null;
|
|
439
|
+
}
|
|
440
|
+
await saveHealthStore(health);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Return a full budget status report, including per-provider breakdown.
|
|
444
|
+
*/
|
|
445
|
+
export async function getBudgetStatus() {
|
|
446
|
+
const store = await loadUsageStore();
|
|
447
|
+
const maxBudget = await loadMaxBudget();
|
|
448
|
+
const spent = store.totalSpentUsd;
|
|
449
|
+
const remaining = Math.max(0, maxBudget - spent);
|
|
450
|
+
const utilizationPct = maxBudget > 0 ? Math.round((spent / maxBudget) * 100) : 0;
|
|
451
|
+
let status;
|
|
452
|
+
if (remaining <= 0) {
|
|
453
|
+
status = "exceeded";
|
|
454
|
+
}
|
|
455
|
+
else if (utilizationPct >= 80) {
|
|
456
|
+
status = "warning";
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
status = "ok";
|
|
460
|
+
}
|
|
461
|
+
const haikuCalls = store.records.filter((r) => r.tier === "haiku").length;
|
|
462
|
+
const sonnetCalls = store.records.filter((r) => r.tier === "sonnet").length;
|
|
463
|
+
const breakdownByTaskType = {};
|
|
464
|
+
const breakdownByProvider = {};
|
|
465
|
+
for (const record of store.records) {
|
|
466
|
+
// By task type
|
|
467
|
+
const byTask = breakdownByTaskType[record.taskType] ?? { calls: 0, estimatedCostUsd: 0 };
|
|
468
|
+
byTask.calls += 1;
|
|
469
|
+
byTask.estimatedCostUsd += record.estimatedCostUsd;
|
|
470
|
+
breakdownByTaskType[record.taskType] = byTask;
|
|
471
|
+
// By provider
|
|
472
|
+
const provKey = record.provider ?? "anthropic";
|
|
473
|
+
const byProv = breakdownByProvider[provKey] ?? { calls: 0, estimatedCostUsd: 0 };
|
|
474
|
+
byProv.calls += 1;
|
|
475
|
+
byProv.estimatedCostUsd += record.estimatedCostUsd;
|
|
476
|
+
breakdownByProvider[provKey] = byProv;
|
|
477
|
+
}
|
|
478
|
+
for (const key of Object.keys(breakdownByTaskType)) {
|
|
479
|
+
breakdownByTaskType[key].estimatedCostUsd =
|
|
480
|
+
Math.round(breakdownByTaskType[key].estimatedCostUsd * 10000) / 10000;
|
|
481
|
+
}
|
|
482
|
+
for (const key of Object.keys(breakdownByProvider)) {
|
|
483
|
+
breakdownByProvider[key].estimatedCostUsd =
|
|
484
|
+
Math.round(breakdownByProvider[key].estimatedCostUsd * 10000) / 10000;
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
maxBudgetUsd: maxBudget,
|
|
488
|
+
spentUsd: Math.round(spent * 10000) / 10000,
|
|
489
|
+
remainingUsd: Math.round(remaining * 10000) / 10000,
|
|
490
|
+
utilizationPct,
|
|
491
|
+
status,
|
|
492
|
+
haikuCalls,
|
|
493
|
+
sonnetCalls,
|
|
494
|
+
totalCalls: store.records.length,
|
|
495
|
+
breakdownByTaskType,
|
|
496
|
+
breakdownByProvider,
|
|
497
|
+
recentUsage: store.records.slice(-10)
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// Zod schemas for MCP tool params
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
const TASK_TYPE_VALUES = [
|
|
504
|
+
"pattern_match", "manifest_scan", "evidence_collection", "lockfile_parse",
|
|
505
|
+
"dlp_scan", "config_read", "dependency_scan", "secret_scan",
|
|
506
|
+
"code_review", "remediation", "threat_model", "compliance_analysis",
|
|
507
|
+
"exploit_chain", "ai_redteam", "pentest", "crypto_analysis",
|
|
508
|
+
"auth_analysis", "incident_response", "risk_scoring", "report_generation"
|
|
509
|
+
];
|
|
510
|
+
export const GetModelForTaskParams = {
|
|
511
|
+
taskType: z
|
|
512
|
+
.enum(TASK_TYPE_VALUES)
|
|
513
|
+
.describe("Task type to route. Read-only/pattern tasks → cheapest light-tier model. " +
|
|
514
|
+
"Reasoning/remediation → cheapest standard-tier model. " +
|
|
515
|
+
"Routing picks the cheapest healthy provider meeting the capability floor."),
|
|
516
|
+
agentName: z.string().min(1).max(128).optional().describe("Optional agent name for usage tracking."),
|
|
517
|
+
agentRunId: z.string().optional().describe("Optional agent run ID for correlating usage to a run.")
|
|
518
|
+
};
|
|
519
|
+
export const GetModelForTaskSchema = z.object(GetModelForTaskParams);
|
|
520
|
+
export const TrackUsageParams = {
|
|
521
|
+
taskType: z.enum(TASK_TYPE_VALUES).describe("Task type that was executed."),
|
|
522
|
+
model: z.string().describe("Model ID used (e.g. claude-sonnet-4-6, gpt-4o, gemini-1.5-pro)."),
|
|
523
|
+
provider: z
|
|
524
|
+
.enum(["anthropic", "openai", "google", "cohere", "local"])
|
|
525
|
+
.describe("Provider that handled the call."),
|
|
526
|
+
tier: z.enum(["haiku", "sonnet"]).describe("Legacy model tier label (haiku=light, sonnet=standard)."),
|
|
527
|
+
inputTokens: z.number().int().min(0).describe("Input tokens consumed."),
|
|
528
|
+
outputTokens: z.number().int().min(0).describe("Output tokens produced."),
|
|
529
|
+
agentName: z.string().optional().describe("Agent that made the call."),
|
|
530
|
+
agentRunId: z.string().optional().describe("Agent run ID for correlation.")
|
|
531
|
+
};
|
|
532
|
+
export const TrackUsageSchema = z.object(TrackUsageParams);
|
|
533
|
+
export const RecordProviderFailureParams = {
|
|
534
|
+
provider: z
|
|
535
|
+
.enum(["anthropic", "openai", "google", "cohere", "local"])
|
|
536
|
+
.describe("Provider that failed. Increments consecutive failure count; opens circuit after 3 failures.")
|
|
537
|
+
};
|
|
538
|
+
export const RecordProviderFailureSchema = z.object(RecordProviderFailureParams);
|
|
539
|
+
export const ResetProviderCircuitParams = {
|
|
540
|
+
provider: z
|
|
541
|
+
.enum(["anthropic", "openai", "google", "cohere", "local"])
|
|
542
|
+
.describe("Provider whose circuit breaker to reset (close).")
|
|
543
|
+
};
|
|
544
|
+
export const ResetProviderCircuitSchema = z.object(ResetProviderCircuitParams);
|