@rezti/dsh-rez-suite 0.1.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/LICENSE +13 -0
- package/README.md +9 -0
- package/README.zh.md +11 -0
- package/cordis.patch.yml +68 -0
- package/lib/client.d.ts +7479 -0
- package/lib/client.js +1190 -0
- package/lib/index.d.ts +1726 -0
- package/lib/index.js +7659 -0
- package/lib/style.css +309 -0
- package/package.json +103 -0
- package/servers/fs-mcp-server.mjs +71 -0
- package/servers/sqlite-mcp-server.mjs +50 -0
- package/src/client/api.ts +75 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +45 -0
- package/src/client/locales.ts +176 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AuditTab.tsx +87 -0
- package/src/client/panel/ConfigTab.tsx +223 -0
- package/src/client/panel/RezPanel.tsx +50 -0
- package/src/client/panel/StatusTab.tsx +66 -0
- package/src/client/panel/controller.ts +42 -0
- package/src/client/panel/helpers.ts +21 -0
- package/src/client/panel/panel.module.css +316 -0
- package/src/client/sidebar-entry.ts +80 -0
- package/src/index.ts +166 -0
- package/src/mcp-host.ts +418 -0
- package/src/presets.ts +125 -0
- package/src/protocol.ts +133 -0
- package/src/routes.ts +191 -0
- package/src/store.ts +152 -0
- package/src/token-manager.ts +252 -0
- package/src/tools.ts +167 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/dsh-rez-suite route family: config read/update, connectivity test,
|
|
3
|
+
* live status, and the audit ledger. Loopback-only trust fence plus browser
|
|
4
|
+
* same-origin markers — these endpoints read/write credentials on the host.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import type { RezConfig, RezMcpServerSpec, RezRoleId } from './protocol.ts'
|
|
10
|
+
import { REZ_API } from './protocol.ts'
|
|
11
|
+
import type { McpHost } from './mcp-host.ts'
|
|
12
|
+
import type { TokenManager } from './token-manager.ts'
|
|
13
|
+
import { publicConfig } from './store.ts'
|
|
14
|
+
|
|
15
|
+
const MAX_JSON_BODY_BYTES = 256 * 1024
|
|
16
|
+
const SECRET_MASK = '********'
|
|
17
|
+
|
|
18
|
+
/** Loopback literal check plus browser same-origin markers. */
|
|
19
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
20
|
+
const address = request.socket.remoteAddress
|
|
21
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
22
|
+
const host = request.headers.host
|
|
23
|
+
if (typeof host !== 'string') return false
|
|
24
|
+
let hostUrl: URL
|
|
25
|
+
try {
|
|
26
|
+
hostUrl = new URL('http://' + host)
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
31
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
32
|
+
const origin = request.headers.origin
|
|
33
|
+
if (origin === undefined) return true
|
|
34
|
+
try {
|
|
35
|
+
return new URL(origin).host === hostUrl.host
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
42
|
+
const payload = JSON.stringify(body)
|
|
43
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
44
|
+
res.end(payload)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
48
|
+
const chunks: Buffer[] = []
|
|
49
|
+
let size = 0
|
|
50
|
+
for await (const chunk of req) {
|
|
51
|
+
const buffer = chunk as Buffer
|
|
52
|
+
size += buffer.length
|
|
53
|
+
if (size > MAX_JSON_BODY_BYTES) return undefined
|
|
54
|
+
chunks.push(buffer)
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
58
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Merge record values while keeping masked secrets. */
|
|
65
|
+
function mergeMaskedRecord(base: Record<string, string> | undefined, incoming: unknown): Record<string, string> {
|
|
66
|
+
const next: Record<string, string> = { ...(base ?? {}) }
|
|
67
|
+
if (typeof incoming !== 'object' || incoming === null) return next
|
|
68
|
+
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
|
69
|
+
if (typeof value !== 'string') continue
|
|
70
|
+
if (value === SECRET_MASK) continue
|
|
71
|
+
next[key] = value
|
|
72
|
+
}
|
|
73
|
+
return next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply a browser-safe public patch without overwriting masked secrets. */
|
|
77
|
+
function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): RezConfig {
|
|
78
|
+
const next: RezConfig = JSON.parse(JSON.stringify(current)) as RezConfig
|
|
79
|
+
if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled
|
|
80
|
+
if (patch.role === 'engineer' || patch.role === 'sales' || patch.role === 'operations' || patch.role === 'all') next.role = patch.role as RezRoleId
|
|
81
|
+
|
|
82
|
+
if (typeof patch.servers === 'object' && patch.servers !== null) {
|
|
83
|
+
for (const [id, raw] of Object.entries(patch.servers as Record<string, unknown>)) {
|
|
84
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
85
|
+
const incoming = raw as Record<string, unknown>
|
|
86
|
+
const existing = next.servers[id] ?? { enabled: false, transport: 'stdio' as const }
|
|
87
|
+
const merged: RezMcpServerSpec = { ...existing }
|
|
88
|
+
if (typeof incoming.enabled === 'boolean') merged.enabled = incoming.enabled
|
|
89
|
+
if (incoming.transport === 'stdio' || incoming.transport === 'streamable-http') merged.transport = incoming.transport
|
|
90
|
+
if (typeof incoming.command === 'string') merged.command = incoming.command
|
|
91
|
+
if (Array.isArray(incoming.args)) merged.args = incoming.args.map(String)
|
|
92
|
+
if (typeof incoming.cwd === 'string') merged.cwd = incoming.cwd
|
|
93
|
+
if (typeof incoming.url === 'string') merged.url = incoming.url
|
|
94
|
+
if (typeof incoming.toolCallTimeoutMs === 'number') merged.toolCallTimeoutMs = incoming.toolCallTimeoutMs
|
|
95
|
+
if (incoming.env !== undefined) merged.env = mergeMaskedRecord(existing.env, incoming.env)
|
|
96
|
+
if (incoming.headers !== undefined) merged.headers = mergeMaskedRecord(existing.headers, incoming.headers)
|
|
97
|
+
next.servers[id] = merged
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof patch.billing === 'object' && patch.billing !== null) {
|
|
102
|
+
const value = patch.billing as Record<string, unknown>
|
|
103
|
+
if (typeof value.inputCostPer1k === 'number') next.billing.inputCostPer1k = value.inputCostPer1k
|
|
104
|
+
if (typeof value.outputCostPer1k === 'number') next.billing.outputCostPer1k = value.outputCostPer1k
|
|
105
|
+
if (typeof value.monthlyBudget === 'number') next.billing.monthlyBudget = value.monthlyBudget
|
|
106
|
+
}
|
|
107
|
+
return next
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RezRoutesDeps {
|
|
111
|
+
getConfig: () => RezConfig
|
|
112
|
+
/** Persist a full/patched config and re-sync the MCP host. */
|
|
113
|
+
updateConfig: (next: RezConfig) => Promise<RezConfig>
|
|
114
|
+
host: McpHost
|
|
115
|
+
tokens: TokenManager
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build every /api/dsh-rez-suite route (exact paths). */
|
|
119
|
+
export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
|
|
120
|
+
const { getConfig, updateConfig, host, tokens } = deps
|
|
121
|
+
|
|
122
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
123
|
+
if (!isLoopbackRequest(req)) {
|
|
124
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
if (req.method !== method) {
|
|
128
|
+
writeJson(res, 405, { error: 'method not allowed: ' + (req.method ?? '') })
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
kind: 'exact',
|
|
137
|
+
path: REZ_API.config,
|
|
138
|
+
handler: async (req, res) => {
|
|
139
|
+
if (!guard(req, res, req.method === 'GET' ? 'GET' : 'PUT')) return
|
|
140
|
+
if (req.method === 'GET') {
|
|
141
|
+
writeJson(res, 200, { config: publicConfig(getConfig()) })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const body = await readJsonBody(req)
|
|
145
|
+
if (body === undefined) {
|
|
146
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const next = applyPublicPatch(getConfig(), body)
|
|
150
|
+
const saved = await updateConfig(next)
|
|
151
|
+
writeJson(res, 200, { config: publicConfig(saved) })
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
kind: 'exact',
|
|
156
|
+
path: REZ_API.test,
|
|
157
|
+
handler: async (req, res) => {
|
|
158
|
+
if (!guard(req, res, 'POST')) return
|
|
159
|
+
const results = await host.test(getConfig())
|
|
160
|
+
writeJson(res, 200, { results })
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
kind: 'exact',
|
|
165
|
+
path: REZ_API.status,
|
|
166
|
+
handler: async (req, res) => {
|
|
167
|
+
if (!guard(req, res, 'GET')) return
|
|
168
|
+
const servers = host.status()
|
|
169
|
+
const totalRegisteredTools = servers.reduce((sum, server) => sum + server.registeredTools, 0)
|
|
170
|
+
writeJson(res, 200, { role: getConfig().role, servers, totalRegisteredTools })
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
kind: 'exact',
|
|
175
|
+
path: REZ_API.audit,
|
|
176
|
+
handler: async (req, res) => {
|
|
177
|
+
if (!guard(req, res, 'GET')) return
|
|
178
|
+
writeJson(res, 200, { audit: tokens.summary(getConfig().billing.monthlyBudget) })
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
kind: 'exact',
|
|
183
|
+
path: REZ_API.auditReset,
|
|
184
|
+
handler: async (req, res) => {
|
|
185
|
+
if (!guard(req, res, 'POST')) return
|
|
186
|
+
tokens.reset()
|
|
187
|
+
writeJson(res, 200, { ok: true })
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
]
|
|
191
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host config store: one JSON file (~/.dsh/dsh-rez-suite.json) holding the
|
|
3
|
+
* suite configuration. Server env/header values (API keys, passwords) live in
|
|
4
|
+
* this user-owned file in plaintext — same trust model as dsh-ssh; document
|
|
5
|
+
* it, never log it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import type { RezConfig, RezMcpServerSpec, RezPublicConfig } from './protocol.ts'
|
|
12
|
+
|
|
13
|
+
/** File format version. */
|
|
14
|
+
const FORMAT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** Config store location: <home>/.dsh/dsh-rez-suite.json. */
|
|
17
|
+
export function rezStorePath(): string {
|
|
18
|
+
return join(homedir(), '.dsh', 'dsh-rez-suite.json')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Audit SQLite location: <home>/.dsh/dsh-rez-suite.sqlite. */
|
|
22
|
+
export function rezDbPath(): string {
|
|
23
|
+
return join(homedir(), '.dsh', 'dsh-rez-suite.sqlite')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Default bundled filesystem root. */
|
|
27
|
+
export function rezFsRoot(): string {
|
|
28
|
+
return join(homedir(), '.dsh', 'dsh-rez-suite', 'fs')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Expand a leading ~ (or empty string) to an absolute path. */
|
|
32
|
+
export function expandHome(path: string): string {
|
|
33
|
+
if (path === '') return ''
|
|
34
|
+
if (path === '~') return homedir()
|
|
35
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
|
|
36
|
+
return resolve(path)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Defaults: panel-only. Company MCP rows live in cordis.patch.yml. */
|
|
40
|
+
export function defaultConfig(): RezConfig {
|
|
41
|
+
return {
|
|
42
|
+
enabled: true,
|
|
43
|
+
announceToAgent: true,
|
|
44
|
+
role: 'all',
|
|
45
|
+
servers: {},
|
|
46
|
+
billing: {
|
|
47
|
+
inputCostPer1k: 0.001,
|
|
48
|
+
outputCostPer1k: 0.002,
|
|
49
|
+
monthlyBudget: 0,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mask every env/header value before it leaves the host. */
|
|
55
|
+
function maskRecord(value: Record<string, string> | undefined): Record<string, string> {
|
|
56
|
+
const out: Record<string, string> = {}
|
|
57
|
+
for (const [key, item] of Object.entries(value ?? {})) {
|
|
58
|
+
out[key] = item === '' ? '' : '********'
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function maskServer(server: RezMcpServerSpec): RezMcpServerSpec {
|
|
64
|
+
return {
|
|
65
|
+
...server,
|
|
66
|
+
env: maskRecord(server.env),
|
|
67
|
+
headers: maskRecord(server.headers),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Project a full config to a browser-safe shape. */
|
|
72
|
+
export function publicConfig(config: RezConfig): RezPublicConfig {
|
|
73
|
+
const servers: Record<string, RezMcpServerSpec> = {}
|
|
74
|
+
for (const [id, server] of Object.entries(config.servers)) {
|
|
75
|
+
servers[id] = maskServer(server)
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
enabled: config.enabled,
|
|
79
|
+
role: config.role,
|
|
80
|
+
servers,
|
|
81
|
+
billing: { ...config.billing },
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StoreFile {
|
|
86
|
+
version: number
|
|
87
|
+
config: RezConfig
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Merge a user-edited store file over the built-in defaults. */
|
|
91
|
+
export function mergeConfig(base: RezConfig, override: unknown): RezConfig {
|
|
92
|
+
if (typeof override !== 'object' || override === null || Array.isArray(override)) return base
|
|
93
|
+
const value = override as Record<string, unknown>
|
|
94
|
+
const result: RezConfig = JSON.parse(JSON.stringify(base)) as RezConfig
|
|
95
|
+
if (typeof value.enabled === 'boolean') result.enabled = value.enabled
|
|
96
|
+
if (typeof value.announceToAgent === 'boolean') result.announceToAgent = value.announceToAgent
|
|
97
|
+
if (value.role === 'engineer' || value.role === 'sales' || value.role === 'operations' || value.role === 'all') result.role = value.role
|
|
98
|
+
|
|
99
|
+
if (typeof value.servers === 'object' && value.servers !== null) {
|
|
100
|
+
for (const [id, raw] of Object.entries(value.servers as Record<string, unknown>)) {
|
|
101
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
102
|
+
const incoming = raw as Record<string, unknown>
|
|
103
|
+
const existing = result.servers[id] ?? { enabled: false, transport: 'stdio' as const }
|
|
104
|
+
const merged: RezMcpServerSpec = { ...existing }
|
|
105
|
+
if (typeof incoming.enabled === 'boolean') merged.enabled = incoming.enabled
|
|
106
|
+
if (incoming.transport === 'stdio' || incoming.transport === 'streamable-http') merged.transport = incoming.transport
|
|
107
|
+
if (typeof incoming.command === 'string') merged.command = incoming.command
|
|
108
|
+
if (Array.isArray(incoming.args)) merged.args = incoming.args.map(String)
|
|
109
|
+
if (typeof incoming.cwd === 'string') merged.cwd = incoming.cwd
|
|
110
|
+
if (typeof incoming.url === 'string') merged.url = incoming.url
|
|
111
|
+
if (typeof incoming.toolCallTimeoutMs === 'number') merged.toolCallTimeoutMs = incoming.toolCallTimeoutMs
|
|
112
|
+
if (typeof incoming.env === 'object' && incoming.env !== null) {
|
|
113
|
+
merged.env = { ...(existing.env ?? {}), ...(incoming.env as Record<string, string>) }
|
|
114
|
+
}
|
|
115
|
+
if (typeof incoming.headers === 'object' && incoming.headers !== null) {
|
|
116
|
+
merged.headers = { ...(existing.headers ?? {}), ...(incoming.headers as Record<string, string>) }
|
|
117
|
+
}
|
|
118
|
+
result.servers[id] = merged
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const billing = value.billing
|
|
123
|
+
if (typeof billing === 'object' && billing !== null) {
|
|
124
|
+
result.billing = { ...result.billing, ...(billing as object) }
|
|
125
|
+
}
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Load the config, or defaults when the file is absent/invalid. */
|
|
130
|
+
export function loadConfig(): RezConfig {
|
|
131
|
+
const base = defaultConfig()
|
|
132
|
+
const path = rezStorePath()
|
|
133
|
+
if (!existsSync(path)) return base
|
|
134
|
+
try {
|
|
135
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
136
|
+
if (typeof parsed !== 'object' || parsed === null) return base
|
|
137
|
+
const file = parsed as Partial<StoreFile>
|
|
138
|
+
return mergeConfig(base, file.config)
|
|
139
|
+
} catch {
|
|
140
|
+
return base
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Atomically persist the config (tmp + rename) with 0600 permissions. */
|
|
145
|
+
export function saveConfig(config: RezConfig): void {
|
|
146
|
+
const path = rezStorePath()
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
148
|
+
const payload: StoreFile = { version: FORMAT_VERSION, config }
|
|
149
|
+
const tmp = path + '.tmp'
|
|
150
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 })
|
|
151
|
+
renameSync(tmp, path)
|
|
152
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token / audit manager: a SQLite ledger under ~/.dsh/dsh-rez-suite.sqlite.
|
|
3
|
+
* Token counts are estimates (JSON/text length / 4); cost uses the panel's
|
|
4
|
+
* per-1k-token rates. The manager never throws into the tool pipeline — a
|
|
5
|
+
* storage failure logs and continues in memory-only mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
9
|
+
import { mkdirSync } from 'node:fs'
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import type { RezAuditRecord, RezAuditResponse, RezRoleId } from './protocol.ts'
|
|
12
|
+
|
|
13
|
+
export interface AuditEntry {
|
|
14
|
+
server: string
|
|
15
|
+
tool: string
|
|
16
|
+
role: RezRoleId | string
|
|
17
|
+
inputTokens: number
|
|
18
|
+
outputTokens: number
|
|
19
|
+
cost: number
|
|
20
|
+
durationMs: number
|
|
21
|
+
ok: boolean
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Rough token estimate: 4 chars ≈ 1 token, with a minimum of 1. */
|
|
26
|
+
export function estimateTokens(value: unknown): number {
|
|
27
|
+
let text = ''
|
|
28
|
+
try {
|
|
29
|
+
text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
|
|
30
|
+
} catch {
|
|
31
|
+
text = String(value)
|
|
32
|
+
}
|
|
33
|
+
const length = text.trim().length
|
|
34
|
+
return length === 0 ? 0 : Math.max(1, Math.ceil(length / 4))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TokenManager {
|
|
38
|
+
private db: DatabaseSync | null = null
|
|
39
|
+
private memory: RezAuditRecord[] = []
|
|
40
|
+
private nextId = 1
|
|
41
|
+
private readonly dbPath: string
|
|
42
|
+
|
|
43
|
+
constructor(dbPath: string) {
|
|
44
|
+
this.dbPath = dbPath
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(dirname(dbPath), { recursive: true })
|
|
47
|
+
const db = new DatabaseSync(dbPath)
|
|
48
|
+
db.exec([
|
|
49
|
+
'CREATE TABLE IF NOT EXISTS usage_log (',
|
|
50
|
+
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
|
|
51
|
+
' ts INTEGER NOT NULL,',
|
|
52
|
+
' server TEXT NOT NULL,',
|
|
53
|
+
' tool TEXT NOT NULL,',
|
|
54
|
+
' role TEXT NOT NULL,',
|
|
55
|
+
' input_tokens INTEGER NOT NULL,',
|
|
56
|
+
' output_tokens INTEGER NOT NULL,',
|
|
57
|
+
' cost REAL NOT NULL,',
|
|
58
|
+
' duration_ms INTEGER NOT NULL,',
|
|
59
|
+
' ok INTEGER NOT NULL,',
|
|
60
|
+
' error TEXT',
|
|
61
|
+
');',
|
|
62
|
+
'CREATE INDEX IF NOT EXISTS idx_usage_log_ts ON usage_log(ts);',
|
|
63
|
+
].join('\n'))
|
|
64
|
+
this.db = db
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.warn('[dsh-rez-suite] sqlite unavailable, audit falls back to memory:', error)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
record(entry: AuditEntry): void {
|
|
71
|
+
const row: RezAuditRecord = {
|
|
72
|
+
id: this.nextId++,
|
|
73
|
+
ts: Date.now(),
|
|
74
|
+
server: entry.server,
|
|
75
|
+
tool: entry.tool,
|
|
76
|
+
role: entry.role,
|
|
77
|
+
inputTokens: Math.max(0, entry.inputTokens),
|
|
78
|
+
outputTokens: Math.max(0, entry.outputTokens),
|
|
79
|
+
cost: Math.max(0, entry.cost),
|
|
80
|
+
durationMs: Math.max(0, entry.durationMs),
|
|
81
|
+
ok: entry.ok,
|
|
82
|
+
error: entry.error,
|
|
83
|
+
}
|
|
84
|
+
if (this.db !== null) {
|
|
85
|
+
try {
|
|
86
|
+
const statement = this.db.prepare(
|
|
87
|
+
'INSERT INTO usage_log (ts, server, tool, role, input_tokens, output_tokens, cost, duration_ms, ok, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
88
|
+
)
|
|
89
|
+
statement.run(
|
|
90
|
+
row.ts,
|
|
91
|
+
row.server,
|
|
92
|
+
row.tool,
|
|
93
|
+
row.role,
|
|
94
|
+
row.inputTokens,
|
|
95
|
+
row.outputTokens,
|
|
96
|
+
row.cost,
|
|
97
|
+
row.durationMs,
|
|
98
|
+
row.ok ? 1 : 0,
|
|
99
|
+
row.error ?? null,
|
|
100
|
+
)
|
|
101
|
+
row.id = Number(this.lastInsertRowId())
|
|
102
|
+
return
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.warn('[dsh-rez-suite] audit write failed, using memory:', error)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.memory.push(row)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private lastInsertRowId(): number | bigint {
|
|
111
|
+
if (this.db === null) return 0
|
|
112
|
+
const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id?: number | bigint } | undefined
|
|
113
|
+
return row?.id ?? 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private rows(): RezAuditRecord[] {
|
|
117
|
+
if (this.db === null) return [...this.memory]
|
|
118
|
+
try {
|
|
119
|
+
const rows = this.db.prepare([
|
|
120
|
+
'SELECT id, ts, server, tool, role, input_tokens AS inputTokens, output_tokens AS outputTokens,',
|
|
121
|
+
' cost, duration_ms AS durationMs, ok, error',
|
|
122
|
+
'FROM usage_log',
|
|
123
|
+
'ORDER BY id DESC',
|
|
124
|
+
'LIMIT 500',
|
|
125
|
+
].join('\n')).all() as Array<Record<string, unknown>>
|
|
126
|
+
return rows.map(row => ({
|
|
127
|
+
id: Number(row.id),
|
|
128
|
+
ts: Number(row.ts),
|
|
129
|
+
server: String(row.server),
|
|
130
|
+
tool: String(row.tool),
|
|
131
|
+
role: String(row.role),
|
|
132
|
+
inputTokens: Number(row.inputTokens),
|
|
133
|
+
outputTokens: Number(row.outputTokens),
|
|
134
|
+
cost: Number(row.cost),
|
|
135
|
+
durationMs: Number(row.durationMs),
|
|
136
|
+
ok: Boolean(row.ok),
|
|
137
|
+
error: row.error === null || row.error === undefined ? undefined : String(row.error),
|
|
138
|
+
}))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.warn('[dsh-rez-suite] audit read failed:', error)
|
|
141
|
+
return [...this.memory]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private recent(limit: number): RezAuditRecord[] {
|
|
146
|
+
if (this.db === null) return this.memory.slice(-limit).reverse()
|
|
147
|
+
try {
|
|
148
|
+
const rows = this.db.prepare(
|
|
149
|
+
'SELECT id, ts, server, tool, role, input_tokens AS inputTokens, output_tokens AS outputTokens, cost, duration_ms AS durationMs, ok, error FROM usage_log ORDER BY id DESC LIMIT ?',
|
|
150
|
+
).all(limit) as Array<Record<string, unknown>>
|
|
151
|
+
return rows.map(row => ({
|
|
152
|
+
id: Number(row.id),
|
|
153
|
+
ts: Number(row.ts),
|
|
154
|
+
server: String(row.server),
|
|
155
|
+
tool: String(row.tool),
|
|
156
|
+
role: String(row.role),
|
|
157
|
+
inputTokens: Number(row.inputTokens),
|
|
158
|
+
outputTokens: Number(row.outputTokens),
|
|
159
|
+
cost: Number(row.cost),
|
|
160
|
+
durationMs: Number(row.durationMs),
|
|
161
|
+
ok: Boolean(row.ok),
|
|
162
|
+
error: row.error === null || row.error === undefined ? undefined : String(row.error),
|
|
163
|
+
}))
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.warn('[dsh-rez-suite] audit recent read failed:', error)
|
|
166
|
+
return this.memory.slice(-limit).reverse()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
summary(monthlyBudget: number): RezAuditResponse {
|
|
171
|
+
const monthStart = new Date()
|
|
172
|
+
monthStart.setDate(1)
|
|
173
|
+
monthStart.setHours(0, 0, 0, 0)
|
|
174
|
+
const monthStartMs = monthStart.getTime()
|
|
175
|
+
|
|
176
|
+
if (this.db !== null) {
|
|
177
|
+
try {
|
|
178
|
+
const totals = this.db.prepare(
|
|
179
|
+
'SELECT COUNT(*) AS calls, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cost),0) AS cost, COALESCE(SUM(CASE WHEN ts >= ? THEN cost ELSE 0 END),0) AS monthCost FROM usage_log',
|
|
180
|
+
).get(monthStartMs) as Record<string, unknown>
|
|
181
|
+
const serverRows = this.db.prepare(
|
|
182
|
+
'SELECT server, COUNT(*) AS calls, COALESCE(SUM(input_tokens + output_tokens),0) AS tokens, COALESCE(SUM(cost),0) AS cost FROM usage_log GROUP BY server ORDER BY server',
|
|
183
|
+
).all() as Array<Record<string, unknown>>
|
|
184
|
+
const byServer: Record<string, { calls: number; tokens: number; cost: number }> = {}
|
|
185
|
+
for (const row of serverRows) {
|
|
186
|
+
byServer[String(row.server)] = { calls: Number(row.calls), tokens: Number(row.tokens), cost: Number(row.cost) }
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
totalCalls: Number(totals.calls),
|
|
190
|
+
totalInputTokens: Number(totals.inputTokens),
|
|
191
|
+
totalOutputTokens: Number(totals.outputTokens),
|
|
192
|
+
totalCost: Number(totals.cost),
|
|
193
|
+
monthlyBudget,
|
|
194
|
+
monthCost: Number(totals.monthCost),
|
|
195
|
+
byServer,
|
|
196
|
+
recent: this.recent(50),
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.warn('[dsh-rez-suite] audit summary read failed, using memory:', error)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const rows = [...this.memory].reverse()
|
|
204
|
+
let totalCalls = 0
|
|
205
|
+
let totalInputTokens = 0
|
|
206
|
+
let totalOutputTokens = 0
|
|
207
|
+
let totalCost = 0
|
|
208
|
+
let monthCost = 0
|
|
209
|
+
const byServer: Record<string, { calls: number; tokens: number; cost: number }> = {}
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
totalCalls += 1
|
|
212
|
+
totalInputTokens += row.inputTokens
|
|
213
|
+
totalOutputTokens += row.outputTokens
|
|
214
|
+
totalCost += row.cost
|
|
215
|
+
if (row.ts >= monthStartMs) monthCost += row.cost
|
|
216
|
+
const bucket = byServer[row.server] ?? (byServer[row.server] = { calls: 0, tokens: 0, cost: 0 })
|
|
217
|
+
bucket.calls += 1
|
|
218
|
+
bucket.tokens += row.inputTokens + row.outputTokens
|
|
219
|
+
bucket.cost += row.cost
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
totalCalls,
|
|
223
|
+
totalInputTokens,
|
|
224
|
+
totalOutputTokens,
|
|
225
|
+
totalCost,
|
|
226
|
+
monthlyBudget,
|
|
227
|
+
monthCost,
|
|
228
|
+
byServer,
|
|
229
|
+
recent: rows.slice(0, 50),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
reset(): void {
|
|
234
|
+
if (this.db !== null) {
|
|
235
|
+
try {
|
|
236
|
+
this.db.exec('DELETE FROM usage_log')
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn('[dsh-rez-suite] audit reset failed:', error)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.memory = []
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
close(): void {
|
|
245
|
+
try {
|
|
246
|
+
this.db?.close()
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore
|
|
249
|
+
}
|
|
250
|
+
this.db = null
|
|
251
|
+
}
|
|
252
|
+
}
|