@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.
@@ -0,0 +1,244 @@
1
+ // Model family hints, RAM-aware memory profiles, and the memory-admission
2
+ // check for the Ollama provider (agentic-security-ollama-offline-prd.md
3
+ // §13, §14, §15, §22.3, §30).
4
+ //
5
+ // FAMILY HINTS ARE DEFAULTS, NEVER AUTHORITY (PRD §12/§13). A name like
6
+ // `gemma4:e2b` tells us nothing Ollama itself won't confirm — it only lets the
7
+ // harness suggest a sane default before any network call. If a model actually
8
+ // installed under a family-hinted name lacks a capability the hint implied,
9
+ // the runtime probe (model-probe.js, added when tool-calling/structured-output
10
+ // probing lands) always wins. This module only classifies and estimates; it
11
+ // never asserts a capability is present.
12
+ //
13
+ // MEMORY NUMBERS ARE ESTIMATES, NOT PROMISES (PRD §22.3, §14.1). Ollama
14
+ // artifact sizes and this module's headroom reserves are best-effort figures
15
+ // sourced from what Ollama currently publishes; they exist so the harness can
16
+ // fail BEFORE an OS-level OOM, not so it can claim an exact answer. Every
17
+ // admission decision leaves a stated safety margin rather than trying to pack
18
+ // memory to the byte.
19
+
20
+ import * as os from 'node:os';
21
+
22
+ // PRD §12/§13 FR-1203 — non-authoritative family hint from a model name.
23
+ // Longest/most-specific pattern first so `qwen3.5:4b` doesn't fall through to
24
+ // the bare `qwen` bucket.
25
+ const FAMILY_PATTERNS = [
26
+ [/^qwen3\.5/i, 'qwen3.5'],
27
+ [/^qwen3-coder-next/i, 'qwen3-coder-next'],
28
+ [/^qwen3-coder/i, 'qwen3-coder'],
29
+ [/^qwen2\.5-coder/i, 'qwen2.5-coder'],
30
+ [/^qwen3/i, 'qwen3'],
31
+ [/^qwen/i, 'qwen'],
32
+ [/^gemma4/i, 'gemma4'],
33
+ [/^functiongemma/i, 'functiongemma'],
34
+ [/^gemma3/i, 'gemma3'],
35
+ [/^gemma/i, 'gemma'],
36
+ ];
37
+
38
+ /** Non-authoritative family classification for defaults/messaging only. */
39
+ export function classifyModelFamily(modelName) {
40
+ const name = String(modelName || '').trim();
41
+ for (const [re, family] of FAMILY_PATTERNS) if (re.test(name)) return family;
42
+ return 'unknown';
43
+ }
44
+
45
+ // PRD §13.1 — non-authoritative defaults per family, overridden by any real
46
+ // runtime probe result (model-probe.js). `tools`/`structuredJson`/`thinking`
47
+ // are 'unknown' where Ollama's own behavior varies by specific tag/quant
48
+ // rather than by family alone.
49
+ const FAMILY_CAPABILITY_HINTS = {
50
+ 'qwen3.5': { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
51
+ qwen3: { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
52
+ 'qwen3-coder': { chat: true, structuredJson: true, tools: true, thinking: false },
53
+ 'qwen3-coder-next': { chat: true, structuredJson: true, tools: true, thinking: false },
54
+ 'qwen2.5-coder': { chat: true, structuredJson: true, tools: 'unknown', thinking: false },
55
+ qwen: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
56
+ gemma4: { chat: true, structuredJson: true, tools: true, thinking: 'unknown' },
57
+ functiongemma: { chat: true, structuredJson: 'unknown', tools: true, thinking: false },
58
+ gemma3: { chat: true, structuredJson: true, tools: false, thinking: false },
59
+ gemma: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
60
+ unknown: { chat: true, structuredJson: 'unknown', tools: 'unknown', thinking: 'unknown' },
61
+ };
62
+
63
+ /**
64
+ * Build the PRD §13.1 ModelCapabilities object from a family hint alone
65
+ * (Layer B). Layer A (Ollama's own /api/show metadata) and Layer C (runtime
66
+ * probes) are applied by the caller and override these fields — this
67
+ * function only ever sets `source.familyHint: true`.
68
+ */
69
+ export function capabilitiesFromFamilyHint(modelName) {
70
+ const family = classifyModelFamily(modelName);
71
+ const hint = FAMILY_CAPABILITY_HINTS[family] || FAMILY_CAPABILITY_HINTS.unknown;
72
+ return {
73
+ chat: hint.chat,
74
+ structuredJson: hint.structuredJson,
75
+ tools: hint.tools,
76
+ thinking: hint.thinking,
77
+ vision: false,
78
+ contextTokens: undefined,
79
+ source: { metadata: false, familyHint: true, runtimeProbe: false },
80
+ };
81
+ }
82
+
83
+ // ── RAM-aware memory profiles (PRD §14.4, §15.2, §22.3, §30) ───────────────
84
+
85
+ const MB = 1024 * 1024;
86
+ const GB = 1024 * MB;
87
+
88
+ // Best-effort artifact sizes as currently distributed by Ollama, used only to
89
+ // pick a SENSIBLE STARTING recommendation — the real admission decision below
90
+ // uses actually-free memory, not this table. Keep in sync with the PRD's own
91
+ // cited figures; a stale entry only affects the suggested default, never the
92
+ // admission math (which reads real os.freemem()).
93
+ export const KNOWN_MODEL_SIZE_GB = Object.freeze({
94
+ 'qwen3.5:2b': 1.7,
95
+ 'qwen3.5:4b': 3.4,
96
+ 'qwen3.5:9b': 6.6,
97
+ 'gemma4:e2b': 7.2,
98
+ 'gemma4:12b': 7.6,
99
+ 'gemma4:latest': 9.6,
100
+ });
101
+
102
+ /** PRD §30 profile presets. `auto` picks between these by detected RAM. */
103
+ export const MEMORY_PROFILES = Object.freeze({
104
+ '8gb': {
105
+ label: '8gb',
106
+ preferredModel: 'qwen3.5:4b',
107
+ fallbackModel: 'qwen3.5:2b',
108
+ initialContextTokens: 4096,
109
+ targetContextTokens: 8192,
110
+ maxConcurrency: 1,
111
+ minFreeRamMb: 1536,
112
+ },
113
+ '16gb-qwen': {
114
+ label: '16gb-qwen',
115
+ preferredModel: 'qwen3.5:9b',
116
+ fallbackModel: 'qwen3.5:4b',
117
+ initialContextTokens: 16384,
118
+ targetContextTokens: 32768,
119
+ maxConcurrency: 1,
120
+ minFreeRamMb: 2048,
121
+ },
122
+ '16gb-gemma': {
123
+ label: '16gb-gemma',
124
+ preferredModel: 'gemma4:e2b',
125
+ fallbackModel: 'qwen3.5:4b',
126
+ initialContextTokens: 8192,
127
+ targetContextTokens: 16384,
128
+ maxConcurrency: 1,
129
+ minFreeRamMb: 2048,
130
+ },
131
+ });
132
+
133
+ /**
134
+ * PRD §22.3 — detect total/available system RAM. Thin wrapper over `os` so
135
+ * tests can inject fake values without mocking the `os` module globally.
136
+ */
137
+ export function detectSystemMemory({ totalBytes, freeBytes } = {}) {
138
+ return {
139
+ totalBytes: Number.isFinite(totalBytes) ? totalBytes : os.totalmem(),
140
+ freeBytes: Number.isFinite(freeBytes) ? freeBytes : os.freemem(),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Pick the RAM tier ('8gb' | '16gb') a machine falls into. Anything under
146
+ * ~9 GB total is treated as the 8 GB tier — real "8 GB" machines report
147
+ * slightly less than 8*1024^3 bytes to userspace (firmware/GPU reservations),
148
+ * so a hard `< 8*GB` cutoff would misclassify real 8 GB hardware as unknown.
149
+ */
150
+ export function detectMemoryTier(totalBytes) {
151
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) return 'unknown';
152
+ if (totalBytes < 9 * GB) return '8gb';
153
+ return '16gb';
154
+ }
155
+
156
+ /**
157
+ * PRD §22.3 admission algorithm: does `contextTokens` at `model` fit in
158
+ * currently-free memory with the configured reserve intact?
159
+ *
160
+ * This is deliberately conservative and coarse (PRD "avoid pretending memory
161
+ * estimates are exact"): model residency is estimated from KNOWN_MODEL_SIZE_GB
162
+ * when available (falling back to a pessimistic 8 GB assumption for an
163
+ * unrecognized tag so an unknown model never LOOKS safer than a known large
164
+ * one), and KV-cache growth is approximated as a fixed per-1K-token cost
165
+ * rather than modeled per-architecture — real KV cache size depends on layer
166
+ * count/head count/quantization the harness cannot know without Ollama's own
167
+ * runtime numbers.
168
+ */
169
+ const ESTIMATED_KV_CACHE_MB_PER_1K_TOKENS = 32; // conservative, model-independent approximation
170
+ const RUNTIME_OVERHEAD_MB = 512; // Ollama server + OS scheduler slack, independent of model size
171
+
172
+ export function evaluateMemoryAdmission({
173
+ modelName,
174
+ contextTokens,
175
+ freeBytes,
176
+ minFreeRamMb,
177
+ modelSizeGb,
178
+ } = {}) {
179
+ const sizeGb = Number.isFinite(modelSizeGb) ? modelSizeGb : (KNOWN_MODEL_SIZE_GB[modelName] ?? 8);
180
+ const modelMb = sizeGb * 1024;
181
+ const kvCacheMb = (Number(contextTokens) || 0) / 1000 * ESTIMATED_KV_CACHE_MB_PER_1K_TOKENS;
182
+ const requiredMb = modelMb + kvCacheMb + RUNTIME_OVERHEAD_MB + (Number(minFreeRamMb) || 0);
183
+ const freeMb = (Number(freeBytes) || 0) / MB;
184
+ const admitted = freeMb >= requiredMb;
185
+ return {
186
+ admitted,
187
+ freeMb: Math.round(freeMb),
188
+ requiredMb: Math.round(requiredMb),
189
+ modelEstimateMb: Math.round(modelMb),
190
+ kvCacheEstimateMb: Math.round(kvCacheMb),
191
+ reserveMb: Number(minFreeRamMb) || 0,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Full recommendation flow (PRD §22 "Memory admission algorithm"):
197
+ * try the profile's preferred context, shrink it, then fall back to the
198
+ * profile's smaller model, before ever declaring the profile unusable.
199
+ * Never recommends cloud — the worst outcome this function can return is
200
+ * `{admitted:false}` with a human-readable explanation, which callers treat
201
+ * as "run deterministic-only" (PRD §23.4).
202
+ */
203
+ export function recommendAdmission({ profile, freeBytes, requestedContextTokens, requestedModel } = {}) {
204
+ const p = MEMORY_PROFILES[profile];
205
+ if (!p) return { admitted: false, reason: `unknown memory profile '${profile}'` };
206
+
207
+ const model = requestedModel || p.preferredModel;
208
+ const attempts = [];
209
+
210
+ // 1. Requested (or target) context at the requested/preferred model.
211
+ const primaryContext = Number.isFinite(requestedContextTokens) ? requestedContextTokens : p.targetContextTokens;
212
+ let check = evaluateMemoryAdmission({ modelName: model, contextTokens: primaryContext, freeBytes, minFreeRamMb: p.minFreeRamMb });
213
+ attempts.push({ model, contextTokens: primaryContext, ...check });
214
+ if (check.admitted) return { admitted: true, model, contextTokens: primaryContext, attempts };
215
+
216
+ // 2. Reduce context to the profile's conservative initial value first —
217
+ // PRD FR-2104: "shrink context before declaring an otherwise compatible
218
+ // model unusable."
219
+ if (primaryContext !== p.initialContextTokens) {
220
+ check = evaluateMemoryAdmission({ modelName: model, contextTokens: p.initialContextTokens, freeBytes, minFreeRamMb: p.minFreeRamMb });
221
+ attempts.push({ model, contextTokens: p.initialContextTokens, ...check });
222
+ if (check.admitted) return { admitted: true, model, contextTokens: p.initialContextTokens, attempts, reducedContext: true };
223
+ }
224
+
225
+ // 3. Fall back to the profile's smaller model at its initial context.
226
+ if (p.fallbackModel && p.fallbackModel !== model) {
227
+ check = evaluateMemoryAdmission({ modelName: p.fallbackModel, contextTokens: p.initialContextTokens, freeBytes, minFreeRamMb: p.minFreeRamMb });
228
+ attempts.push({ model: p.fallbackModel, contextTokens: p.initialContextTokens, ...check });
229
+ if (check.admitted) {
230
+ return {
231
+ admitted: true, model: p.fallbackModel, contextTokens: p.initialContextTokens, attempts,
232
+ reducedContext: true, fellBackToSmallerModel: true,
233
+ };
234
+ }
235
+ }
236
+
237
+ // 4. Nothing fits — deterministic-only, never cloud.
238
+ return {
239
+ admitted: false,
240
+ attempts,
241
+ reason: `No local model/context combination fit in available memory with the configured reserve. ` +
242
+ `Recommend deterministic-only scanning, or free memory before retrying.`,
243
+ };
244
+ }
@@ -0,0 +1,194 @@
1
+ // PRD §13.2 — the three-layer model capability detection strategy.
2
+ //
3
+ // LAYER A (metadata) is the cheapest and most authoritative: Ollama's own
4
+ // `/api/show` response, when it reports a `capabilities` array, is not a
5
+ // guess. LAYER B (model-capabilities.js's family hint) is a non-authoritative
6
+ // default used only where Layer A is silent. LAYER C (this module's
7
+ // `probeStructuredOutput`/`probeToolCalling`) is the most expensive — it
8
+ // consumes real inference time — so it is OPT-IN (the caller decides when
9
+ // "necessary" per the PRD's own wording), never run implicitly on every
10
+ // `models doctor`/`models inspect` invocation.
11
+ //
12
+ // PRECEDENCE: Layer C overrides Layer A overrides Layer B, field by field. A
13
+ // field only ever gets overridden by a MORE authoritative layer that actually
14
+ // has an opinion — a probe that couldn't run (offline/timeout) leaves the
15
+ // field exactly as the layer below it set it, it never downgrades to
16
+ // 'unknown'.
17
+ //
18
+ // CACHE KEY = Ollama version + model digest + model name (PRD §13.2 exactly).
19
+ // Digest is load-bearing: `ollama pull` replacing a tag's underlying weights
20
+ // must invalidate the cache even though the name/tag string is unchanged.
21
+ // Persisted forever (no TTL) because the key itself is what expires the
22
+ // entry — a version/digest bump makes a new key, not a stale hit on the old
23
+ // one. Same disk-cache directory convention as sca/sigstore-verify.js and
24
+ // engine.js's OSV cache (`~/.claude/agentic-security/<name>/`).
25
+
26
+ import * as fs from 'node:fs';
27
+ import * as path from 'node:path';
28
+ import * as os from 'node:os';
29
+ import * as crypto from 'node:crypto';
30
+ import { callOllamaStructured, callOllamaChat, showOllamaModel, getOllamaVersion } from './ollama-provider.js';
31
+ import { capabilitiesFromFamilyHint } from './model-capabilities.js';
32
+
33
+ const CACHE_DIR = path.join(os.homedir(), '.claude', 'agentic-security', 'ollama-capability-cache');
34
+
35
+ function _ensureCacheDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch {} }
36
+ function _cacheKey(ollamaVersion, modelDigest, modelName) {
37
+ return crypto.createHash('sha256').update(`${ollamaVersion}::${modelDigest}::${modelName}`).digest('hex');
38
+ }
39
+ function _cachePath(key) { return path.join(CACHE_DIR, key + '.json'); }
40
+
41
+ function _readProbeCache(key) {
42
+ try { return JSON.parse(fs.readFileSync(_cachePath(key), 'utf8')); } catch { return null; }
43
+ }
44
+ function _writeProbeCache(key, value) {
45
+ _ensureCacheDir();
46
+ try { fs.writeFileSync(_cachePath(key), JSON.stringify(value)); } catch {}
47
+ }
48
+
49
+ /**
50
+ * PRD §13.2 Layer A — parse `/api/show`'s response into the subset of
51
+ * ModelCapabilities it can actually speak to. A field this layer has no
52
+ * opinion on is omitted (not set to `false`) so the caller's merge never
53
+ * mistakes silence for a negative.
54
+ */
55
+ export function capabilitiesFromShowMetadata(show) {
56
+ const out = { source: { metadata: true } };
57
+ if (Array.isArray(show?.capabilities) && show.capabilities.length > 0) {
58
+ const caps = show.capabilities;
59
+ out.chat = caps.includes('completion') || caps.includes('chat');
60
+ out.tools = caps.includes('tools');
61
+ out.vision = caps.includes('vision');
62
+ out.thinking = caps.includes('thinking');
63
+ }
64
+ const modelInfo = show?.modelInfo;
65
+ if (modelInfo && typeof modelInfo === 'object') {
66
+ const ctxKey = Object.keys(modelInfo).find((k) => k.endsWith('.context_length'));
67
+ if (ctxKey && Number.isFinite(modelInfo[ctxKey])) out.contextTokens = modelInfo[ctxKey];
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * PRD §13.2 Layer C — structured-output probe. A tiny schema, a request for
74
+ * `{"ok": true}`, verified end to end through the SAME
75
+ * callOllamaStructured() bounded-retry path every real structured call uses
76
+ * (not a bespoke lighter-weight check that could disagree with production
77
+ * behavior).
78
+ */
79
+ const PROBE_SCHEMA = { type: 'object', required: ['ok'], properties: { ok: { type: 'boolean' } } };
80
+
81
+ export async function probeStructuredOutput({ host, model, timeouts, keepAlive } = {}) {
82
+ const r = await callOllamaStructured({
83
+ host, model,
84
+ messages: [{ role: 'user', content: 'Reply with ONLY a JSON object: {"ok": true}' }],
85
+ schema: PROBE_SCHEMA,
86
+ validateFn: (obj) => (obj && obj.ok === true ? { ok: true, value: obj } : { ok: false }),
87
+ keepAlive, timeouts,
88
+ });
89
+ if (r.ok) return { supported: true };
90
+ // A transport-level failure (server unreachable, timed out) tells us
91
+ // nothing about the MODEL's capability — leave it 'unknown' rather than
92
+ // reporting a false negative for an offline/slow server.
93
+ if (['ollama-unreachable', 'ollama-not-running', 'ollama-timeout', 'ollama-model-not-installed'].includes(r.code)) {
94
+ return { supported: 'unknown', reason: r.reason || r.code };
95
+ }
96
+ return { supported: false, reason: r.reason || r.code };
97
+ }
98
+
99
+ /**
100
+ * PRD §13.2 Layer C — tool-calling probe. One harmless `echo_capability_probe`
101
+ * function; success is Ollama returning a structured `tool_calls` entry
102
+ * naming it, not a check on what the model chose to reply with in prose.
103
+ */
104
+ const PROBE_TOOL = {
105
+ type: 'function',
106
+ function: {
107
+ name: 'echo_capability_probe',
108
+ description: 'Echo back the given value. Used only to test whether this model supports tool calling.',
109
+ parameters: { type: 'object', required: ['value'], properties: { value: { type: 'string' } } },
110
+ },
111
+ };
112
+
113
+ export async function probeToolCalling({ host, model, timeouts, keepAlive } = {}) {
114
+ const r = await callOllamaChat({
115
+ host, model,
116
+ messages: [{ role: 'user', content: 'Call the echo_capability_probe function with value set to "probe-ok". Reply with nothing else.' }],
117
+ tools: [PROBE_TOOL],
118
+ keepAlive, timeouts,
119
+ });
120
+ if (!r.ok) {
121
+ if (['ollama-unreachable', 'ollama-not-running', 'ollama-timeout', 'ollama-model-not-installed'].includes(r.code)) {
122
+ return { supported: 'unknown', reason: r.reason || r.code };
123
+ }
124
+ return { supported: false, reason: r.reason || r.code };
125
+ }
126
+ const calls = r.result.toolCalls || [];
127
+ const called = calls.some((c) => c?.function?.name === 'echo_capability_probe');
128
+ return called ? { supported: true } : { supported: false, reason: 'model did not emit a tool_calls entry for the probe function' };
129
+ }
130
+
131
+ function _mergeLayer(base, overlay, sourceFlag) {
132
+ const merged = { ...base };
133
+ let touched = false;
134
+ for (const field of ['chat', 'structuredJson', 'tools', 'thinking', 'vision', 'contextTokens']) {
135
+ if (overlay[field] !== undefined) { merged[field] = overlay[field]; touched = true; }
136
+ }
137
+ if (touched) merged.source = { ...merged.source, [sourceFlag]: true };
138
+ return merged;
139
+ }
140
+
141
+ /**
142
+ * Orchestrates all three layers (PRD §13.2) with caching (PRD: "so startup
143
+ * does not repeatedly consume inference time"). `probe: true` opts into
144
+ * Layer C — omitted or false, this returns Layer A+B only, which is what
145
+ * every non-probing caller (models list/inspect/doctor's default path)
146
+ * should use, since Layer C spends real inference time on the user's
147
+ * machine.
148
+ *
149
+ * @returns {{ok:true, capabilities:object, cached:boolean} | {ok:false, code, reason}}
150
+ */
151
+ export async function getModelCapabilities({ host, model, env = process.env, probe = false, timeouts, keepAlive } = {}) {
152
+ let capabilities = capabilitiesFromFamilyHint(model);
153
+
154
+ const show = await showOllamaModel({ host, model, timeouts });
155
+ if (show.ok) {
156
+ capabilities = _mergeLayer(capabilities, capabilitiesFromShowMetadata(show), 'metadata');
157
+ }
158
+
159
+ if (!probe) {
160
+ return { ok: true, capabilities, cached: false };
161
+ }
162
+
163
+ const versionResult = await getOllamaVersion({ host, timeouts });
164
+ const ollamaVersion = versionResult.ok ? versionResult.version : 'unknown-version';
165
+ // The digest is whatever Layer A's /api/show reported under `details`
166
+ // (Ollama does not expose it on /api/show consistently across versions —
167
+ // fall back to the model name alone, which still invalidates on a tag
168
+ // change, just not on a same-tag re-pull).
169
+ const modelDigest = show.ok && show.details?.digest ? show.details.digest : 'unknown-digest';
170
+ const cacheKey = _cacheKey(ollamaVersion, modelDigest, model);
171
+
172
+ const cached = _readProbeCache(cacheKey);
173
+ if (cached) {
174
+ return { ok: true, capabilities: _mergeLayer(capabilities, cached, 'runtimeProbe'), cached: true };
175
+ }
176
+
177
+ const [structured, tools] = await Promise.all([
178
+ probeStructuredOutput({ host, model, timeouts, keepAlive }),
179
+ probeToolCalling({ host, model, timeouts, keepAlive }),
180
+ ]);
181
+
182
+ const probeResult = {};
183
+ if (structured.supported !== 'unknown') probeResult.structuredJson = structured.supported;
184
+ if (tools.supported !== 'unknown') probeResult.tools = tools.supported;
185
+
186
+ // Only cache a probe that actually resolved something — an all-'unknown'
187
+ // result (server unreachable mid-probe) would otherwise poison the cache
188
+ // with a permanent non-answer.
189
+ if (Object.keys(probeResult).length > 0) _writeProbeCache(cacheKey, probeResult);
190
+
191
+ return { ok: true, capabilities: _mergeLayer(capabilities, probeResult, 'runtimeProbe'), cached: false };
192
+ }
193
+
194
+ export const _internals = { CACHE_DIR, _cacheKey, _cachePath };
@@ -64,3 +64,30 @@ export function summarizeModelStatus(findings) {
64
64
  }
65
65
  return { counts, notApplicable, total: (findings || []).length };
66
66
  }
67
+
68
+ /**
69
+ * agentic-security-ollama-offline-prd.md §26 — turn a summarizeModelStatus()
70
+ * result into the "AI stages" call/success/refused/failed breakdown a report
71
+ * can render. Reuses the SAME five-state taxonomy every other status
72
+ * consumer already trusts, rather than adding new instrumentation: COMPLETED
73
+ * + MALFORMED + UNAVAILABLE are all outcomes of an ATTEMPTED call (the model
74
+ * was actually asked); POLICY_BLOCKED never reached the network at all, so
75
+ * it is reported as "refused" rather than folded into "calls". DISABLED
76
+ * (nothing configured) contributes to neither — a caller checks that
77
+ * separately (status.counts[MODEL_STATUS.DISABLED] === status.total means
78
+ * "don't render this stage at all").
79
+ *
80
+ * @returns {{calls:number, success:number, refused:number, failed:number}}
81
+ */
82
+ export function stageSummaryFromModelStatus(status) {
83
+ const c = status?.counts || {};
84
+ const completed = c[MODEL_STATUS.COMPLETED] || 0;
85
+ const malformed = c[MODEL_STATUS.MALFORMED] || 0;
86
+ const unavailable = c[MODEL_STATUS.UNAVAILABLE] || 0;
87
+ return {
88
+ calls: completed + malformed + unavailable,
89
+ success: completed,
90
+ refused: c[MODEL_STATUS.POLICY_BLOCKED] || 0,
91
+ failed: malformed + unavailable,
92
+ };
93
+ }