@dijkspicy/opencode-stats-reporter 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +5 -1
- package/src/cli.js +21 -6
- package/src/opencode-config.js +175 -0
package/README.md
CHANGED
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
npm i -g @dijkspicy/opencode-stats-reporter
|
|
23
|
-
opencode-stats-reporter config set --gateway https://<你的网关> --key <你的 API Key>
|
|
24
23
|
opencode-stats-reporter web start # 打开 http://127.0.0.1:8787
|
|
25
24
|
```
|
|
26
25
|
|
|
26
|
+
- **无需单独填网关和 Key**:默认自动从 opencode 配置读取——取所选模型对应 provider 的 `options.baseURL` / `options.apiKey`(Key 不在配置里时回退到 opencode 凭据存储 `auth.json`);也可显式覆盖:`opencode-stats-reporter config set --gateway <url> --key <key>`,或环境变量 `OPENCODE_STATS_GATEWAY` / `OPENCODE_STATS_API_KEY`(优先级:本工具配置 > 环境变量 > opencode 推导);
|
|
27
|
+
|
|
27
28
|
- 本地服务同时做三件事:托管静态面板、把 `/v1/usage/*` 与 `/v1/models` **反代**到你的网关(Key 只保存在服务端)、并在本地 SQLite 存储会话用量;
|
|
28
29
|
- 浏览器只与 `localhost` 通信,因此**没有 CORS 问题,上游 Key 也不会进入浏览器**;
|
|
29
30
|
- 会话上报插件,**推荐用 opencode 自带的插件命令安装**(走 npm,随包更新):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dijkspicy/opencode-stats-reporter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./contrib/opencode-session-reporter/plugin-server.ts",
|
|
@@ -26,5 +26,9 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"test": "node --test test/*.test.js"
|
|
28
28
|
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
29
33
|
"license": "Apache-2.0"
|
|
30
34
|
}
|
package/src/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs"
|
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { spawn } from "node:child_process"
|
|
4
4
|
import { readConfig, setConfig, maskKey } from "./config.js"
|
|
5
|
+
import { resolveGateway } from "./opencode-config.js"
|
|
5
6
|
import { dbFile, panelDir, pluginSource, opencodePluginsDir, PKG_ROOT } from "./paths.js"
|
|
6
7
|
import { Store } from "./store.js"
|
|
7
8
|
import { createServer } from "./server.js"
|
|
@@ -63,8 +64,14 @@ function cmdConfig(argv) {
|
|
|
63
64
|
}
|
|
64
65
|
if (sub === "get") {
|
|
65
66
|
const cfg = readConfig()
|
|
66
|
-
console.log("gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
67
|
-
console.log("apiKey: " + maskKey(cfg.apiKey))
|
|
67
|
+
console.log("显式配置 gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
68
|
+
console.log("显式配置 apiKey: " + maskKey(cfg.apiKey))
|
|
69
|
+
const r = resolveGateway({ fileConfig: cfg })
|
|
70
|
+
if (r.ok) {
|
|
71
|
+
console.log("生效网关: " + r.config.gatewayBaseUrl + "(来源: " + r.source + (r.providerId ? ",provider=" + r.providerId : "") + ")")
|
|
72
|
+
} else {
|
|
73
|
+
console.log("生效网关: (未能解析,请先 `config set` 或确保 opencode 配置含 provider baseURL/apiKey)")
|
|
74
|
+
}
|
|
68
75
|
return
|
|
69
76
|
}
|
|
70
77
|
throw new Error("用法: opencode-stats-reporter config set|get")
|
|
@@ -74,10 +81,17 @@ async function cmdWeb(argv) {
|
|
|
74
81
|
const sub = argv[0]
|
|
75
82
|
if (sub !== "start") throw new Error("用法: opencode-stats-reporter web start")
|
|
76
83
|
const flags = parseFlags(argv.slice(1))
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
// 上游配置:显式 config.json > 环境变量 > 从 opencode 配置推导(无需手填)
|
|
85
|
+
const resolved = resolveGateway({ fileConfig: readConfig() })
|
|
86
|
+
if (!resolved.ok) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"未能确定上游网关。请任选其一:\n" +
|
|
89
|
+
" a) 直接使用 opencode 配置(确保 opencode 里某个 provider 有 baseURL 与 apiKey)\n" +
|
|
90
|
+
" b) 显式配置: opencode-stats-reporter config set --gateway <url> --key <key>\n" +
|
|
91
|
+
" c) 环境变量: OPENCODE_STATS_GATEWAY / OPENCODE_STATS_API_KEY",
|
|
92
|
+
)
|
|
80
93
|
}
|
|
94
|
+
const cfg = resolved.config
|
|
81
95
|
const port = Number(flags.port || DEFAULT_PORT)
|
|
82
96
|
const host = String(flags.host || DEFAULT_HOST)
|
|
83
97
|
const mode = String(flags.mode || process.env.SESSION_USAGE_MODE || "local")
|
|
@@ -95,7 +109,8 @@ async function cmdWeb(argv) {
|
|
|
95
109
|
|
|
96
110
|
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`
|
|
97
111
|
console.log("本地看板已启动: " + url)
|
|
98
|
-
console.log("上游网关: " + cfg.gatewayBaseUrl
|
|
112
|
+
console.log("上游网关: " + cfg.gatewayBaseUrl + "(来源: " + resolved.source +
|
|
113
|
+
(resolved.providerId ? ",provider=" + resolved.providerId : "") + ")")
|
|
99
114
|
console.log("数据文件: " + dbFile())
|
|
100
115
|
if (!loopback) console.warn("警告: 正在监听非回环地址 " + host + ",本机网络内可访问,请自行确认风险")
|
|
101
116
|
if (flags.open) openBrowser(url)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 opencode 自身配置推导上游网关与凭据,免去重复配置。
|
|
3
|
+
*
|
|
4
|
+
* 来源与优先级(后者仅在前者缺失时生效):
|
|
5
|
+
* 1. 本工具显式配置(`config set` 写入的 config.json)
|
|
6
|
+
* 2. 环境变量覆盖:OPENCODE_STATS_GATEWAY / OPENCODE_STATS_API_KEY
|
|
7
|
+
* 3. opencode 配置:provider.<default>.options.baseURL + options.apiKey
|
|
8
|
+
* 4. opencode 凭据:<data>/opencode/auth.json 的 <provider>.key
|
|
9
|
+
*
|
|
10
|
+
* 注意:opencode 的 baseURL 指向 OpenAI 兼容根(形如 `https://host/v1`),
|
|
11
|
+
* 而本服务的面板按 `/v1/usage/*` 取路径,故推导网关根时需去掉末尾的 `/v1`。
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs"
|
|
14
|
+
import os from "node:os"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
|
|
17
|
+
/** 字符串感知的 JSONC 去注释 + 去尾逗号,避免误伤 `https://` 里的 `//` */
|
|
18
|
+
export function stripJsonc(text) {
|
|
19
|
+
let out = ""
|
|
20
|
+
let inStr = false
|
|
21
|
+
let quote = ""
|
|
22
|
+
for (let i = 0; i < text.length; i++) {
|
|
23
|
+
const c = text[i]
|
|
24
|
+
const next = text[i + 1]
|
|
25
|
+
if (inStr) {
|
|
26
|
+
out += c
|
|
27
|
+
if (c === "\\") {
|
|
28
|
+
out += next ?? ""
|
|
29
|
+
i++
|
|
30
|
+
} else if (c === quote) {
|
|
31
|
+
inStr = false
|
|
32
|
+
}
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (c === '"' || c === "'") {
|
|
36
|
+
inStr = true
|
|
37
|
+
quote = c
|
|
38
|
+
out += c
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (c === "/" && next === "/") {
|
|
42
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
43
|
+
out += "\n"
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (c === "/" && next === "*") {
|
|
47
|
+
i += 2
|
|
48
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
49
|
+
i++
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
out += c
|
|
53
|
+
}
|
|
54
|
+
return out.replace(/,(\s*[}\]])/g, "$1")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseJsonc(text) {
|
|
58
|
+
return JSON.parse(stripJsonc(text))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function opencodeConfigDir() {
|
|
62
|
+
return process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), ".config", "opencode")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function opencodeDataDir() {
|
|
66
|
+
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share")
|
|
67
|
+
return path.join(base, "opencode")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readConfigFiles() {
|
|
71
|
+
const explicit = process.env.OPENCODE_CONFIG
|
|
72
|
+
const candidates = explicit
|
|
73
|
+
? [explicit]
|
|
74
|
+
: ["config.json", "opencode.json", "opencode.jsonc"].map((f) => path.join(opencodeConfigDir(), f))
|
|
75
|
+
const merged = {}
|
|
76
|
+
const readFrom = []
|
|
77
|
+
for (const f of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
const obj = parseJsonc(fs.readFileSync(f, "utf8"))
|
|
80
|
+
if (obj && typeof obj === "object") {
|
|
81
|
+
Object.assign(merged, obj)
|
|
82
|
+
readFrom.push(f)
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* 文件不存在或不可解析时跳过 */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { config: merged, readFrom }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readAuth() {
|
|
92
|
+
try {
|
|
93
|
+
const p = path.join(opencodeDataDir(), "auth.json")
|
|
94
|
+
const obj = JSON.parse(fs.readFileSync(p, "utf8"))
|
|
95
|
+
return obj && typeof obj === "object" ? obj : {}
|
|
96
|
+
} catch {
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** opencode baseURL → 本服务网关根(去掉末尾 /v1,因为面板路径自带 /v1 前缀) */
|
|
102
|
+
export function gatewayRootFromBaseUrl(baseUrl) {
|
|
103
|
+
const trimmed = String(baseUrl).trim().replace(/\/+$/, "")
|
|
104
|
+
return trimmed.replace(/\/v1$/i, "")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pickProvider(cfg) {
|
|
108
|
+
const providers = cfg.provider && typeof cfg.provider === "object" ? cfg.provider : {}
|
|
109
|
+
const ids = Object.keys(providers)
|
|
110
|
+
const modelId = typeof cfg.model === "string" && cfg.model.includes("/") ? cfg.model.split("/")[0] : null
|
|
111
|
+
if (modelId && providers[modelId]) return { id: modelId, provider: providers[modelId] }
|
|
112
|
+
const withBase = ids.filter((id) => providers[id] && providers[id].options && providers[id].options.baseURL)
|
|
113
|
+
if (withBase.length === 1) return { id: withBase[0], provider: providers[withBase[0]] }
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 解析出本服务所需的上游配置。
|
|
119
|
+
* @returns {{gatewayBaseUrl:string, apiKey:string, source:string, providerId?:string, model?:string}|null}
|
|
120
|
+
*/
|
|
121
|
+
export function discoverGatewayFromOpencode() {
|
|
122
|
+
const { config, readFrom } = readConfigFiles()
|
|
123
|
+
const picked = pickProvider(config)
|
|
124
|
+
if (!picked) return null
|
|
125
|
+
const opts = picked.provider.options || {}
|
|
126
|
+
if (!opts.baseURL) return null
|
|
127
|
+
const auth = readAuth()
|
|
128
|
+
const apiKey = (typeof opts.apiKey === "string" && opts.apiKey.trim()) || auth[picked.id]?.key || ""
|
|
129
|
+
if (!apiKey) return null
|
|
130
|
+
return {
|
|
131
|
+
gatewayBaseUrl: gatewayRootFromBaseUrl(opts.baseURL),
|
|
132
|
+
apiKey: String(apiKey),
|
|
133
|
+
source: "opencode 配置" + (readFrom.length ? ` (${readFrom.map((f) => path.basename(f)).join(", ")})` : ""),
|
|
134
|
+
providerId: picked.id,
|
|
135
|
+
model: typeof config.model === "string" ? config.model : undefined,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 生效配置(按来源整体选取,避免把 A 的网关和 B 的 Key 拼一起):
|
|
141
|
+
* 1. 本工具显式配置(需成对齐全)
|
|
142
|
+
* 2. 环境变量(需成对齐全)
|
|
143
|
+
* 3. opencode 配置推导
|
|
144
|
+
* 选定来源后,允许环境变量做单项覆盖。
|
|
145
|
+
*/
|
|
146
|
+
export function resolveGateway({ fileConfig = {}, env = process.env } = {}) {
|
|
147
|
+
const discovered = discoverGatewayFromOpencode()
|
|
148
|
+
let config = null
|
|
149
|
+
let source = null
|
|
150
|
+
|
|
151
|
+
if (fileConfig.gatewayBaseUrl && fileConfig.apiKey) {
|
|
152
|
+
config = { gatewayBaseUrl: fileConfig.gatewayBaseUrl, apiKey: fileConfig.apiKey }
|
|
153
|
+
source = "本工具配置 (config.json)"
|
|
154
|
+
} else if (env.OPENCODE_STATS_GATEWAY && env.OPENCODE_STATS_API_KEY) {
|
|
155
|
+
config = { gatewayBaseUrl: env.OPENCODE_STATS_GATEWAY, apiKey: env.OPENCODE_STATS_API_KEY }
|
|
156
|
+
source = "环境变量"
|
|
157
|
+
} else if (discovered) {
|
|
158
|
+
config = { gatewayBaseUrl: discovered.gatewayBaseUrl, apiKey: discovered.apiKey }
|
|
159
|
+
source = discovered.source
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!config) return { ok: false, source: null, discovered }
|
|
163
|
+
|
|
164
|
+
if (env.OPENCODE_STATS_GATEWAY) config.gatewayBaseUrl = env.OPENCODE_STATS_GATEWAY
|
|
165
|
+
if (env.OPENCODE_STATS_API_KEY) config.apiKey = env.OPENCODE_STATS_API_KEY
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
config,
|
|
170
|
+
source,
|
|
171
|
+
providerId: discovered?.providerId,
|
|
172
|
+
model: discovered?.model,
|
|
173
|
+
discovered,
|
|
174
|
+
}
|
|
175
|
+
}
|