@dijkspicy/opencode-stats-reporter 0.1.0 → 0.1.2

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,17 +20,18 @@
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> [--endpoint <url>]`,或环境变量 `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,随包更新):
30
31
  ```bash
31
32
  opencode plugin @dijkspicy/opencode-stats-reporter -g
32
33
  ```
33
- 离线/本地文件方式作为替代:`opencode-stats-reporter plugin install`。之后在 opencode 进程环境设置 `SESSION_USAGE_ENDPOINT=http://127.0.0.1:8787/v1/session-usage/report`(本地模式无需 `SESSION_USAGE_API_KEY`,且不要用 `--pure` 启动);
34
+ 离线/本地文件方式作为替代:`opencode-stats-reporter plugin install`。插件**零配置**:启动服务后插件自动上报(`web start` 会把实际端点写入 `config.json`,插件未设环境变量时也生效;如需覆盖可用 `config set --endpoint <url>` 或环境变量 `SESSION_USAGE_ENDPOINT`。本地模式无需 `SESSION_USAGE_API_KEY`,且不要用 `--pure` 启动);
34
35
  - 默认仅监听 `127.0.0.1`;数据落在 `~/.local/share/opencode-stats-reporter/`,不会上传第三方;
35
36
  - **托管模式**:`opencode-stats-reporter web start --mode gateway` 时,会话接口要求网关注入的身份(`X-Forward-Consumer`),并按消费者做租户隔离;本地模式则为单一本地用户;
36
37
  - 要求 Node ≥ 24(使用内置 `node:sqlite`,零外部依赖)。
@@ -22,11 +22,23 @@ cp session-usage-reporter.ts .opencode/plugins/
22
22
  - 注意:以 `--pure` 启动 opencode 会禁用全部外部插件(包括本插件)。
23
23
  - 一个 opencode server 实例加载一份插件实例;插件按事件里的 `sessionID` 处理对应会话,无需多份。
24
24
 
25
+ ## 零配置:上报端点解析
26
+
27
+ 插件**始终激活**,无需任何环境变量。上报端点按以下优先级解析(从高到低):
28
+
29
+ 1. 环境变量 `SESSION_USAGE_ENDPOINT`(trim 后非空即用;保留为显式覆盖)
30
+ 2. 本工具配置文件 `<configDir>/config.json` 的 `reportEndpoint` 字段(须是合法 http(s) URL)
31
+ 3. 内置默认值 `http://127.0.0.1:8787/v1/session-usage/report`
32
+
33
+ 其中 `configDir` 为 `MONITOR_CONFIG_DIR` 或 `$XDG_CONFIG_HOME/opencode-stats-reporter`(与 `src/paths.js` 一致)。配套 CLI 的 `web start` 启动后会自动把实际监听端口的端点(`http://127.0.0.1:<port>/v1/session-usage/report`)写入 `config.json` 的 `reportEndpoint`,因此**启动服务 + 安装插件即可,无需任何手动配置**。
34
+
35
+ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默回退到下一优先级,解析过程绝不抛错。插件初始化成功后会通过 `app.log` 输出一条 info 日志(含最终端点与来源 `env/config/default`),便于排查「装了插件却没上报」的问题。
36
+
25
37
  ## 环境变量
26
38
 
27
39
  | 变量 | 必填 | 说明 |
28
40
  | --- | --- | --- |
29
- | `SESSION_USAGE_ENDPOINT` | 是(未设置则插件完全惰性) | 上报端点 URL。插件对它发 `POST`,`content-type: application/json`,body 见下。 |
41
+ | `SESSION_USAGE_ENDPOINT` | 否(显式覆盖,优先级最高) | 上报端点 URL。插件对它发 `POST`,`content-type: application/json`,body 见下。不设置时走 `config.json` 的 `reportEndpoint`,再退回内置默认值。 |
30
42
  | `SESSION_USAGE_API_KEY` | 否 | 上报请求的网关 Key(插件发 `Authorization: Bearer <key>`)。网关 key-auth 通过后注入 `X-Forward-Consumer`,服务端据此确定租户。未配置则不带鉴权头。 |
31
43
 
32
44
  > **身份来源**:载荷本身**不含**任何消费者身份字段。若 `SESSION_USAGE_ENDPOINT` 指向网关后的服务,必须配 `SESSION_USAGE_API_KEY`,否则请求会被网关 401 拒绝。
@@ -36,6 +48,7 @@ cp session-usage-reporter.ts .opencode/plugins/
36
48
  ## 行为
37
49
 
38
50
  - **触发时机**:bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`(两种空闲信号都处理)。
51
+ - **初始化可观测**:插件加载即解析端点并通过 `app.log` 记录 info 日志(端点 + 来源);上报失败仅在**每轮连续失败的首 次**记录日志(成功后重置),服务端未运行时不会刷屏。
39
52
  - **采集**:经 SDK 读取 `session.messages`(仅统计 `role === "assistant"` 且 `info.time.completed` 已设置的**已完成**消息,天然去重不双计),再经 `session.children` 递归子会话。
40
53
  - **父子独立**:每个会话(含子代理会话)各自生成一条快照;**父会话快照绝不并入子会话用量**。
41
54
  - **口径原样分列**:`tokens_input / tokens_output / tokens_reasoning / tokens_cache_read / tokens_cache_write` 直接取自 `info.tokens`,**客户端不做任何相加或换算**(opencode 语义:`output` 不含 `reasoning`,`input` 不含 `cache.read`;`cost` 恒为 0,不采信、不上报)。合计由服务端派生。
@@ -52,7 +65,7 @@ cp session-usage-reporter.ts .opencode/plugins/
52
65
  {
53
66
  "schema_version": 1,
54
67
  "install_id": "0b3f7e6a-....",
55
- "reporter": { "client": "opencode", "version": "0.1.0" },
68
+ "reporter": { "client": "opencode", "version": "0.1.2" },
56
69
  "sessions": [
57
70
  {
58
71
  "session_id": "ses_...",
@@ -80,13 +93,13 @@ cp session-usage-reporter.ts .opencode/plugins/
80
93
  bun test reporter.test.ts # 或 bun run reporter.test.ts
81
94
  ```
82
95
 
83
- 自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id 稳定性。
96
+ 自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id 稳定性、**端点解析优先级(env / config / default 全分支)与未设环境变量时插件仍激活**。
84
97
 
85
98
  ## 局限
86
99
 
87
100
  - **队列在内存**:进程崩溃(非正常 dispose 退出)会丢失未上报的积压;队列满后丢最旧。
88
101
  - **会话级快照**:无 per-message / 按模型 / 按时间细分;一个会话只记录最后一条消息的 model/agent,多模型会话归属到最后的模型。
89
102
  - **消息被压缩/删除**时 `message_count` 可能回退,revision 随之回退——依赖服务端单调守卫保留较高修订。
90
- - **不携带身份与鉴权**:请求无凭据,完全依赖网关注入消费者身份(与设计 D4/D9 一致);端点本身须由网关/服务端做租户隔离与限流。
103
+ - **不携带身份与鉴权**:载荷无凭据;`SESSION_USAGE_API_KEY` 未配置时请求不带鉴权头,完全依赖网关注入消费者身份(与设计 D4/D9 一致);端点本身须由网关/服务端做租户隔离与限流。默认端点指向本机 `127.0.0.1`,服务未启动时数据仅进本地队列(有界、退避重试)。
91
104
  - **与 opencode 版本耦合**:消息/事件/插件 loader 形状按 1.18.33 核实,字段均为防御式读取;升级 opencode 后建议重跑测试。
92
105
  - 每台机器一个 `install_id`;同一消费者跨机器的会话默认按 `install_id` 分列。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-reporter",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "opencode 客户端插件:在会话空闲时聚合各会话(含子代理会话)的 token 用量快照,经有界队列 + 指数退避非阻塞上报;载荷不含消费者身份字段。",
@@ -20,6 +20,8 @@ import {
20
20
  createQueue,
21
21
  createReporterCore,
22
22
  loadOrCreateInstallId,
23
+ resolveReportEndpoint,
24
+ SessionUsageReporter,
23
25
  } from "./session-usage-reporter.ts"
24
26
  import type {
25
27
  ReporterClient,
@@ -364,6 +366,121 @@ await runTest("loadOrCreateInstallId 首次生成、之后稳定复用同一 UUI
364
366
  }
365
367
  })
366
368
 
369
+ await runTest("resolveReportEndpoint: env 存在时优先于 config 与默认值", () => {
370
+ const r = resolveReportEndpoint({
371
+ env: { SESSION_USAGE_ENDPOINT: "http://env.example:1/report" },
372
+ configFile: "/nonexistent/config.json",
373
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
374
+ })
375
+ assertEqual(r.endpoint, "http://env.example:1/report", "endpoint")
376
+ assertEqual(r.source, "env", "source")
377
+ })
378
+
379
+ await runTest("resolveReportEndpoint: env 为空/空白时回退 config(并 trim)", () => {
380
+ const empty = resolveReportEndpoint({
381
+ env: {},
382
+ configFile: "/nonexistent/config.json",
383
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
384
+ })
385
+ assertEqual(empty.endpoint, "http://cfg.example:2/report", "空 env → config")
386
+ assertEqual(empty.source, "config", "source")
387
+
388
+ const blank = resolveReportEndpoint({
389
+ env: { SESSION_USAGE_ENDPOINT: " " },
390
+ configFile: "/nonexistent/config.json",
391
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: " http://cfg.example:2/report " }),
392
+ })
393
+ assertEqual(blank.endpoint, "http://cfg.example:2/report", "空白 env → config(值已 trim)")
394
+ assertEqual(blank.source, "config", "source")
395
+ })
396
+
397
+ await runTest("resolveReportEndpoint: config 缺失/JSON 非法/reportEndpoint 非法 → 回退默认值", () => {
398
+ const env = {}
399
+ const missing = resolveReportEndpoint({
400
+ env,
401
+ configFile: "/nonexistent/config.json",
402
+ readFileSyncFn: () => {
403
+ throw new Error("ENOENT")
404
+ },
405
+ })
406
+ assertEqual(missing.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "config 缺失 → 默认")
407
+ assertEqual(missing.source, "default", "source")
408
+
409
+ const badJson = resolveReportEndpoint({ env, configFile: "/x", readFileSyncFn: () => "{not json" })
410
+ assertEqual(badJson.source, "default", "JSON 非法 → 默认")
411
+
412
+ const badUrl = resolveReportEndpoint({
413
+ env,
414
+ configFile: "/x",
415
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "not-a-url" }),
416
+ })
417
+ assertEqual(badUrl.source, "default", "非法 URL → 默认")
418
+
419
+ const wrongScheme = resolveReportEndpoint({
420
+ env,
421
+ configFile: "/x",
422
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "ftp://example.com/report" }),
423
+ })
424
+ assertEqual(wrongScheme.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "非 http(s) → 默认")
425
+
426
+ const wrongType = resolveReportEndpoint({
427
+ env,
428
+ configFile: "/x",
429
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: 123 }),
430
+ })
431
+ assertEqual(wrongType.source, "default", "非字符串字段 → 默认")
432
+ })
433
+
434
+ await runTest("resolveReportEndpoint: 三者皆缺 → 内置默认值", () => {
435
+ const r = resolveReportEndpoint({
436
+ env: {},
437
+ configFile: "/nonexistent/config.json",
438
+ readFileSyncFn: () => {
439
+ throw new Error("ENOENT")
440
+ },
441
+ })
442
+ assertEqual(r.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "默认端点")
443
+ assertEqual(r.source, "default", "来源")
444
+ })
445
+
446
+ await runTest("插件未设 SESSION_USAGE_ENDPOINT 时依然激活(返回 event/dispose 并输出端点日志)", async () => {
447
+ const savedEndpoint = process.env.SESSION_USAGE_ENDPOINT
448
+ const savedConfigDir = process.env.MONITOR_CONFIG_DIR
449
+ delete process.env.SESSION_USAGE_ENDPOINT
450
+ // 指向不存在的配置目录 → config 分支静默回退默认值,测试不依赖真实 HOME
451
+ process.env.MONITOR_CONFIG_DIR = join(tmpdir(), `sur-resolve-${randomUUID()}`)
452
+ const logs: Array<{ body?: { level?: string; service?: string; message?: string } }> = []
453
+ try {
454
+ const fakeCtx = {
455
+ client: {
456
+ app: {
457
+ log: (args: { body?: { level?: string; service?: string; message?: string } }) => {
458
+ logs.push(args)
459
+ return Promise.resolve()
460
+ },
461
+ },
462
+ session: makeClient({}).session,
463
+ },
464
+ directory: "/tmp/fake-project",
465
+ } as unknown as Parameters<typeof SessionUsageReporter>[0]
466
+ const hooks = (await SessionUsageReporter(fakeCtx)) as Record<string, unknown>
467
+ assert(typeof hooks.event === "function", "不再惰性:应返回 event hook")
468
+ assert(typeof hooks.dispose === "function", "不再惰性:应返回 dispose hook")
469
+ const initLog = logs.map((l) => l.body?.message ?? "").find((m) => m.includes("端点"))
470
+ assert(
471
+ !!initLog &&
472
+ initLog.includes("http://127.0.0.1:8787/v1/session-usage/report") &&
473
+ initLog.includes("default"),
474
+ `初始化日志应包含端点与来源,实际: ${initLog}`,
475
+ )
476
+ } finally {
477
+ if (savedEndpoint === undefined) delete process.env.SESSION_USAGE_ENDPOINT
478
+ else process.env.SESSION_USAGE_ENDPOINT = savedEndpoint
479
+ if (savedConfigDir === undefined) delete process.env.MONITOR_CONFIG_DIR
480
+ else process.env.MONITOR_CONFIG_DIR = savedConfigDir
481
+ }
482
+ })
483
+
367
484
  // ---------------------------------------------------------------------------
368
485
  // 汇总
369
486
  // ---------------------------------------------------------------------------
@@ -7,7 +7,8 @@
7
7
  * - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
8
8
  * - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
9
9
  * 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
10
- * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到 SESSION_USAGE_ENDPOINT。
10
+ * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到解析出的上报端点
11
+ * (零配置解析:环境变量 SESSION_USAGE_ENDPOINT > <configDir>/config.json 的 reportEndpoint > 内置默认值)。
11
12
  * - 有界内存队列(默认 500 条快照,溢出丢最旧)+ 指数退避(带抖动)+ AbortSignal.timeout 超时。
12
13
  * - `dispose` 钩子在进程退出时以约 3 秒预算尽力刷新队列,超时即放弃,绝不阻塞退出。
13
14
  * - 载荷不含任何消费者身份字段;身份完全由网关(key-auth 注入 X-Forward-Consumer)决定。
@@ -26,11 +27,13 @@
26
27
  * 绝不能抛错、返回 null 或 undefined(否则 `V.event?.` 会读到空值导致崩溃)。
27
28
  *
28
29
  * 安装:复制本文件到 ~/.config/opencode/plugins/(全局)或 <项目>/.opencode/plugins/(项目级)。
29
- * 启用:设置环境变量 SESSION_USAGE_ENDPOINT(未设置时插件保持惰性)。
30
+ * 启用:零配置即生效(插件始终激活),初始化时会通过 app.log 输出最终端点与来源;
31
+ * 如需覆盖端点,可设置环境变量 SESSION_USAGE_ENDPOINT 或用配套 CLI 写 config.json 的 reportEndpoint。
30
32
  */
31
33
 
32
34
  import type { Plugin } from "@opencode-ai/plugin"
33
35
  import { randomUUID } from "node:crypto"
36
+ import { readFileSync } from "node:fs"
34
37
  import { mkdir, readFile, writeFile } from "node:fs/promises"
35
38
  import { homedir } from "node:os"
36
39
  import { dirname, join } from "node:path"
@@ -40,7 +43,7 @@ import { dirname, join } from "node:path"
40
43
  // ---------------------------------------------------------------------------
41
44
 
42
45
  const SCHEMA_VERSION = 1
43
- const PLUGIN_VERSION = "0.1.0"
46
+ const PLUGIN_VERSION = "0.1.2"
44
47
  /** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
45
48
  const QUEUE_CAP = 500
46
49
  /** 单次上报请求超时(AbortSignal.timeout) */
@@ -59,6 +62,20 @@ const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode
59
62
 
60
63
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
61
64
 
65
+ /** 零配置默认上报端点(与本地 `web start` 的默认端口一致) */
66
+ const DEFAULT_REPORT_ENDPOINT = "http://127.0.0.1:8787/v1/session-usage/report"
67
+
68
+ /**
69
+ * 本工具配置目录:必须与 src/paths.js 的 configDir() 完全一致。
70
+ * 本文件保持独立可复制,故此处自带一份等价实现(不 import src/)。
71
+ */
72
+ const configFilePath = (): string =>
73
+ join(
74
+ process.env.MONITOR_CONFIG_DIR ||
75
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode-stats-reporter"),
76
+ "config.json",
77
+ )
78
+
62
79
  // ---------------------------------------------------------------------------
63
80
  // 类型(type-only 导出在运行时会被擦除,不影响 loader 的「导出必须全是函数」校验)
64
81
  // ---------------------------------------------------------------------------
@@ -113,6 +130,13 @@ export type ReportPayload = {
113
130
  sessions: SessionSnapshot[]
114
131
  }
115
132
 
133
+ export type ReportEndpointSource = "env" | "config" | "default"
134
+
135
+ export type ResolvedReportEndpoint = {
136
+ endpoint: string
137
+ source: ReportEndpointSource
138
+ }
139
+
116
140
  /** 本插件实际用到的 opencode SDK client 最小形状(便于 mock 测试) */
117
141
  export type ReporterClient = {
118
142
  session: {
@@ -340,6 +364,51 @@ const loadOrCreateInstallIdImpl = async (
340
364
  return id
341
365
  }
342
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // 零配置端点解析:env > config.json(reportEndpoint) > 内置默认值;绝不抛错
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const isValidHttpUrl = (value: string): boolean => {
372
+ try {
373
+ const parsed = new URL(value)
374
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
375
+ } catch {
376
+ return false
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 解析上报端点(优先级从高到低):
382
+ * 1. 环境变量 SESSION_USAGE_ENDPOINT(trim 后非空即用,显式覆盖)
383
+ * 2. 本工具配置文件 <configDir>/config.json 的 reportEndpoint 字段(须为合法 http(s) URL)
384
+ * 3. 内置默认值 http://127.0.0.1:8787/v1/session-usage/report
385
+ * config.json 不存在 / JSON 非法 / 字段缺失 / URL 非法 → 一律静默回退到下一优先级。
386
+ * 依赖全部可注入(env / configFile / readFileSyncFn),便于单测且不碰真实环境。
387
+ */
388
+ const resolveReportEndpointImpl = (
389
+ options: {
390
+ env?: Record<string, string | undefined>
391
+ configFile?: string
392
+ readFileSyncFn?: (file: string, encoding: "utf8") => string
393
+ } = {},
394
+ ): ResolvedReportEndpoint => {
395
+ const env = options.env ?? process.env
396
+ const fromEnv = (env.SESSION_USAGE_ENDPOINT ?? "").trim()
397
+ if (fromEnv) return { endpoint: fromEnv, source: "env" }
398
+
399
+ const configFile = options.configFile ?? configFilePath()
400
+ const readFileSyncFn = options.readFileSyncFn ?? ((file: string) => readFileSync(file, "utf8"))
401
+ try {
402
+ const parsed = JSON.parse(readFileSyncFn(configFile, "utf8")) as { reportEndpoint?: unknown } | null
403
+ const candidate = typeof parsed?.reportEndpoint === "string" ? parsed.reportEndpoint.trim() : ""
404
+ if (candidate && isValidHttpUrl(candidate)) return { endpoint: candidate, source: "config" }
405
+ } catch {
406
+ // 文件不存在 / 不可读 / JSON 非法:静默回退到默认值
407
+ }
408
+
409
+ return { endpoint: DEFAULT_REPORT_ENDPOINT, source: "default" }
410
+ }
411
+
343
412
  // ---------------------------------------------------------------------------
344
413
  // 核心:采集 + 非阻塞队列 + 退避重试 + dispose 限时刷新
345
414
  // ---------------------------------------------------------------------------
@@ -365,6 +434,7 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
365
434
  let flushCancel: (() => void) | null = null
366
435
  let retryCancel: (() => void) | null = null
367
436
  let retryAttempt = 0
437
+ let failureLogged = false
368
438
  const inFlight = new Set<string>()
369
439
 
370
440
  const send = async (snapshots: readonly SessionSnapshot[], timeoutMs: number): Promise<void> => {
@@ -392,13 +462,18 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
392
462
  try {
393
463
  await send(batch, requestTimeoutMs)
394
464
  retryAttempt = 0
465
+ failureLogged = false // 成功即重置:下一轮失败重新记录首次
395
466
  return true
396
467
  } catch (error) {
397
468
  queue.restore(batch)
398
469
  retryAttempt += 1
399
470
  // 第 1 次失败 → 基础 1s,此后指数翻倍(2s、4s…),封顶 5 分钟,均带抖动
400
471
  const delay = computeBackoffMsImpl(Math.max(0, retryAttempt - 1), random())
401
- log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
472
+ // 降噪:连续失败只记录首轮,避免服务端未运行时刷屏
473
+ if (!failureLogged) {
474
+ failureLogged = true
475
+ log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
476
+ }
402
477
  if (!retryCancel) {
403
478
  retryCancel = scheduler(() => {
404
479
  retryCancel = null
@@ -544,17 +619,15 @@ export const buildPayload = pluginSafe(buildPayloadImpl)
544
619
  export const createQueue = pluginSafe(createQueueImpl)
545
620
  export const computeBackoffMs = pluginSafe(computeBackoffMsImpl)
546
621
  export const loadOrCreateInstallId = pluginSafe(loadOrCreateInstallIdImpl)
622
+ export const resolveReportEndpoint = pluginSafe(resolveReportEndpointImpl)
547
623
  export const createReporterCore = pluginSafe(createReporterCoreImpl)
548
624
 
549
625
  /**
550
- * 插件入口。
551
- * 未设置 SESSION_USAGE_ENDPOINT 时返回空 hooks,完全不影响会话。
626
+ * 插件入口:零配置,始终激活。
627
+ * 端点按 env > config.json > 默认值 解析;初始化成功后通过 app.log 输出端点与来源,便于排查。
552
628
  */
553
629
  export const SessionUsageReporter: Plugin = async (ctx) => {
554
630
  try {
555
- const endpoint = (process.env.SESSION_USAGE_ENDPOINT ?? "").trim() || undefined
556
- if (!endpoint) return {}
557
-
558
631
  const log = (message: string, error?: unknown): void => {
559
632
  try {
560
633
  const detail = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined ? "" : String(error)
@@ -573,17 +646,21 @@ export const SessionUsageReporter: Plugin = async (ctx) => {
573
646
  }
574
647
  }
575
648
 
649
+ const resolved = resolveReportEndpointImpl()
576
650
  const installId = await loadOrCreateInstallIdImpl()
577
651
  const core = createReporterCoreImpl({
578
652
  client: ctx.client as unknown as ReporterClient,
579
653
  directory: ctx.directory,
580
- endpoint,
654
+ endpoint: resolved.endpoint,
581
655
  installId,
582
656
  // 上报请求的网关 Key(APIG key-auth 后注入 X-Forward-Consumer);未配置则不带鉴权头
583
657
  authToken: (process.env.SESSION_USAGE_API_KEY ?? "").trim() || undefined,
584
658
  log,
585
659
  })
586
660
 
661
+ // 可观测性:明确告知最终端点与来源(排查「装了插件却没上报」的关键线索)
662
+ log(`session-usage: 已启用会话用量上报,端点 ${resolved.endpoint}(来源: ${resolved.source})`)
663
+
587
664
  return {
588
665
  event: async (input: { event?: { type?: unknown; properties?: unknown } }) => {
589
666
  try {
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.2",
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"
@@ -41,11 +42,12 @@ function parseFlags(argv) {
41
42
  const HELP = `opencode-stats-reporter — 本地一体化 MaaS 用量看板
42
43
 
43
44
  用法:
44
- opencode-stats-reporter config set --gateway <url> --key <key> 配置上游网关与 Key
45
+ opencode-stats-reporter config set --gateway <url> --key <key> [--endpoint <url>]
46
+ 配置上游网关、Key 与插件上报端点
45
47
  opencode-stats-reporter config get 查看配置(Key 脱敏)
46
48
  opencode-stats-reporter web start [--port 8787] [--host 127.0.0.1] [--open]
47
- 启动本地服务并托管面板
48
- opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件
49
+ 启动本地服务并托管面板(自动写入插件上报端点 reportEndpoint)
50
+ opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件(零配置,默认上报本机服务)
49
51
 
50
52
  选项:
51
53
  -h, --help 显示帮助
@@ -56,15 +58,23 @@ function cmdConfig(argv) {
56
58
  const sub = argv[0]
57
59
  const flags = parseFlags(argv.slice(1))
58
60
  if (sub === "set") {
59
- const cfg = setConfig({ gateway: flags.gateway, key: flags.key })
61
+ const cfg = setConfig({ gateway: flags.gateway, key: flags.key, endpoint: flags.endpoint })
60
62
  console.log("已写入配置: " + (cfg.gatewayBaseUrl || "(未设置)"))
61
63
  console.log("API Key: " + maskKey(cfg.apiKey))
64
+ console.log("reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
62
65
  return
63
66
  }
64
67
  if (sub === "get") {
65
68
  const cfg = readConfig()
66
- console.log("gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
67
- console.log("apiKey: " + maskKey(cfg.apiKey))
69
+ console.log("显式配置 gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
70
+ console.log("显式配置 apiKey: " + maskKey(cfg.apiKey))
71
+ console.log("显式配置 reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
72
+ const r = resolveGateway({ fileConfig: cfg })
73
+ if (r.ok) {
74
+ console.log("生效网关: " + r.config.gatewayBaseUrl + "(来源: " + r.source + (r.providerId ? ",provider=" + r.providerId : "") + ")")
75
+ } else {
76
+ console.log("生效网关: (未能解析,请先 `config set` 或确保 opencode 配置含 provider baseURL/apiKey)")
77
+ }
68
78
  return
69
79
  }
70
80
  throw new Error("用法: opencode-stats-reporter config set|get")
@@ -74,10 +84,17 @@ async function cmdWeb(argv) {
74
84
  const sub = argv[0]
75
85
  if (sub !== "start") throw new Error("用法: opencode-stats-reporter web start")
76
86
  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>")
87
+ // 上游配置:显式 config.json > 环境变量 > 从 opencode 配置推导(无需手填)
88
+ const resolved = resolveGateway({ fileConfig: readConfig() })
89
+ if (!resolved.ok) {
90
+ throw new Error(
91
+ "未能确定上游网关。请任选其一:\n" +
92
+ " a) 直接使用 opencode 配置(确保 opencode 里某个 provider 有 baseURL 与 apiKey)\n" +
93
+ " b) 显式配置: opencode-stats-reporter config set --gateway <url> --key <key>\n" +
94
+ " c) 环境变量: OPENCODE_STATS_GATEWAY / OPENCODE_STATS_API_KEY",
95
+ )
80
96
  }
97
+ const cfg = resolved.config
81
98
  const port = Number(flags.port || DEFAULT_PORT)
82
99
  const host = String(flags.host || DEFAULT_HOST)
83
100
  const mode = String(flags.mode || process.env.SESSION_USAGE_MODE || "local")
@@ -93,10 +110,25 @@ async function cmdWeb(argv) {
93
110
  server.listen(port, host, resolve)
94
111
  })
95
112
 
96
- const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`
113
+ const address = server.address()
114
+ const actualPort = address && typeof address === "object" ? address.port : port
115
+ const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`
97
116
  console.log("本地看板已启动: " + url)
98
- console.log("上游网关: " + cfg.gatewayBaseUrl)
117
+ console.log("上游网关: " + cfg.gatewayBaseUrl + "(来源: " + resolved.source +
118
+ (resolved.providerId ? ",provider=" + resolved.providerId : "") + ")")
99
119
  console.log("数据文件: " + dbFile())
120
+
121
+ // 零配置上报:把插件上报端点写入 config.json(插件解析顺序 env > reportEndpoint > 默认值)。
122
+ // 即使 --host 0.0.0.0 也写 127.0.0.1(插件默认同机);写入失败只告警,不影响服务启动。
123
+ const reportEndpoint = `http://127.0.0.1:${actualPort}/v1/session-usage/report`
124
+ try {
125
+ setConfig({ endpoint: reportEndpoint })
126
+ console.log("插件上报端点: " + reportEndpoint + "(已写入 config.json,插件零配置自动上报)")
127
+ } catch (error) {
128
+ console.warn("警告: 未能写入插件上报端点 reportEndpoint(不影响服务运行): " +
129
+ (error && error.message ? error.message : String(error)))
130
+ }
131
+
100
132
  if (!loopback) console.warn("警告: 正在监听非回环地址 " + host + ",本机网络内可访问,请自行确认风险")
101
133
  if (flags.open) openBrowser(url)
102
134
 
@@ -106,6 +138,7 @@ async function cmdWeb(argv) {
106
138
  server.close(() => process.exit(0))
107
139
  setTimeout(() => process.exit(0), 1000).unref()
108
140
  })
141
+ return server
109
142
  }
110
143
 
111
144
  function cmdPlugin(argv) {
@@ -130,7 +163,8 @@ function cmdPlugin(argv) {
130
163
  console.log("推荐用 npm 方式安装(可随 npm 更新、支持 -f 覆盖):")
131
164
  console.log(" opencode plugin @dijkspicy/opencode-stats-reporter -g")
132
165
  console.log("")
133
- console.log("以上为离线/本地文件安装方式。之后在 opencode 进程环境中设置:")
166
+ console.log("以上为离线/本地文件安装方式。插件零配置:默认上报本机服务(web start 会把实际端点自动写入 config.json)。")
167
+ console.log("如需显式覆盖端点,可在 opencode 进程环境中设置:")
134
168
  console.log(" SESSION_USAGE_ENDPOINT=http://127.0.0.1:" + port + "/v1/session-usage/report")
135
169
  console.log("(本地模式无需 SESSION_USAGE_API_KEY;请勿使用 --pure 启动 opencode)")
136
170
  }
package/src/config.js CHANGED
@@ -34,7 +34,7 @@ function validUrl(url) {
34
34
  }
35
35
  }
36
36
 
37
- export function setConfig({ gateway, key }) {
37
+ export function setConfig({ gateway, key, endpoint }) {
38
38
  const cfg = readConfig()
39
39
  if (gateway !== undefined) {
40
40
  const g = String(gateway).trim().replace(/\/+$/, "")
@@ -46,6 +46,11 @@ export function setConfig({ gateway, key }) {
46
46
  if (!k) throw new Error("key 不能为空")
47
47
  cfg.apiKey = k
48
48
  }
49
+ if (endpoint !== undefined) {
50
+ const e = String(endpoint).trim()
51
+ if (!validUrl(e)) throw new Error("endpoint 必须是合法的 http(s) URL")
52
+ cfg.reportEndpoint = e
53
+ }
49
54
  return writeConfig(cfg)
50
55
  }
51
56
 
@@ -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
+ }