@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 +3 -2
- package/contrib/opencode-session-reporter/README.md +17 -4
- package/contrib/opencode-session-reporter/package.json +1 -1
- package/contrib/opencode-session-reporter/reporter.test.ts +117 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +87 -10
- package/package.json +5 -1
- package/src/cli.js +46 -12
- package/src/config.js +6 -1
- package/src/opencode-config.js +175 -0
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
|
|
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` |
|
|
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.
|
|
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
|
-
-
|
|
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` 分列。
|
|
@@ -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
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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>
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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("
|
|
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
|
+
}
|