@clear-capabilities/agentic-security-scanner 0.149.4 → 0.150.1
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/CHANGELOG.md +62 -0
- package/bin/agentic-security.js +469 -1
- package/dist/1310.index.js +3161 -0
- package/dist/1905.index.js +97 -2
- package/dist/4399.index.js +266 -0
- package/dist/5756.index.js +978 -0
- package/dist/6257.index.js +157 -0
- package/dist/6994.index.js +143 -0
- package/dist/7039.index.js +477 -0
- package/dist/957.index.js +127 -0
- package/dist/agentic-security.mjs +6 -6
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/discovery/disprove.js +6 -1
- package/src/discovery/hunter.js +10 -1
- package/src/discovery/llm-invoke.js +77 -0
- package/src/egress/policy.js +11 -1
- package/src/engine.js +26 -1
- package/src/llm-validator/agent-loop.js +135 -0
- package/src/llm-validator/agent-tools.js +271 -0
- package/src/llm-validator/explain-proposal.js +106 -0
- package/src/llm-validator/fix-proposal.js +136 -0
- package/src/llm-validator/index.js +51 -3
- package/src/llm-validator/model-capabilities.js +244 -0
- package/src/llm-validator/model-probe.js +194 -0
- package/src/llm-validator/model-status.js +27 -0
- package/src/llm-validator/ollama-provider.js +357 -0
- package/src/llm-validator/poc-proposal.js +122 -0
- package/src/llm-validator/providers.js +25 -0
- package/src/report/index.js +22 -0
package/dist/1905.index.js
CHANGED
|
@@ -113,6 +113,10 @@ function partitionCallGraph(callGraph, opts = {}) {
|
|
|
113
113
|
var discovery_lenses = __webpack_require__(3499);
|
|
114
114
|
// EXTERNAL MODULE: ./src/egress/policy.js
|
|
115
115
|
var policy = __webpack_require__(5712);
|
|
116
|
+
// EXTERNAL MODULE: ./src/llm-validator/providers.js
|
|
117
|
+
var providers = __webpack_require__(8947);
|
|
118
|
+
// EXTERNAL MODULE: ./src/llm-validator/ollama-provider.js
|
|
119
|
+
var ollama_provider = __webpack_require__(3837);
|
|
116
120
|
;// CONCATENATED MODULE: ./src/discovery/llm-invoke.js
|
|
117
121
|
//
|
|
118
122
|
// Shared LLM endpoint caller. Both the hunter and the refutation panel need
|
|
@@ -123,10 +127,65 @@ var policy = __webpack_require__(5712);
|
|
|
123
127
|
|
|
124
128
|
|
|
125
129
|
|
|
130
|
+
|
|
131
|
+
|
|
126
132
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
127
133
|
|
|
134
|
+
// agentic-security-ollama-offline-prd.md §32 — hunt is one of the highest-
|
|
135
|
+
// value initial Ollama use cases, and this is the single injected caller both
|
|
136
|
+
// hunter.js and disprove.js already share (per this directory's CLAUDE.md:
|
|
137
|
+
// "no other module may talk to an LLM directly"). Rather than give hunt its
|
|
138
|
+
// own separate provider-resolution copy, `defaultLlmInvoke` now checks
|
|
139
|
+
// `resolveProvider()` FIRST — but only when the caller hasn't already pinned
|
|
140
|
+
// a literal `opts.endpoint` (the multi-endpoint consensus path in this same
|
|
141
|
+
// file does exactly that, one resolved URL per voter, predating the provider
|
|
142
|
+
// abstraction; that path must keep POSTing `{prompt}` to that literal URL
|
|
143
|
+
// exactly as before, so it deliberately skips provider resolution).
|
|
144
|
+
//
|
|
145
|
+
// BACKWARD COMPATIBILITY: for every existing deployment that sets
|
|
146
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT with no PRESET, resolveProvider() resolves
|
|
147
|
+
// that to `provider: 'byo'`, not `'ollama'` — so this function falls straight
|
|
148
|
+
// through to the untouched raw-fetch path below, byte-identical to before.
|
|
149
|
+
// Only `PRESET=ollama` takes the new branch. `'hunt'` is passed as the role
|
|
150
|
+
// deliberately: it is not a member of providers.js's ROLES set, so
|
|
151
|
+
// `resolveProvider` never picks up a role-specific override
|
|
152
|
+
// (AGENTIC_SECURITY_LLM_MODEL_VALIDATE etc.) that was never meant to apply
|
|
153
|
+
// to a hunt call — only the global AGENTIC_SECURITY_LLM_PRESET/_MODEL.
|
|
128
154
|
async function defaultLlmInvoke(prompt, opts = {}) {
|
|
129
155
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
156
|
+
|
|
157
|
+
if (!opts.endpoint) {
|
|
158
|
+
const resolved = (0,providers.resolveProvider)({ role: opts.role || 'hunt' });
|
|
159
|
+
// A REFUSAL (a preset was explicitly configured and declined — e.g. a
|
|
160
|
+
// non-loopback Ollama host, or `local`'s own non-loopback refusal) must
|
|
161
|
+
// propagate here, not silently fall through to the legacy raw-endpoint
|
|
162
|
+
// path below. Falling through would mean a refused `ollama`/`local`
|
|
163
|
+
// config could still reach a network call via a leftover
|
|
164
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT — exactly the bypass PRD §23.2 exists to
|
|
165
|
+
// prevent. `resolved.reason` is non-null ONLY for a genuine refusal;
|
|
166
|
+
// "nothing configured" always carries `reason: null` (providers.js).
|
|
167
|
+
if (!resolved.ok && resolved.reason) throw new Error(resolved.reason);
|
|
168
|
+
if (resolved.ok && resolved.config.provider === 'ollama') {
|
|
169
|
+
const oc = resolved.config.ollama;
|
|
170
|
+
const r = await (0,ollama_provider/* callOllamaChat */.L5)({
|
|
171
|
+
host: resolved.config.endpoint,
|
|
172
|
+
model: resolved.config.model,
|
|
173
|
+
messages: [{ role: 'user', content: prompt }],
|
|
174
|
+
keepAlive: oc?.keepAlive,
|
|
175
|
+
timeouts: oc
|
|
176
|
+
? { connectTimeoutMs: oc.connectTimeoutMs, requestTimeoutMs: oc.requestTimeoutMs }
|
|
177
|
+
: { connectTimeoutMs: 3000, requestTimeoutMs: timeoutMs },
|
|
178
|
+
});
|
|
179
|
+
// PRD §23.4: never fall back to a cloud provider on failure — throwing
|
|
180
|
+
// here is exactly what the pre-existing raw-fetch path already does on
|
|
181
|
+
// a non-2xx/network error, and both hunter.js and disprove.js already
|
|
182
|
+
// treat a thrown/rejected llmInvoke as "this voter did not answer",
|
|
183
|
+
// never as "try something else".
|
|
184
|
+
if (!r.ok) throw new Error(`ollama ${r.code}: ${r.reason || 'request failed'}`);
|
|
185
|
+
return r.result.text;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
130
189
|
// The URL is the operator's own configured endpoint, read from an environment
|
|
131
190
|
// variable they set. Reaching it is this module's entire purpose; no
|
|
132
191
|
// request-controlled input exists anywhere on this path, and an operator who
|
|
@@ -275,6 +334,28 @@ function resolveLlmInvokeWithDecision(opts = {}) {
|
|
|
275
334
|
return { invoke, decision, decisions };
|
|
276
335
|
}
|
|
277
336
|
|
|
337
|
+
// ollama-offline-prd.md §32: PRESET=ollama is a configured provider even
|
|
338
|
+
// when no raw AGENTIC_SECURITY_LLM_ENDPOINT is set — resolve it the exact
|
|
339
|
+
// same way llm-validator/index.js's endpointConfig() does, so hunt gets
|
|
340
|
+
// the SAME egress-evaluated-before-any-call treatment every other
|
|
341
|
+
// configured provider already gets here (this function's whole reason to
|
|
342
|
+
// exist, per the block comment above). Checked BEFORE the legacy
|
|
343
|
+
// raw-endpoint fallback below, matching providers.js's own precedence
|
|
344
|
+
// (ollama/local checked before a bare BYO endpoint).
|
|
345
|
+
const resolved = (0,providers.resolveProvider)({ role: opts.role || 'hunt' });
|
|
346
|
+
if (!resolved.ok && resolved.reason) {
|
|
347
|
+
// A REFUSAL (non-loopback ollama/local, explicitly configured and
|
|
348
|
+
// declined) is itself a policy decision — same shape as an egress
|
|
349
|
+
// denial below, so callers' existing "read .reason when invoke is
|
|
350
|
+
// null" handling covers it without a new branch on their side.
|
|
351
|
+
return { invoke: null, decision: { allowed: false, reason: resolved.reason } };
|
|
352
|
+
}
|
|
353
|
+
if (resolved.ok && resolved.config.provider === 'ollama') {
|
|
354
|
+
const decision = (0,policy/* evaluateEgress */.nn)({ scanRoot: opts.scanRoot, purpose: opts.purpose || 'discovery', endpoint: resolved.config.endpoint, provider: 'ollama' });
|
|
355
|
+
if (!decision.allowed) return { invoke: null, decision };
|
|
356
|
+
return { invoke: (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs, role: opts.role }), decision };
|
|
357
|
+
}
|
|
358
|
+
|
|
278
359
|
const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
279
360
|
if (!endpoint) return { invoke: null, decision: null };
|
|
280
361
|
|
|
@@ -369,7 +450,16 @@ async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
|
|
|
369
450
|
const transcript = [];
|
|
370
451
|
const lensKey = lens?.key || 'unknown';
|
|
371
452
|
const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
|
|
372
|
-
|
|
453
|
+
// ollama-offline-prd.md §32 — "permit each refutation-panel member to be a
|
|
454
|
+
// separately configured local model" extends naturally to the hunter's own
|
|
455
|
+
// lenses: the `business-logic` lens is exactly the PRD's `logic` role
|
|
456
|
+
// ("cross-file business-logic reasoning"), so it alone routes through
|
|
457
|
+
// role='logic' (honoring AGENTIC_SECURITY_LLM_MODEL_LOGIC) while every
|
|
458
|
+
// other lens keeps the existing role='hunt' default — a caller-supplied
|
|
459
|
+
// `opts.role` still wins over both, same precedence resolveProvider
|
|
460
|
+
// already documents.
|
|
461
|
+
const role = opts.role || (lensKey === 'business-logic' ? 'logic' : 'hunt');
|
|
462
|
+
const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, role, purpose: 'discovery-hunter' });
|
|
373
463
|
|
|
374
464
|
if (typeof llmInvoke !== 'function') {
|
|
375
465
|
// FR-601: distinguish "policy denied a configured endpoint" from "nothing
|
|
@@ -520,7 +610,12 @@ async function disproveCandidate(candidate, opts = {}) {
|
|
|
520
610
|
// missing endpoint always has, so it falls straight into this module's own
|
|
521
611
|
// pre-existing rule — "silence never refutes" — with zero votes cast and no
|
|
522
612
|
// prompt ever built for a denied endpoint.
|
|
523
|
-
|
|
613
|
+
// ollama-offline-prd.md §32 — the refutation panel is the PRD's `verify`
|
|
614
|
+
// role ("adversarial verification"): route it through role='verify' by
|
|
615
|
+
// default so AGENTIC_SECURITY_LLM_MODEL_VERIFY applies, same precedence
|
|
616
|
+
// (a caller-supplied opts.role still wins) hunter.js's lens routing uses.
|
|
617
|
+
const role = opts.role || 'verify';
|
|
618
|
+
const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, role, purpose: 'discovery-disprove' });
|
|
524
619
|
|
|
525
620
|
const votes = [];
|
|
526
621
|
if (typeof llmInvoke === 'function') {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
export const id = 4399;
|
|
2
|
+
export const ids = [4399];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 4399:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ MEMORY_PROFILES: () => (/* binding */ MEMORY_PROFILES),
|
|
10
|
+
/* harmony export */ capabilitiesFromFamilyHint: () => (/* binding */ capabilitiesFromFamilyHint),
|
|
11
|
+
/* harmony export */ classifyModelFamily: () => (/* binding */ classifyModelFamily),
|
|
12
|
+
/* harmony export */ detectMemoryTier: () => (/* binding */ detectMemoryTier),
|
|
13
|
+
/* harmony export */ detectSystemMemory: () => (/* binding */ detectSystemMemory),
|
|
14
|
+
/* harmony export */ recommendAdmission: () => (/* binding */ recommendAdmission)
|
|
15
|
+
/* harmony export */ });
|
|
16
|
+
/* unused harmony exports KNOWN_MODEL_SIZE_GB, evaluateMemoryAdmission */
|
|
17
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8161);
|
|
18
|
+
// Model family hints, RAM-aware memory profiles, and the memory-admission
|
|
19
|
+
// check for the Ollama provider (agentic-security-ollama-offline-prd.md
|
|
20
|
+
// §13, §14, §15, §22.3, §30).
|
|
21
|
+
//
|
|
22
|
+
// FAMILY HINTS ARE DEFAULTS, NEVER AUTHORITY (PRD §12/§13). A name like
|
|
23
|
+
// `gemma4:e2b` tells us nothing Ollama itself won't confirm — it only lets the
|
|
24
|
+
// harness suggest a sane default before any network call. If a model actually
|
|
25
|
+
// installed under a family-hinted name lacks a capability the hint implied,
|
|
26
|
+
// the runtime probe (model-probe.js, added when tool-calling/structured-output
|
|
27
|
+
// probing lands) always wins. This module only classifies and estimates; it
|
|
28
|
+
// never asserts a capability is present.
|
|
29
|
+
//
|
|
30
|
+
// MEMORY NUMBERS ARE ESTIMATES, NOT PROMISES (PRD §22.3, §14.1). Ollama
|
|
31
|
+
// artifact sizes and this module's headroom reserves are best-effort figures
|
|
32
|
+
// sourced from what Ollama currently publishes; they exist so the harness can
|
|
33
|
+
// fail BEFORE an OS-level OOM, not so it can claim an exact answer. Every
|
|
34
|
+
// admission decision leaves a stated safety margin rather than trying to pack
|
|
35
|
+
// memory to the byte.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
// PRD §12/§13 FR-1203 — non-authoritative family hint from a model name.
|
|
40
|
+
// Longest/most-specific pattern first so `qwen3.5:4b` doesn't fall through to
|
|
41
|
+
// the bare `qwen` bucket.
|
|
42
|
+
const FAMILY_PATTERNS = [
|
|
43
|
+
[/^qwen3\.5/i, 'qwen3.5'],
|
|
44
|
+
[/^qwen3-coder-next/i, 'qwen3-coder-next'],
|
|
45
|
+
[/^qwen3-coder/i, 'qwen3-coder'],
|
|
46
|
+
[/^qwen2\.5-coder/i, 'qwen2.5-coder'],
|
|
47
|
+
[/^qwen3/i, 'qwen3'],
|
|
48
|
+
[/^qwen/i, 'qwen'],
|
|
49
|
+
[/^gemma4/i, 'gemma4'],
|
|
50
|
+
[/^functiongemma/i, 'functiongemma'],
|
|
51
|
+
[/^gemma3/i, 'gemma3'],
|
|
52
|
+
[/^gemma/i, 'gemma'],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Non-authoritative family classification for defaults/messaging only. */
|
|
56
|
+
function classifyModelFamily(modelName) {
|
|
57
|
+
const name = String(modelName || '').trim();
|
|
58
|
+
for (const [re, family] of FAMILY_PATTERNS) if (re.test(name)) return family;
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// PRD §13.1 — non-authoritative defaults per family, overridden by any real
|
|
63
|
+
// runtime probe result (model-probe.js). `tools`/`structuredJson`/`thinking`
|
|
64
|
+
// are 'unknown' where Ollama's own behavior varies by specific tag/quant
|
|
65
|
+
// rather than by family alone.
|
|
66
|
+
const FAMILY_CAPABILITY_HINTS = {
|
|
67
|
+
'qwen3.5': { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
|
|
68
|
+
qwen3: { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
|
|
69
|
+
'qwen3-coder': { chat: true, structuredJson: true, tools: true, thinking: false },
|
|
70
|
+
'qwen3-coder-next': { chat: true, structuredJson: true, tools: true, thinking: false },
|
|
71
|
+
'qwen2.5-coder': { chat: true, structuredJson: true, tools: 'unknown', thinking: false },
|
|
72
|
+
qwen: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
|
|
73
|
+
gemma4: { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
|
|
74
|
+
functiongemma: { chat: true, structuredJson: 'unknown', tools: true, thinking: false },
|
|
75
|
+
gemma3: { chat: true, structuredJson: true, tools: false, thinking: false },
|
|
76
|
+
gemma: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
|
|
77
|
+
unknown: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the PRD §13.1 ModelCapabilities object from a family hint alone
|
|
82
|
+
* (Layer B). Layer A (Ollama's own /api/show metadata) and Layer C (runtime
|
|
83
|
+
* probes) are applied by the caller and override these fields — this
|
|
84
|
+
* function only ever sets `source.familyHint: true`.
|
|
85
|
+
*/
|
|
86
|
+
function capabilitiesFromFamilyHint(modelName) {
|
|
87
|
+
const family = classifyModelFamily(modelName);
|
|
88
|
+
const hint = FAMILY_CAPABILITY_HINTS[family] || FAMILY_CAPABILITY_HINTS.unknown;
|
|
89
|
+
return {
|
|
90
|
+
chat: hint.chat,
|
|
91
|
+
structuredJson: hint.structuredJson,
|
|
92
|
+
tools: hint.tools,
|
|
93
|
+
thinking: hint.thinking,
|
|
94
|
+
vision: false,
|
|
95
|
+
contextTokens: undefined,
|
|
96
|
+
source: { metadata: false, familyHint: true, runtimeProbe: false },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── RAM-aware memory profiles (PRD §14.4, §15.2, §22.3, §30) ───────────────
|
|
101
|
+
|
|
102
|
+
const MB = 1024 * 1024;
|
|
103
|
+
const GB = 1024 * MB;
|
|
104
|
+
|
|
105
|
+
// Best-effort artifact sizes as currently distributed by Ollama, used only to
|
|
106
|
+
// pick a SENSIBLE STARTING recommendation — the real admission decision below
|
|
107
|
+
// uses actually-free memory, not this table. Keep in sync with the PRD's own
|
|
108
|
+
// cited figures; a stale entry only affects the suggested default, never the
|
|
109
|
+
// admission math (which reads real os.freemem()).
|
|
110
|
+
const KNOWN_MODEL_SIZE_GB = Object.freeze({
|
|
111
|
+
'qwen3.5:2b': 1.7,
|
|
112
|
+
'qwen3.5:4b': 3.4,
|
|
113
|
+
'qwen3.5:9b': 6.6,
|
|
114
|
+
'gemma4:e2b': 7.2,
|
|
115
|
+
'gemma4:12b': 7.6,
|
|
116
|
+
'gemma4:latest': 9.6,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** PRD §30 profile presets. `auto` picks between these by detected RAM. */
|
|
120
|
+
const MEMORY_PROFILES = Object.freeze({
|
|
121
|
+
'8gb': {
|
|
122
|
+
label: '8gb',
|
|
123
|
+
preferredModel: 'qwen3.5:4b',
|
|
124
|
+
fallbackModel: 'qwen3.5:2b',
|
|
125
|
+
initialContextTokens: 4096,
|
|
126
|
+
targetContextTokens: 8192,
|
|
127
|
+
maxConcurrency: 1,
|
|
128
|
+
minFreeRamMb: 1536,
|
|
129
|
+
},
|
|
130
|
+
'16gb-qwen': {
|
|
131
|
+
label: '16gb-qwen',
|
|
132
|
+
preferredModel: 'qwen3.5:9b',
|
|
133
|
+
fallbackModel: 'qwen3.5:4b',
|
|
134
|
+
initialContextTokens: 16384,
|
|
135
|
+
targetContextTokens: 32768,
|
|
136
|
+
maxConcurrency: 1,
|
|
137
|
+
minFreeRamMb: 2048,
|
|
138
|
+
},
|
|
139
|
+
'16gb-gemma': {
|
|
140
|
+
label: '16gb-gemma',
|
|
141
|
+
preferredModel: 'gemma4:e2b',
|
|
142
|
+
fallbackModel: 'qwen3.5:4b',
|
|
143
|
+
initialContextTokens: 8192,
|
|
144
|
+
targetContextTokens: 16384,
|
|
145
|
+
maxConcurrency: 1,
|
|
146
|
+
minFreeRamMb: 2048,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* PRD §22.3 — detect total/available system RAM. Thin wrapper over `os` so
|
|
152
|
+
* tests can inject fake values without mocking the `os` module globally.
|
|
153
|
+
*/
|
|
154
|
+
function detectSystemMemory({ totalBytes, freeBytes } = {}) {
|
|
155
|
+
return {
|
|
156
|
+
totalBytes: Number.isFinite(totalBytes) ? totalBytes : node_os__WEBPACK_IMPORTED_MODULE_0__.totalmem(),
|
|
157
|
+
freeBytes: Number.isFinite(freeBytes) ? freeBytes : node_os__WEBPACK_IMPORTED_MODULE_0__.freemem(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pick the RAM tier ('8gb' | '16gb') a machine falls into. Anything under
|
|
163
|
+
* ~9 GB total is treated as the 8 GB tier — real "8 GB" machines report
|
|
164
|
+
* slightly less than 8*1024^3 bytes to userspace (firmware/GPU reservations),
|
|
165
|
+
* so a hard `< 8*GB` cutoff would misclassify real 8 GB hardware as unknown.
|
|
166
|
+
*/
|
|
167
|
+
function detectMemoryTier(totalBytes) {
|
|
168
|
+
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return 'unknown';
|
|
169
|
+
if (totalBytes < 9 * GB) return '8gb';
|
|
170
|
+
return '16gb';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* PRD §22.3 admission algorithm: does `contextTokens` at `model` fit in
|
|
175
|
+
* currently-free memory with the configured reserve intact?
|
|
176
|
+
*
|
|
177
|
+
* This is deliberately conservative and coarse (PRD "avoid pretending memory
|
|
178
|
+
* estimates are exact"): model residency is estimated from KNOWN_MODEL_SIZE_GB
|
|
179
|
+
* when available (falling back to a pessimistic 8 GB assumption for an
|
|
180
|
+
* unrecognized tag so an unknown model never LOOKS safer than a known large
|
|
181
|
+
* one), and KV-cache growth is approximated as a fixed per-1K-token cost
|
|
182
|
+
* rather than modeled per-architecture — real KV cache size depends on layer
|
|
183
|
+
* count/head count/quantization the harness cannot know without Ollama's own
|
|
184
|
+
* runtime numbers.
|
|
185
|
+
*/
|
|
186
|
+
const ESTIMATED_KV_CACHE_MB_PER_1K_TOKENS = 32; // conservative, model-independent approximation
|
|
187
|
+
const RUNTIME_OVERHEAD_MB = 512; // Ollama server + OS scheduler slack, independent of model size
|
|
188
|
+
|
|
189
|
+
function evaluateMemoryAdmission({
|
|
190
|
+
modelName,
|
|
191
|
+
contextTokens,
|
|
192
|
+
freeBytes,
|
|
193
|
+
minFreeRamMb,
|
|
194
|
+
modelSizeGb,
|
|
195
|
+
} = {}) {
|
|
196
|
+
const sizeGb = Number.isFinite(modelSizeGb) ? modelSizeGb : (KNOWN_MODEL_SIZE_GB[modelName] ?? 8);
|
|
197
|
+
const modelMb = sizeGb * 1024;
|
|
198
|
+
const kvCacheMb = (Number(contextTokens) || 0) / 1000 * ESTIMATED_KV_CACHE_MB_PER_1K_TOKENS;
|
|
199
|
+
const requiredMb = modelMb + kvCacheMb + RUNTIME_OVERHEAD_MB + (Number(minFreeRamMb) || 0);
|
|
200
|
+
const freeMb = (Number(freeBytes) || 0) / MB;
|
|
201
|
+
const admitted = freeMb >= requiredMb;
|
|
202
|
+
return {
|
|
203
|
+
admitted,
|
|
204
|
+
freeMb: Math.round(freeMb),
|
|
205
|
+
requiredMb: Math.round(requiredMb),
|
|
206
|
+
modelEstimateMb: Math.round(modelMb),
|
|
207
|
+
kvCacheEstimateMb: Math.round(kvCacheMb),
|
|
208
|
+
reserveMb: Number(minFreeRamMb) || 0,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Full recommendation flow (PRD §22 "Memory admission algorithm"):
|
|
214
|
+
* try the profile's preferred context, shrink it, then fall back to the
|
|
215
|
+
* profile's smaller model, before ever declaring the profile unusable.
|
|
216
|
+
* Never recommends cloud — the worst outcome this function can return is
|
|
217
|
+
* `{admitted:false}` with a human-readable explanation, which callers treat
|
|
218
|
+
* as "run deterministic-only" (PRD §23.4).
|
|
219
|
+
*/
|
|
220
|
+
function recommendAdmission({ profile, freeBytes, requestedContextTokens, requestedModel } = {}) {
|
|
221
|
+
const p = MEMORY_PROFILES[profile];
|
|
222
|
+
if (!p) return { admitted: false, reason: `unknown memory profile '${profile}'` };
|
|
223
|
+
|
|
224
|
+
const model = requestedModel || p.preferredModel;
|
|
225
|
+
const attempts = [];
|
|
226
|
+
|
|
227
|
+
// 1. Requested (or target) context at the requested/preferred model.
|
|
228
|
+
const primaryContext = Number.isFinite(requestedContextTokens) ? requestedContextTokens : p.targetContextTokens;
|
|
229
|
+
let check = evaluateMemoryAdmission({ modelName: model, contextTokens: primaryContext, freeBytes, minFreeRamMb: p.minFreeRamMb });
|
|
230
|
+
attempts.push({ model, contextTokens: primaryContext, ...check });
|
|
231
|
+
if (check.admitted) return { admitted: true, model, contextTokens: primaryContext, attempts };
|
|
232
|
+
|
|
233
|
+
// 2. Reduce context to the profile's conservative initial value first —
|
|
234
|
+
// PRD FR-2104: "shrink context before declaring an otherwise compatible
|
|
235
|
+
// model unusable."
|
|
236
|
+
if (primaryContext !== p.initialContextTokens) {
|
|
237
|
+
check = evaluateMemoryAdmission({ modelName: model, contextTokens: p.initialContextTokens, freeBytes, minFreeRamMb: p.minFreeRamMb });
|
|
238
|
+
attempts.push({ model, contextTokens: p.initialContextTokens, ...check });
|
|
239
|
+
if (check.admitted) return { admitted: true, model, contextTokens: p.initialContextTokens, attempts, reducedContext: true };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 3. Fall back to the profile's smaller model at its initial context.
|
|
243
|
+
if (p.fallbackModel && p.fallbackModel !== model) {
|
|
244
|
+
check = evaluateMemoryAdmission({ modelName: p.fallbackModel, contextTokens: p.initialContextTokens, freeBytes, minFreeRamMb: p.minFreeRamMb });
|
|
245
|
+
attempts.push({ model: p.fallbackModel, contextTokens: p.initialContextTokens, ...check });
|
|
246
|
+
if (check.admitted) {
|
|
247
|
+
return {
|
|
248
|
+
admitted: true, model: p.fallbackModel, contextTokens: p.initialContextTokens, attempts,
|
|
249
|
+
reducedContext: true, fellBackToSmallerModel: true,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 4. Nothing fits — deterministic-only, never cloud.
|
|
255
|
+
return {
|
|
256
|
+
admitted: false,
|
|
257
|
+
attempts,
|
|
258
|
+
reason: `No local model/context combination fit in available memory with the configured reserve. ` +
|
|
259
|
+
`Recommend deterministic-only scanning, or free memory before retrying.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
/***/ })
|
|
265
|
+
|
|
266
|
+
};
|