|
@@ -1,220 +1,151 @@
|
|
|
-// KirinCode Server v0.6.0 — Personal Usage + Admin API
|
|
|
|
|
-const PORT = 4097
|
|
|
|
|
-const { app } = require("electron")
|
|
|
|
|
-const path = require("path")
|
|
|
|
|
-const fs = require("fs")
|
|
|
|
|
-const { randomUUID, createHmac } = require("crypto")
|
|
|
|
|
-
|
|
|
|
|
-const DATA_FILE = path.join(app?.getPath("userData") || process.env.HOME || "/tmp", "kirincode-admin.json")
|
|
|
|
|
-
|
|
|
|
|
-let data: any = { users: {}, teams: {}, models: {}, usage: [] }
|
|
|
|
|
-try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
|
|
|
|
|
-const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8")
|
|
|
|
|
-
|
|
|
|
|
-// JWT
|
|
|
|
|
-const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-" + "v1")
|
|
|
|
|
-function signToken(uid: string, role: string): string {
|
|
|
|
|
- const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
|
|
|
|
|
- const p = Buffer.from(JSON.stringify({ sub: uid, role, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")
|
|
|
|
|
- const s = createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url")
|
|
|
|
|
- return `${h}.${p}.${s}`
|
|
|
|
|
-}
|
|
|
|
|
-function verifyToken(t: string): { sub: string; role: string } | null {
|
|
|
|
|
- try { const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()); return p.exp*1000 > Date.now() ? { sub: p.sub, role: p.role } : null }
|
|
|
|
|
- catch { return null }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const pricing: Record<string, Record<string, [number, number]>> = {
|
|
|
|
|
- anthropic: { "claude-sonnet-4-5": [3,15], "claude-haiku-4-5": [0.8,4] },
|
|
|
|
|
- openai: { "gpt-4o": [2.5,10], "gpt-4o-mini": [0.15,0.6] },
|
|
|
|
|
- deepseek: { "deepseek-v4-pro": [1.74,3.48], "deepseek-v4-flash": [0.14,0.28] },
|
|
|
|
|
- google: { "gemini-2.5-pro": [3.5,10.5], "gemini-2.5-flash": [0.4,1.5] },
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function cost(provider: string, model: string, input: number, output: number): number {
|
|
|
|
|
- const p = pricing[provider]?.[model]; if (!p) return 0
|
|
|
|
|
- return Math.round(((input/1e6)*p[0] + (output/1e6)*p[1]) * 1e6) / 1e6
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function auth(req: any): { sub: string; role: string } | null {
|
|
|
|
|
- const h = req.headers["authorization"]?.replace("Bearer ", "")
|
|
|
|
|
- if (!h) return null
|
|
|
|
|
- return verifyToken(h)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const json = (res: any, d: any, s = 200) => { res.writeHead(s, {"Content-Type":"application/json","Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"Content-Type,Authorization"}); res.end(JSON.stringify(d)) }
|
|
|
|
|
-
|
|
|
|
|
-async function body(req: any): Promise<any> {
|
|
|
|
|
- return new Promise(r => { let b=""; req.on("data",(c:any)=>b+=c); req.on("end",()=>{try{r(JSON.parse(b))}catch{r({})}}) })
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Cleanup old usage data (keep last 50000)
|
|
|
|
|
-function trim() { if (data.usage.length > 50000) data.usage = data.usage.slice(-50000) }
|
|
|
|
|
-
|
|
|
|
|
-// ===== Server =====
|
|
|
|
|
-require("http").createServer(async (req: any, res: any) => {
|
|
|
|
|
- if (req.method === "OPTIONS") { res.writeHead(200, {"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST,PUT,DELETE","Access-Control-Allow-Headers":"Content-Type,Authorization"}); return res.end() }
|
|
|
|
|
-
|
|
|
|
|
- const u = new URL(req.url, "http://localhost")
|
|
|
|
|
- const p = u.pathname
|
|
|
|
|
|
|
+// KirinCode Admin Server — starts on port 4097, called from sidecar
|
|
|
|
|
+export function startAdminServer(userDataPath?: string) {
|
|
|
|
|
+ const path = require("path")
|
|
|
|
|
+ const fs = require("fs")
|
|
|
|
|
+ const { randomUUID, createHmac } = require("crypto")
|
|
|
|
|
+
|
|
|
|
|
+ const PORT = 4097
|
|
|
|
|
+ const DATA_FILE = path.join(userDataPath || process.env.HOME || "/tmp", "kirincode-admin.json")
|
|
|
|
|
+
|
|
|
|
|
+ let data: any = { users: {}, teams: {}, models: {}, usage: [] }
|
|
|
|
|
+ try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
|
|
|
|
|
+ const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8")
|
|
|
|
|
+
|
|
|
|
|
+ const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-v2")
|
|
|
|
|
+ function signToken(uid: string, role: string): string {
|
|
|
|
|
+ const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
|
|
|
|
|
+ const p = Buffer.from(JSON.stringify({ sub: uid, role, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")
|
|
|
|
|
+ const s = createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url")
|
|
|
|
|
+ return `${h}.${p}.${s}`
|
|
|
|
|
+ }
|
|
|
|
|
+ function verifyToken(t: string): { sub: string; role: string } | null {
|
|
|
|
|
+ try { const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()); return p.exp*1000 > Date.now() ? { sub: p.sub, role: p.role } : null }
|
|
|
|
|
+ catch { return null }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // --- Health ---
|
|
|
|
|
- if (p === "/api/v1/health") return json(res, { status: "ok", version: "0.6.0", users: Object.keys(data.users).length })
|
|
|
|
|
|
|
+ function hashPw(pw: string) { return require("crypto").createHash("sha256").update(pw + JWT_SECRET).digest("hex") }
|
|
|
|
|
|
|
|
- // --- Auth ---
|
|
|
|
|
- if (p === "/api/v1/auth/register" && req.method === "POST") {
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- if (Object.values(data.users).some((u:any) => u.email === b.email)) return json(res, { error: "邮箱已注册" }, 409)
|
|
|
|
|
- const id = randomUUID()
|
|
|
|
|
- const pw = require("crypto").createHash("sha256").update(b.password + JWT_SECRET).digest("hex")
|
|
|
|
|
- data.users[id] = { id, email: b.email, password: pw, display_name: b.display_name || b.email.split("@")[0], role: "member", team_id: null, status: "pending" }
|
|
|
|
|
- save(); return json(res, { token: signToken(id, "member"), user: { id, email: b.email, display_name: data.users[id].display_name, role: "member", status: "pending" } })
|
|
|
|
|
|
|
+ function cost(provider: string, model: string, input: number, output: number): number {
|
|
|
|
|
+ const p: Record<string, Record<string, [number, number]>> = {
|
|
|
|
|
+ anthropic: { "claude-sonnet-4-5": [3,15], "claude-haiku-4-5": [0.8,4] },
|
|
|
|
|
+ openai: { "gpt-4o": [2.5,10], "gpt-4o-mini": [0.15,0.6] },
|
|
|
|
|
+ deepseek: { "deepseek-v4-pro": [1.74,3.48], "deepseek-v4-flash": [0.14,0.28] },
|
|
|
|
|
+ google: { "gemini-2.5-pro": [3.5,10.5], "gemini-2.5-flash": [0.4,1.5] },
|
|
|
|
|
+ }
|
|
|
|
|
+ const pr = p[provider]?.[model]; if (!pr) return 0
|
|
|
|
|
+ return Math.round(((input/1e6)*pr[0] + (output/1e6)*pr[1]) * 1e6) / 1e6
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (p === "/api/v1/auth/login" && req.method === "POST") {
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- const pw = require("crypto").createHash("sha256").update(b.password + JWT_SECRET).digest("hex")
|
|
|
|
|
- const user = Object.values(data.users).find((u:any) => u.email === b.email && u.password === pw) as any
|
|
|
|
|
- if (!user) return json(res, { error: "邮箱或密码错误" }, 401)
|
|
|
|
|
- if (user.status === "disabled") return json(res, { error: "账号已被禁用" }, 403)
|
|
|
|
|
- return json(res, { token: signToken(user.id, user.role), user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id, status: user.status } })
|
|
|
|
|
|
|
+ function auth(req: any): { sub: string; role: string } | null {
|
|
|
|
|
+ const h = (req.headers["authorization"] || req.headers["Authorization"] || "").replace("Bearer ", "")
|
|
|
|
|
+ if (!h) return null
|
|
|
|
|
+ return verifyToken(h)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // --- Record usage (any authenticated user) ---
|
|
|
|
|
- const userAuth = auth(req)
|
|
|
|
|
- if (p.match(/^\/api\/v1\/prompt$/) && req.method === "POST" && userAuth) {
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- const text = b.text || ""
|
|
|
|
|
- const it = Math.ceil(text.length / 4); const ot = Math.ceil(text.length / 3)
|
|
|
|
|
- const c = cost(b.provider || "anthropic", b.model || "claude-sonnet-4-5", it, ot)
|
|
|
|
|
- data.usage.push({ id: randomUUID(), user_id: userAuth.sub, team_id: (data.users[userAuth.sub] as any)?.team_id, provider: b.provider || "anthropic", model: b.model || "claude-sonnet-4-5", input_tokens: it, output_tokens: ot, cost_usd: c, duration_ms: b.duration || 3000, created_at: new Date().toISOString() })
|
|
|
|
|
- trim(); save()
|
|
|
|
|
- return json(res, { recorded: true, tokens: it + ot })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ function json(res: any, d: any, s = 200) { res.writeHead(s, {"Content-Type":"application/json","Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"Content-Type,Authorization"}); res.end(JSON.stringify(d)) }
|
|
|
|
|
|
|
|
- // ===== Personal Usage (用户自己查看) =====
|
|
|
|
|
- if (p === "/api/v1/personal/summary" && userAuth) {
|
|
|
|
|
- const uid = userAuth.sub
|
|
|
|
|
- const today = data.usage.filter((e:any) => e.user_id === uid && new Date(e.created_at) > new Date(Date.now()-86400000))
|
|
|
|
|
- const total = { input: 0, output: 0, requests: today.length, cost: 0 }
|
|
|
|
|
- for (const e of today) { total.input += e.input_tokens; total.output += e.output_tokens; total.cost += e.cost_usd }
|
|
|
|
|
- return json(res, total)
|
|
|
|
|
|
|
+ async function body(req: any): Promise<any> {
|
|
|
|
|
+ return new Promise(r => { let b=""; req.on("data",(c:any)=>b+=c); req.on("end",()=>{try{r(JSON.parse(b))}catch{r({})}}) })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (p === "/api/v1/personal/trend" && userAuth) {
|
|
|
|
|
- const uid = userAuth.sub
|
|
|
|
|
- const m: Record<string,any> = {}
|
|
|
|
|
- data.usage.filter((e:any) => e.user_id === uid && new Date(e.created_at) > new Date(Date.now()-86400000)).forEach((e:any) => {
|
|
|
|
|
- const k = e.created_at.slice(0,16)
|
|
|
|
|
- if (!m[k]) m[k] = { minute: k, input:0, output:0, requests:0, cost:0 }
|
|
|
|
|
- m[k].input += e.input_tokens; m[k].output += e.output_tokens; m[k].requests++; m[k].cost += e.cost_usd
|
|
|
|
|
- })
|
|
|
|
|
- return json(res, Object.values(m).sort((a:any,b:any) => a.minute.localeCompare(b.minute)))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ function trim() { if (data.usage.length > 50000) data.usage = data.usage.slice(-50000) }
|
|
|
|
|
|
|
|
- if (p === "/api/v1/personal/detail" && userAuth) {
|
|
|
|
|
- const uid = userAuth.sub
|
|
|
|
|
- const page = parseInt(u.searchParams.get("page") || "1"); const limit = 20
|
|
|
|
|
- const rows = data.usage.filter((e:any) => e.user_id === uid).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
|
|
|
|
|
- const total = rows.length
|
|
|
|
|
- const paged = rows.slice((page-1)*limit, page*limit)
|
|
|
|
|
- return json(res, { data: paged, total, page, pages: Math.ceil(total/limit) })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ require("http").createServer(async (req: any, res: any) => {
|
|
|
|
|
+ if (req.method === "OPTIONS") { res.writeHead(200, {"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST,PUT,DELETE","Access-Control-Allow-Headers":"Content-Type,Authorization"}); return res.end() }
|
|
|
|
|
+ const u = new URL(req.url, "http://localhost"); const p = u.pathname
|
|
|
|
|
|
|
|
- // ===== Admin only below =====
|
|
|
|
|
- if (!userAuth || userAuth.role !== "admin") return json(res, { error: "Not found" }, 404)
|
|
|
|
|
|
|
+ if (p === "/api/v1/health") return json(res, { status: "ok", version: "0.7.1", users: Object.keys(data.users).length })
|
|
|
|
|
|
|
|
- // --- Team Management ---
|
|
|
|
|
- if (p === "/api/v1/admin/teams" && req.method === "GET") return json(res, Object.values(data.teams))
|
|
|
|
|
- if (p === "/api/v1/admin/teams" && req.method === "POST") {
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- const id = randomUUID()
|
|
|
|
|
- data.teams[id] = { id, name: b.name, owner_id: userAuth.sub, created_at: new Date().toISOString() }
|
|
|
|
|
- save(); return json(res, data.teams[id])
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Auth
|
|
|
|
|
+ if (p === "/api/v1/auth/register" && req.method === "POST") {
|
|
|
|
|
+ const b = await body(req)
|
|
|
|
|
+ if (Object.values(data.users).some((x:any) => x.email === b.email)) return json(res, { error: "邮箱已注册" }, 409)
|
|
|
|
|
+ const id = randomUUID()
|
|
|
|
|
+ data.users[id] = { id, email: b.email, password: hashPw(b.password), display_name: b.display_name || b.email.split("@")[0], role: "member", team_id: null, status: "active" }
|
|
|
|
|
+ save(); return json(res, { token: signToken(id, "member"), user: data.users[id] })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p === "/api/v1/auth/login" && req.method === "POST") {
|
|
|
|
|
+ const b = await body(req)
|
|
|
|
|
+ const user = Object.values(data.users).find((x:any) => x.email === b.email && x.password === hashPw(b.password)) as any
|
|
|
|
|
+ if (!user) return json(res, { error: "邮箱或密码错误" }, 401)
|
|
|
|
|
+ return json(res, { token: signToken(user.id, user.role), user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id } })
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // --- User Management (CRUD) for team ---
|
|
|
|
|
- if (p === "/api/v1/admin/users" && req.method === "GET") {
|
|
|
|
|
- const teamId = u.searchParams.get("team_id") || (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- const users = Object.values(data.users).filter((u:any) => u.team_id === teamId).map((u:any) => ({
|
|
|
|
|
- id: u.id, email: u.email, display_name: u.display_name, role: u.role, status: u.status || "active",
|
|
|
|
|
- usage: data.usage.filter((e:any) => e.user_id === u.id).reduce((s:any,e:any)=>({input:s.input+e.input_tokens,output:s.output+e.output_tokens,requests:s.requests+1,cost:s.cost+e.cost_usd}),{input:0,output:0,requests:0,cost:0})
|
|
|
|
|
- }))
|
|
|
|
|
- return json(res, users)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Record usage
|
|
|
|
|
+ const ua = auth(req)
|
|
|
|
|
+ if (p === "/api/v1/prompt" && req.method === "POST" && ua) {
|
|
|
|
|
+ const b = await body(req)
|
|
|
|
|
+ const it = Math.ceil((b.text||"").length/4); const ot = Math.ceil((b.text||"").length/3)
|
|
|
|
|
+ const c = cost(b.provider||"anthropic", b.model||"claude-sonnet-4-5", it, ot)
|
|
|
|
|
+ data.usage.push({ id: randomUUID(), user_id: ua.sub, team_id: (data.users[ua.sub] as any)?.team_id, provider: b.provider||"anthropic", model: b.model||"claude-sonnet-4-5", input_tokens: it, output_tokens: ot, cost_usd: c, created_at: new Date().toISOString() })
|
|
|
|
|
+ trim(); save(); return json(res, { recorded: true, tokens: it+ot })
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (p.match(/^\/api\/v1\/admin\/users\/[^/]+$/) && req.method === "PUT") {
|
|
|
|
|
- const uid = p.split("/")[5]
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- if (data.users[uid]) {
|
|
|
|
|
- if (b.display_name) data.users[uid].display_name = b.display_name
|
|
|
|
|
- if (b.role) data.users[uid].role = b.role
|
|
|
|
|
- if (b.status) data.users[uid].status = b.status
|
|
|
|
|
- save()
|
|
|
|
|
|
|
+ // Personal usage
|
|
|
|
|
+ if (p === "/api/v1/personal/summary" && ua) {
|
|
|
|
|
+ const today = data.usage.filter((e:any) => e.user_id === ua.sub && new Date(e.created_at) > new Date(Date.now()-86400000))
|
|
|
|
|
+ return json(res, { input: today.reduce((s:any,e:any)=>s+e.input_tokens,0), output: today.reduce((s:any,e:any)=>s+e.output_tokens,0), requests: today.length, cost: today.reduce((s:any,e:any)=>s+e.cost_usd,0) })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p === "/api/v1/personal/trend" && ua) {
|
|
|
|
|
+ const m: Record<string,any> = {}
|
|
|
|
|
+ data.usage.filter((e:any) => e.user_id === ua.sub && new Date(e.created_at) > new Date(Date.now()-86400000)).forEach((e:any) => {
|
|
|
|
|
+ const k = e.created_at.slice(0,16)
|
|
|
|
|
+ if (!m[k]) m[k] = { minute: k, input:0, output:0, requests:0, cost:0 }
|
|
|
|
|
+ m[k].input += e.input_tokens; m[k].output += e.output_tokens; m[k].requests++; m[k].cost += e.cost_usd
|
|
|
|
|
+ })
|
|
|
|
|
+ return json(res, Object.values(m).sort((a:any,b:any) => a.minute.localeCompare(b.minute)))
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p === "/api/v1/personal/detail" && ua) {
|
|
|
|
|
+ const page = parseInt(u.searchParams.get("page")||"1"); const limit = 20
|
|
|
|
|
+ const rows = data.usage.filter((e:any) => e.user_id === ua.sub).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
|
|
|
|
|
+ return json(res, { data: rows.slice((page-1)*limit, page*limit), total: rows.length, page, pages: Math.ceil(rows.length/limit) })
|
|
|
}
|
|
}
|
|
|
- return json(res, data.users[uid])
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- // --- Model Configuration (admin sets enterprise models) ---
|
|
|
|
|
- if (p === "/api/v1/admin/models" && req.method === "GET") {
|
|
|
|
|
- const teamId = (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- return json(res, Object.values(data.models).filter((m:any) => !m.team_id || m.team_id === teamId))
|
|
|
|
|
- }
|
|
|
|
|
- if (p === "/api/v1/admin/models" && req.method === "POST") {
|
|
|
|
|
- const b = await body(req)
|
|
|
|
|
- const id = randomUUID()
|
|
|
|
|
- const teamId = (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- data.models[id] = { id, team_id: teamId, provider: b.provider, model: b.model, api_key: b.api_key, base_url: b.base_url || "", created_at: new Date().toISOString() }
|
|
|
|
|
- save(); return json(res, data.models[id])
|
|
|
|
|
- }
|
|
|
|
|
- if (p.match(/^\/api\/v1\/admin\/models\/[^/]+$/) && req.method === "DELETE") {
|
|
|
|
|
- const mid = p.split("/")[5]
|
|
|
|
|
- delete data.models[mid]; save()
|
|
|
|
|
- return json(res, { success: true })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Admin
|
|
|
|
|
+ if (!ua || ua.role !== "admin") return json(res, { error: "Not found" }, 404)
|
|
|
|
|
+ const teamId = (data.users[ua.sub] as any)?.team_id
|
|
|
|
|
|
|
|
- // --- Admin Usage Stats ---
|
|
|
|
|
- if (p === "/api/v1/admin/usage/summary") {
|
|
|
|
|
- const teamId = (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- const from = u.searchParams.get("from") || new Date(Date.now()-86400000).toISOString()
|
|
|
|
|
- const filtered = data.usage.filter((e:any) => e.team_id === teamId && e.created_at > from)
|
|
|
|
|
- const r = { input:0, output:0, requests:0, cost:0, users: new Set<string>() }
|
|
|
|
|
- for (const e of filtered) { r.input += e.input_tokens; r.output += e.output_tokens; r.requests++; r.cost += e.cost_usd; r.users.add(e.user_id) }
|
|
|
|
|
- return json(res, { ...r, users: r.users.size })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (p === "/api/v1/admin/usage/summary") {
|
|
|
|
|
+ const filtered = data.usage.filter((e:any) => e.team_id === teamId && new Date(e.created_at) > new Date(Date.now()-86400000))
|
|
|
|
|
+ return json(res, { input: filtered.reduce((s:any,e:any)=>s+e.input_tokens,0), output: filtered.reduce((s:any,e:any)=>s+e.output_tokens,0), requests: filtered.length, cost: filtered.reduce((s:any,e:any)=>s+e.cost_usd,0), users: new Set(filtered.map((e:any)=>e.user_id)).size })
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (p === "/api/v1/admin/usage/detail") {
|
|
|
|
|
- const teamId = (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- const uidFilter = u.searchParams.get("user_id")
|
|
|
|
|
- const modelFilter = u.searchParams.get("model")
|
|
|
|
|
- const from = u.searchParams.get("from") || "1970-01-01"
|
|
|
|
|
- const to = u.searchParams.get("to") || "2099-12-31"
|
|
|
|
|
- const page = parseInt(u.searchParams.get("page") || "1"); const limit = 30
|
|
|
|
|
- const filtered = data.usage.filter((e:any) => {
|
|
|
|
|
- if (e.team_id !== teamId) return false
|
|
|
|
|
- if (uidFilter && e.user_id !== uidFilter) return false
|
|
|
|
|
- if (modelFilter && !e.model.includes(modelFilter)) return false
|
|
|
|
|
- if (e.created_at < from || e.created_at > to) return false
|
|
|
|
|
- return true
|
|
|
|
|
- }).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
|
|
|
|
|
- const total = filtered.length
|
|
|
|
|
- const paged = filtered.slice((page-1)*limit, page*limit).map((e:any) => {
|
|
|
|
|
- const u = data.users[e.user_id] || {}
|
|
|
|
|
- return { ...e, display_name: (u as any).display_name || e.user_id, email: (u as any).email || "" }
|
|
|
|
|
- })
|
|
|
|
|
- return json(res, { data: paged, total, page, pages: Math.ceil(total/limit) })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (p === "/api/v1/admin/users") {
|
|
|
|
|
+ return json(res, Object.values(data.users).filter((u:any) => u.team_id === teamId).map((u:any) => ({
|
|
|
|
|
+ id: u.id, email: u.email, display_name: u.display_name, role: u.role, status: u.status||"active",
|
|
|
|
|
+ usage: data.usage.filter((e:any) => e.user_id === u.id).reduce((s:any,e:any)=>({input:s.input+e.input_tokens,output:s.output+e.output_tokens,requests:s.requests+1,cost:s.cost+e.cost_usd}),{input:0,output:0,requests:0,cost:0})
|
|
|
|
|
+ })))
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p.match(/^\/api\/v1\/admin\/users\/[^/]+$/) && req.method === "PUT") {
|
|
|
|
|
+ const uid = p.split("/")[5]
|
|
|
|
|
+ const b = await body(req)
|
|
|
|
|
+ if (data.users[uid]) { if (b.display_name) data.users[uid].display_name = b.display_name; if (b.role) data.users[uid].role = b.role; if (b.status) data.users[uid].status = b.status; save() }
|
|
|
|
|
+ return json(res, data.users[uid])
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (p === "/api/v1/admin/usage/export") {
|
|
|
|
|
- const teamId = (data.users[userAuth.sub] as any)?.team_id
|
|
|
|
|
- const csv = ["时间,用户,邮箱,模型,Token(入),Token(出),费用(USD)"]
|
|
|
|
|
- data.usage.filter((e:any) => e.team_id === teamId).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at)).forEach((e:any) => {
|
|
|
|
|
- const u = data.users[e.user_id] || {}
|
|
|
|
|
- csv.push(`${e.created_at},${(u as any).display_name||e.user_id},${(u as any).email||""},${e.provider}/${e.model},${e.input_tokens},${e.output_tokens},${e.cost_usd}`)
|
|
|
|
|
- })
|
|
|
|
|
- res.writeHead(200, {"Content-Type":"text/csv","Content-Disposition":"attachment;filename=usage-export.csv","Access-Control-Allow-Origin":"*"})
|
|
|
|
|
- return res.end(csv.join("\n"))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (p === "/api/v1/admin/models" && req.method === "GET") return json(res, Object.values(data.models).filter((m:any) => !m.team_id || m.team_id === teamId))
|
|
|
|
|
+ if (p === "/api/v1/admin/models" && req.method === "POST") {
|
|
|
|
|
+ const b = await body(req); const id = randomUUID()
|
|
|
|
|
+ data.models[id] = { id, team_id: teamId, provider: b.provider, model: b.model, api_key: b.api_key, created_at: new Date().toISOString() }
|
|
|
|
|
+ save(); return json(res, data.models[id])
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p.match(/^\/api\/v1\/admin\/models\/[^/]+$/) && req.method === "DELETE") { delete data.models[p.split("/")[5]]; save(); return json(res, {success: true}) }
|
|
|
|
|
|
|
|
- json(res, { error: "Not found" }, 404)
|
|
|
|
|
-}).listen(PORT, () => console.log(`Admin API: http://localhost:${PORT}`))
|
|
|
|
|
|
|
+ if (p === "/api/v1/admin/usage/detail") {
|
|
|
|
|
+ const uidF = u.searchParams.get("user_id"); const mdlF = u.searchParams.get("model"); const page = parseInt(u.searchParams.get("page")||"1")
|
|
|
|
|
+ const filtered = data.usage.filter((e:any) => e.team_id === teamId && (!uidF || e.user_id===uidF) && (!mdlF || e.model.includes(mdlF))).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
|
|
|
|
|
+ return json(res, { data: filtered.slice((page-1)*30, page*30).map((e:any)=>({...e, display_name: (data.users[e.user_id] as any)?.display_name||e.user_id, email: (data.users[e.user_id] as any)?.email||""})), total: filtered.length, page, pages: Math.ceil(filtered.length/30) })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (p === "/api/v1/admin/usage/export") {
|
|
|
|
|
+ const csv = ["时间,用户,邮箱,模型,Token入,Token出,费用"]
|
|
|
|
|
+ data.usage.filter((e:any) => e.team_id === teamId).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at)).forEach((e:any) => {
|
|
|
|
|
+ csv.push(`${e.created_at},${(data.users[e.user_id] as any)?.display_name||e.user_id},${(data.users[e.user_id] as any)?.email||""},${e.provider}/${e.model},${e.input_tokens},${e.output_tokens},${e.cost_usd}`)
|
|
|
|
|
+ })
|
|
|
|
|
+ res.writeHead(200, {"Content-Type":"text/csv;charset=utf-8","Content-Disposition":"attachment;filename=usage.csv","Access-Control-Allow-Origin":"*"})
|
|
|
|
|
+ return res.end("\uFEFF" + csv.join("\n"))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ json(res, { error: "Not found" }, 404)
|
|
|
|
|
+ }).listen(PORT, () => console.log(`[admin] http://localhost:${PORT}`))
|
|
|
|
|
+}
|