@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 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.0",
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
- const cfg = readConfig()
78
- if (!cfg.gatewayBaseUrl || !cfg.apiKey) {
79
- throw new Error("尚未配置上游网关,请先运行: opencode-stats-reporter config set --gateway <url> --key <key>")
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
+ }