@miphamai/cli 0.11.0 → 0.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/package.json +1 -1
- package/src/agent/background-registry.ts +11 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/artifacts/server.ts +39 -0
- package/src/config/defaults.ts +35 -1
- package/src/config/loader.ts +122 -2
- package/src/core/credential-masker.ts +203 -0
- package/src/core/engine.ts +56 -1
- package/src/core/hooks.ts +23 -0
- package/src/core/inference-hook.ts +224 -0
- package/src/core/metrics.ts +364 -0
- package/src/core/permission.ts +62 -8
- package/src/index.tsx +16 -1
- package/src/plugin/plugin-manager.ts +39 -2
- package/src/shared/types.ts +100 -0
- package/src/tools/exec/bash.ts +29 -3
- package/src/tools/file/read.ts +30 -1
- package/src/ui/app.tsx +6 -1
- package/src/ui/chat.tsx +50 -17
- package/src/ui/commands.ts +348 -4
- package/dist/mipham +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'node:crypto'
|
|
2
|
+
import type {
|
|
3
|
+
InferenceHookConfig,
|
|
4
|
+
InferenceCheckRequest,
|
|
5
|
+
InferenceCheckResponse,
|
|
6
|
+
Message,
|
|
7
|
+
} from '../shared/index.ts'
|
|
8
|
+
|
|
9
|
+
// ── Result type ──
|
|
10
|
+
|
|
11
|
+
export interface InferenceVerdict {
|
|
12
|
+
allowed: boolean
|
|
13
|
+
reason?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Public API ──
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the inference-check request payload from current conversation state.
|
|
20
|
+
*
|
|
21
|
+
* Follows Claude Inference Hooks protocol:
|
|
22
|
+
* - Sends all messages EXCEPT system role (never expose system prompts)
|
|
23
|
+
* - Includes recent tool calls with result previews (truncated to 2000 chars)
|
|
24
|
+
* - Omits tool definitions and raw file/image content
|
|
25
|
+
*/
|
|
26
|
+
export function buildRequest(
|
|
27
|
+
messages: Message[],
|
|
28
|
+
sessionId: string,
|
|
29
|
+
provider: string,
|
|
30
|
+
model: string,
|
|
31
|
+
organizationId?: string,
|
|
32
|
+
): InferenceCheckRequest {
|
|
33
|
+
// Filter: exclude system messages, flatten content blocks to text
|
|
34
|
+
const serialized: Array<{ role: string; content: string }> = []
|
|
35
|
+
for (const msg of messages) {
|
|
36
|
+
if (msg.role === 'system') continue
|
|
37
|
+
serialized.push({
|
|
38
|
+
role: msg.role,
|
|
39
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Extract tool calls from the message history
|
|
44
|
+
const toolCalls = extractToolCalls(messages)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
type: 'inference_check',
|
|
48
|
+
id: `evt_${randomUUID()}`,
|
|
49
|
+
created_at: new Date().toISOString(),
|
|
50
|
+
data: {
|
|
51
|
+
type: 'pre_inference',
|
|
52
|
+
session_id: sessionId,
|
|
53
|
+
organization_id: organizationId || undefined,
|
|
54
|
+
provider,
|
|
55
|
+
model,
|
|
56
|
+
messages: serialized,
|
|
57
|
+
tool_calls: toolCalls,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send the inference-check request to the DLP server and return the verdict.
|
|
64
|
+
*
|
|
65
|
+
* On network error or timeout, applies the configured `on_failure` strategy:
|
|
66
|
+
* - 'fail-closed': block the request (security-first)
|
|
67
|
+
* - 'fail-open': allow the request (availability-first)
|
|
68
|
+
*/
|
|
69
|
+
export async function sendInferenceCheck(
|
|
70
|
+
config: InferenceHookConfig,
|
|
71
|
+
request: InferenceCheckRequest,
|
|
72
|
+
): Promise<InferenceVerdict> {
|
|
73
|
+
const body = JSON.stringify(request)
|
|
74
|
+
|
|
75
|
+
// Build signature header
|
|
76
|
+
const signature = signPayload(config.signing_secret, body)
|
|
77
|
+
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'User-Agent': `MiphamCode/${getVersion()}`,
|
|
81
|
+
...(config.headers || {}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (signature) {
|
|
85
|
+
headers['X-Mipham-Signature'] = signature
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(config.endpoint, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers,
|
|
92
|
+
body,
|
|
93
|
+
signal: AbortSignal.timeout(config.timeout),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const responseBody = await response.text()
|
|
97
|
+
|
|
98
|
+
if (response.status === 200) {
|
|
99
|
+
// Parse verdict — treat any non-deny as allow
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(responseBody) as InferenceCheckResponse
|
|
102
|
+
if (parsed.verdict === 'deny') {
|
|
103
|
+
return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
|
|
104
|
+
}
|
|
105
|
+
return { allowed: true }
|
|
106
|
+
} catch {
|
|
107
|
+
// Unparseable response — treat as allow (server acknowledged receipt)
|
|
108
|
+
return { allowed: true }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 403 = explicit deny
|
|
113
|
+
if (response.status === 403) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(responseBody) as InferenceCheckResponse
|
|
116
|
+
return { allowed: false, reason: parsed.reason || 'Blocked by DLP policy' }
|
|
117
|
+
} catch {
|
|
118
|
+
return { allowed: false, reason: `DLP server denied (403): ${responseBody.slice(0, 200)}` }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Other non-2xx: apply failure posture
|
|
123
|
+
if (config.on_failure === 'fail-closed') {
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
reason: `DLP server returned ${response.status}: ${responseBody.slice(0, 200)}`,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { allowed: true }
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Network error or timeout
|
|
132
|
+
if (config.on_failure === 'fail-closed') {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: `DLP server unreachable: ${msg}`,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// fail-open: allow through
|
|
140
|
+
return { allowed: true }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if inference hook is configured and should be used.
|
|
146
|
+
*/
|
|
147
|
+
export function isInferenceHookEnabled(config?: InferenceHookConfig): boolean {
|
|
148
|
+
return !!(config?.endpoint && config.endpoint.length > 0)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Internal helpers ──
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract tool calls and their results from the entire message history.
|
|
155
|
+
* Scans for tool_use/tool_result pairs across all messages.
|
|
156
|
+
* Each result_preview is truncated to 2000 characters.
|
|
157
|
+
*/
|
|
158
|
+
function extractToolCalls(
|
|
159
|
+
messages: Message[],
|
|
160
|
+
): Array<{ name: string; input: Record<string, unknown>; result_preview: string }> {
|
|
161
|
+
// Collect tool results first (keyed by tool_use_id)
|
|
162
|
+
const toolResults = new Map<string, string>()
|
|
163
|
+
for (const msg of messages) {
|
|
164
|
+
if (Array.isArray(msg.content)) {
|
|
165
|
+
for (const block of msg.content) {
|
|
166
|
+
if (block.type === 'tool_result') {
|
|
167
|
+
toolResults.set(block.tool_use_id, block.content || '')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract tool_use blocks with their result previews
|
|
174
|
+
const calls: Array<{
|
|
175
|
+
name: string
|
|
176
|
+
input: Record<string, unknown>
|
|
177
|
+
result_preview: string
|
|
178
|
+
}> = []
|
|
179
|
+
|
|
180
|
+
for (const msg of messages) {
|
|
181
|
+
if (Array.isArray(msg.content)) {
|
|
182
|
+
for (const block of msg.content) {
|
|
183
|
+
if (block.type === 'tool_use') {
|
|
184
|
+
const resultPreview = toolResults.get(block.id) || ''
|
|
185
|
+
calls.push({
|
|
186
|
+
name: block.name,
|
|
187
|
+
input: block.input,
|
|
188
|
+
result_preview: resultPreview.slice(0, 2000),
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return calls
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sign the request body using HMAC-SHA256.
|
|
200
|
+
* Follows Standard Webhooks specification:
|
|
201
|
+
* X-Mipham-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
|
|
202
|
+
* Returns empty string if no signing secret is configured.
|
|
203
|
+
*/
|
|
204
|
+
function signPayload(secret: string, body: string): string {
|
|
205
|
+
if (!secret) return ''
|
|
206
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
207
|
+
const signedPayload = `${timestamp}.${body}`
|
|
208
|
+
const signature = createHmac('sha256', secret).update(signedPayload).digest('hex')
|
|
209
|
+
return `t=${timestamp},v1=${signature}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get the current package version for the User-Agent header.
|
|
214
|
+
*/
|
|
215
|
+
function getVersion(): string {
|
|
216
|
+
try {
|
|
217
|
+
// Dynamic import to avoid circular deps — the shared package-info
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
219
|
+
const pkg = require('../../package.json') as { version?: string }
|
|
220
|
+
return pkg.version || '0.0.0'
|
|
221
|
+
} catch {
|
|
222
|
+
return '0.0.0'
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Prometheus-compatible metrics registry for mipham-code.
|
|
3
|
+
*
|
|
4
|
+
* Provides Counter, Gauge, and Histogram metric types compatible with
|
|
5
|
+
* the Python shared/metrics.py module in MegaSystem. Metrics are
|
|
6
|
+
* collected in-memory and can be exported in Prometheus text format
|
|
7
|
+
* or JSON.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { getMetrics } from '../core/metrics.js'
|
|
11
|
+
*
|
|
12
|
+
* const m = getMetrics()
|
|
13
|
+
* m.cliInvocations.inc()
|
|
14
|
+
* m.toolCalls.inc({ tool_name: 'bash' })
|
|
15
|
+
* m.modelRequestDuration.observe(150.0, { provider: 'anthropic' })
|
|
16
|
+
*
|
|
17
|
+
* Endpoints (via ArtifactServer):
|
|
18
|
+
* GET /metrics — Prometheus text format
|
|
19
|
+
* GET /metrics/json — JSON format
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ── Metric types ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface MetricLabels {
|
|
25
|
+
[key: string]: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format labels into Prometheus {...} string. */
|
|
29
|
+
function formatLabels(labels?: MetricLabels): string {
|
|
30
|
+
if (!labels || Object.keys(labels).length === 0) return ''
|
|
31
|
+
const parts = Object.entries(labels)
|
|
32
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
33
|
+
.map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`)
|
|
34
|
+
return `{${parts.join(',')}}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Counter ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export class Counter {
|
|
40
|
+
readonly name: string
|
|
41
|
+
readonly help: string
|
|
42
|
+
readonly labels: MetricLabels
|
|
43
|
+
private _value = 0
|
|
44
|
+
|
|
45
|
+
constructor(name: string, help: string, labels?: MetricLabels) {
|
|
46
|
+
this.name = name
|
|
47
|
+
this.help = help
|
|
48
|
+
this.labels = labels ?? {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
inc(amount = 1): void {
|
|
52
|
+
this._value += amount
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get value(): number {
|
|
56
|
+
return this._value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Full metric name with labels for dedup key. */
|
|
60
|
+
get key(): string {
|
|
61
|
+
return this.name + formatLabels(this.labels)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toPrometheus(): string {
|
|
65
|
+
const labelStr = formatLabels(this.labels)
|
|
66
|
+
return `${this.name}${labelStr} ${this._value}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toJSON(): object {
|
|
70
|
+
return {
|
|
71
|
+
name: this.name,
|
|
72
|
+
type: 'counter',
|
|
73
|
+
help: this.help,
|
|
74
|
+
labels: this.labels,
|
|
75
|
+
value: this._value,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Gauge ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export class Gauge {
|
|
83
|
+
readonly name: string
|
|
84
|
+
readonly help: string
|
|
85
|
+
readonly labels: MetricLabels
|
|
86
|
+
private _value = 0
|
|
87
|
+
|
|
88
|
+
constructor(name: string, help: string, labels?: MetricLabels) {
|
|
89
|
+
this.name = name
|
|
90
|
+
this.help = help
|
|
91
|
+
this.labels = labels ?? {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
inc(amount = 1): void {
|
|
95
|
+
this._value += amount
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dec(amount = 1): void {
|
|
99
|
+
this._value -= amount
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
set(value: number): void {
|
|
103
|
+
this._value = value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get value(): number {
|
|
107
|
+
return this._value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get key(): string {
|
|
111
|
+
return this.name + formatLabels(this.labels)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
toPrometheus(): string {
|
|
115
|
+
const labelStr = formatLabels(this.labels)
|
|
116
|
+
return `${this.name}${labelStr} ${this._value}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
toJSON(): object {
|
|
120
|
+
return {
|
|
121
|
+
name: this.name,
|
|
122
|
+
type: 'gauge',
|
|
123
|
+
help: this.help,
|
|
124
|
+
labels: this.labels,
|
|
125
|
+
value: this._value,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Histogram ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
|
133
|
+
|
|
134
|
+
export class Histogram {
|
|
135
|
+
readonly name: string
|
|
136
|
+
readonly help: string
|
|
137
|
+
readonly labels: MetricLabels
|
|
138
|
+
readonly buckets: number[]
|
|
139
|
+
private _count = 0
|
|
140
|
+
private _sum = 0
|
|
141
|
+
private _bucketCounts: number[]
|
|
142
|
+
|
|
143
|
+
constructor(name: string, help: string, buckets?: number[], labels?: MetricLabels) {
|
|
144
|
+
this.name = name
|
|
145
|
+
this.help = help
|
|
146
|
+
this.labels = labels ?? {}
|
|
147
|
+
this.buckets = buckets ?? DEFAULT_BUCKETS
|
|
148
|
+
this._bucketCounts = new Array(this.buckets.length).fill(0)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
observe(value: number): void {
|
|
152
|
+
this._count++
|
|
153
|
+
this._sum += value
|
|
154
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
155
|
+
if (value <= (this.buckets[i] ?? Infinity)) {
|
|
156
|
+
this._bucketCounts[i] = (this._bucketCounts[i] ?? 0) + 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get count(): number {
|
|
162
|
+
return this._count
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get sum(): number {
|
|
166
|
+
return this._sum
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get key(): string {
|
|
170
|
+
return this.name + formatLabels(this.labels)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
toPrometheus(): string {
|
|
174
|
+
const labelStr = formatLabels(this.labels)
|
|
175
|
+
const lines: string[] = []
|
|
176
|
+
|
|
177
|
+
// _bucket values
|
|
178
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
179
|
+
const bucketLabel = formatLabels({
|
|
180
|
+
...this.labels,
|
|
181
|
+
le: String(this.buckets[i]),
|
|
182
|
+
})
|
|
183
|
+
lines.push(`${this.name}_bucket${bucketLabel} ${this._bucketCounts[i]}`)
|
|
184
|
+
}
|
|
185
|
+
// +Inf bucket
|
|
186
|
+
const infLabel = formatLabels({ ...this.labels, le: '+Inf' })
|
|
187
|
+
lines.push(`${this.name}_bucket${infLabel} ${this._count}`)
|
|
188
|
+
|
|
189
|
+
// _sum and _count
|
|
190
|
+
lines.push(`${this.name}_sum${labelStr} ${this._sum}`)
|
|
191
|
+
lines.push(`${this.name}_count${labelStr} ${this._count}`)
|
|
192
|
+
|
|
193
|
+
return lines.join('\n')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
toJSON(): object {
|
|
197
|
+
const bucketResults: { le: string; count: number }[] = []
|
|
198
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
199
|
+
bucketResults.push({ le: String(this.buckets[i]), count: this._bucketCounts[i] ?? 0 })
|
|
200
|
+
}
|
|
201
|
+
bucketResults.push({ le: '+Inf', count: this._count })
|
|
202
|
+
return {
|
|
203
|
+
name: this.name,
|
|
204
|
+
type: 'histogram',
|
|
205
|
+
help: this.help,
|
|
206
|
+
labels: this.labels,
|
|
207
|
+
count: this._count,
|
|
208
|
+
sum: this._sum,
|
|
209
|
+
buckets: bucketResults,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── MetricsRegistry ──────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export class MetricsRegistry {
|
|
217
|
+
private _counters = new Map<string, Counter>()
|
|
218
|
+
private _gauges = new Map<string, Gauge>()
|
|
219
|
+
private _histograms = new Map<string, Histogram>()
|
|
220
|
+
|
|
221
|
+
// ── Predefined metrics ─────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/** CLI invocation counter. Incremented on each CLI startup. */
|
|
224
|
+
readonly cliInvocations: Counter
|
|
225
|
+
|
|
226
|
+
/** Tool call counter, labelled by tool_name. Callers use .inc({tool_name}). */
|
|
227
|
+
readonly toolCalls: Counter
|
|
228
|
+
|
|
229
|
+
/** Model API request counter, labelled by provider and model. */
|
|
230
|
+
readonly modelRequests: Counter
|
|
231
|
+
|
|
232
|
+
/** Model API request error counter, labelled by provider and error type. */
|
|
233
|
+
readonly modelRequestErrors: Counter
|
|
234
|
+
|
|
235
|
+
/** Model API request latency histogram (milliseconds). */
|
|
236
|
+
readonly modelRequestDurationMs: Histogram
|
|
237
|
+
|
|
238
|
+
/** Active CLI sessions gauge. */
|
|
239
|
+
readonly activeSessions: Gauge
|
|
240
|
+
|
|
241
|
+
constructor() {
|
|
242
|
+
// Pre-register standard metrics
|
|
243
|
+
this.cliInvocations = this.counter(
|
|
244
|
+
'mipham_code_cli_invocations_total',
|
|
245
|
+
'Number of CLI invocations',
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
this.toolCalls = this.counter('mipham_code_tool_calls_total', 'Number of tool invocations')
|
|
249
|
+
|
|
250
|
+
this.modelRequests = this.counter(
|
|
251
|
+
'mipham_code_model_requests_total',
|
|
252
|
+
'Number of model API requests',
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
this.modelRequestErrors = this.counter(
|
|
256
|
+
'mipham_code_model_request_errors_total',
|
|
257
|
+
'Number of model API request errors',
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
this.modelRequestDurationMs = this.histogram(
|
|
261
|
+
'mipham_code_model_request_duration_ms',
|
|
262
|
+
'Model API request duration in milliseconds',
|
|
263
|
+
[50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
this.activeSessions = this.gauge('mipham_code_active_sessions', 'Number of active CLI sessions')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Factory methods ─────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
counter(name: string, help: string, labels?: MetricLabels): Counter {
|
|
272
|
+
const c = new Counter(name, help, labels)
|
|
273
|
+
if (this._counters.has(c.key)) return this._counters.get(c.key)!
|
|
274
|
+
this._counters.set(c.key, c)
|
|
275
|
+
return c
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
gauge(name: string, help: string, labels?: MetricLabels): Gauge {
|
|
279
|
+
const g = new Gauge(name, help, labels)
|
|
280
|
+
if (this._gauges.has(g.key)) return this._gauges.get(g.key)!
|
|
281
|
+
this._gauges.set(g.key, g)
|
|
282
|
+
return g
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
histogram(name: string, help: string, buckets?: number[], labels?: MetricLabels): Histogram {
|
|
286
|
+
const h = new Histogram(name, help, buckets, labels)
|
|
287
|
+
if (this._histograms.has(h.key)) return this._histograms.get(h.key)!
|
|
288
|
+
this._histograms.set(h.key, h)
|
|
289
|
+
return h
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Export ──────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/** Export all metrics in Prometheus text format. */
|
|
295
|
+
toPrometheusText(): string {
|
|
296
|
+
const lines: string[] = []
|
|
297
|
+
const seen = new Set<string>()
|
|
298
|
+
|
|
299
|
+
for (const c of this._counters.values()) {
|
|
300
|
+
if (!seen.has(c.name)) {
|
|
301
|
+
lines.push(`# HELP ${c.name} ${c.help}`)
|
|
302
|
+
lines.push(`# TYPE ${c.name} counter`)
|
|
303
|
+
seen.add(c.name)
|
|
304
|
+
}
|
|
305
|
+
lines.push(c.toPrometheus())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const g of this._gauges.values()) {
|
|
309
|
+
if (!seen.has(g.name)) {
|
|
310
|
+
lines.push(`# HELP ${g.name} ${g.help}`)
|
|
311
|
+
lines.push(`# TYPE ${g.name} gauge`)
|
|
312
|
+
seen.add(g.name)
|
|
313
|
+
}
|
|
314
|
+
lines.push(g.toPrometheus())
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const h of this._histograms.values()) {
|
|
318
|
+
if (!seen.has(h.name)) {
|
|
319
|
+
lines.push(`# HELP ${h.name} ${h.help}`)
|
|
320
|
+
lines.push(`# TYPE ${h.name} histogram`)
|
|
321
|
+
seen.add(h.name)
|
|
322
|
+
}
|
|
323
|
+
lines.push(h.toPrometheus())
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return lines.join('\n') + '\n'
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Export all metrics as JSON. */
|
|
330
|
+
toJSON(): object {
|
|
331
|
+
return {
|
|
332
|
+
counters: Array.from(this._counters.values()).map((c) => c.toJSON()),
|
|
333
|
+
gauges: Array.from(this._gauges.values()).map((g) => g.toJSON()),
|
|
334
|
+
histograms: Array.from(this._histograms.values()).map((h) => h.toJSON()),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Reset all metrics (useful for testing). */
|
|
339
|
+
reset(): void {
|
|
340
|
+
this._counters.clear()
|
|
341
|
+
this._gauges.clear()
|
|
342
|
+
this._histograms.clear()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Singleton ────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
let _instance: MetricsRegistry | null = null
|
|
349
|
+
|
|
350
|
+
/** Get the global MetricsRegistry singleton. */
|
|
351
|
+
export function getMetrics(): MetricsRegistry {
|
|
352
|
+
if (!_instance) {
|
|
353
|
+
_instance = new MetricsRegistry()
|
|
354
|
+
}
|
|
355
|
+
return _instance
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Reset the singleton (useful for testing). */
|
|
359
|
+
export function resetMetrics(): void {
|
|
360
|
+
if (_instance) {
|
|
361
|
+
_instance.reset()
|
|
362
|
+
_instance = null
|
|
363
|
+
}
|
|
364
|
+
}
|