opencode-usage-limit 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +89 -2
- package/bin/opencode-usage-limit.mjs +120 -0
- package/config-default.json +21 -0
- package/index.js +120 -0
- package/package.json +35 -4
- package/tui/usage-tracker.js +629 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-usage-limit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-usage-limit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode 플러그인 — **오늘 남은 사용량 한도**를 사이드바 최하단 게이지로 표시하고, 429 rate limit을 실측 추적합니다.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
⚡ 오늘 남음 1.16M (38%) · 10h 11m 리셋
|
|
7
|
+
[████░░░░░░] 사용 1.8M · 86 req
|
|
8
|
+
⛔ 429 쿨다운 38m (429 발생 시에만)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- 바의 채워진 부분 = **남은 비율** (사용할수록 줄어드는 연료게이지)
|
|
12
|
+
- 50% 이하 노랑, 20% 이하 빨강 + 자동 toast 경고
|
|
13
|
+
- 매일 자정(타임존 설정 가능) 리셋
|
|
14
|
+
- 429 감지 시 provider별 쿨다운 표시 (NVIDIA NIM 40분 리필, opencode Zen 무료 모델 3분, 기타 5분)
|
|
15
|
+
- `/usage` 슬래시 커맨드: 오늘 상세 / 모델별 Top 5 / 429 통계 대시보드
|
|
16
|
+
|
|
17
|
+
## 설치
|
|
18
|
+
|
|
19
|
+
### 방법 1 — opencode 자동 설치 (백그라운드 수집기)
|
|
20
|
+
|
|
21
|
+
`~/.config/opencode/opencode.jsonc`의 `plugin` 배열에 패키지 이름 추가:
|
|
22
|
+
|
|
23
|
+
```jsonc
|
|
24
|
+
{
|
|
25
|
+
"plugin": ["opencode-usage-limit"]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
opencode 시작 시 npm에서 자동 설치되어 429 수집기가 로드됩니다.
|
|
30
|
+
|
|
31
|
+
### 방법 2 — TUI 게이지 설치
|
|
32
|
+
|
|
33
|
+
터미널에서 한 번 실행:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx opencode-usage-limit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
설치 CLI가 TUI 플러그인을 `~/.config/opencode/tui-plugins/`에 복사하고 `tui.json`에 등록합니다.
|
|
40
|
+
|
|
41
|
+
**두 단계 모두 필요합니다** (백그라운드 수집기 + TUI 게이지). 설치 후 opencode를 재시작하세요.
|
|
42
|
+
|
|
43
|
+
### 제거
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx opencode-usage-limit --remove
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
설치 스크립트는 재실행해도 안전합니다 (idempotent).
|
|
50
|
+
|
|
51
|
+
## 설정 (`~/.config/opencode/usage-tracker.json`)
|
|
52
|
+
|
|
53
|
+
| 키 | 기본값 | 설명 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `dailyLimit` | `3000000` | 일일 순토큰 한도. `0`이면 남은 한도 대신 사용량만 표시 |
|
|
56
|
+
| `plan` | `"free"` | `"go"` / `"go-plus"` 지정 시 OpenCode Go 달러 한도 게이지로 전환 (5시간 = 월 한도 20%, 월간 = 100%, 공식 단가표 내장) |
|
|
57
|
+
| `cacheWeights` | `{"read": 0.1, "write": 0.25}` | 순토큰 = `input + output + cacheRead×0.1 + cacheWrite×0.25` |
|
|
58
|
+
| `resetTimezone` | `"Asia/Seoul"` | 일일 리셋 자정 기준 시간대 |
|
|
59
|
+
| `pollSeconds` | `30` | 집계 갱신 주기 |
|
|
60
|
+
| `rateLimitPatterns` | (기본 제공) | 429 감지용 에러 문자열 패턴 |
|
|
61
|
+
|
|
62
|
+
설치 시 기존 설정 파일이 있으면 덮어쓰지 않습니다.
|
|
63
|
+
|
|
64
|
+
## 동작 방식
|
|
65
|
+
|
|
66
|
+
- opencode의 로컬 DB(`opencode.db`, `part` 테이블의 step-finish 파트)를 **읽기 전용**으로 집계 — 별도 기록 없이 설치 즉시 오늘 사용량이 표시됩니다
|
|
67
|
+
- `message.part.updated` 이벤트 + 주기적 폴링으로 실시간 갱신
|
|
68
|
+
- 백그라운드 수집기(`index.js`)가 429 에러를 감지해 `~/.config/opencode/usage-tracker/rate-limits.json`에 기록하면 TUI가 표시합니다
|
|
69
|
+
|
|
70
|
+
## 구성 요소
|
|
71
|
+
|
|
72
|
+
| 파일 | 설치 위치 | 역할 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `index.js` | npm 플러그인 (opencode가 자동 설치) | 백그라운드 429 수집기 |
|
|
75
|
+
| `tui/usage-tracker.js` | `~/.config/opencode/tui-plugins/usage-tracker.js` | TUI 게이지 + `/usage` 대시보드 |
|
|
76
|
+
| `config-default.json` | `~/.config/opencode/usage-tracker.json` | 기본 설정 (설치 시 기존 파일이 없을 때만 생성) |
|
|
77
|
+
|
|
78
|
+
## 유의사항
|
|
79
|
+
|
|
80
|
+
- opencode.db는 기기별 로컬입니다. 여러 기기가 같은 계정을 써도 각 기기 사용량만 집계되며, 각 기기에 설치하면 각자 표시됩니다. 서버 측 전체 사용량은 [opencode.ai/console](https://opencode.ai/console)에서 확인하세요.
|
|
81
|
+
- 다른 플러그인이 `sidebar_footer` 슬롯(단일 승자)을 사용 중이면 게이지가 대신 표시될 수 있습니다. 기존 footer 플러그인은 `sidebar_content`(append 방식) 슬롯으로 옮기면 공존합니다.
|
|
82
|
+
|
|
83
|
+
## 요구 사항
|
|
84
|
+
|
|
85
|
+
- [opencode](https://opencode.ai)
|
|
86
|
+
- node 18 이상 (설치 CLI용)
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// opencode-usage-limit 설치 CLI
|
|
3
|
+
// 사용:
|
|
4
|
+
// node install.mjs (설치 — 소스 디렉토리에서 직접 실행)
|
|
5
|
+
// npx opencode-usage-limit (npm 설치 후)
|
|
6
|
+
// node install.mjs --remove (제거)
|
|
7
|
+
//
|
|
8
|
+
// 설치 내용:
|
|
9
|
+
// 1. 백그라운드 수집기(index.js) → ~/.config/opencode/plugins/usage-tracker-collector.js 복사 + opencode.jsonc 등록
|
|
10
|
+
// 2. TUI 플러그인(tui/usage-tracker.js) → ~/.config/opencode/tui-plugins/usage-tracker.js 복사 + tui.json 등록
|
|
11
|
+
// 3. 기본 설정(config-default.json) → ~/.config/opencode/usage-tracker.json (기존 파일이 있으면 유지)
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from "node:fs"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import { homedir } from "node:os"
|
|
15
|
+
|
|
16
|
+
const remove = process.argv.includes("--remove")
|
|
17
|
+
const SRC = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..")
|
|
18
|
+
const CONFIG_DIR = process.env.OPENCODE_CONFIG_DIR || path.join(homedir(), ".config/opencode")
|
|
19
|
+
|
|
20
|
+
const COLLECTOR_SRC = path.join(SRC, "index.js")
|
|
21
|
+
const TUI_SRC = path.join(SRC, "tui", "usage-tracker.js")
|
|
22
|
+
const CONFIG_SRC = path.join(SRC, "config-default.json")
|
|
23
|
+
|
|
24
|
+
const COLLECTOR_DST = path.join(CONFIG_DIR, "plugins/usage-tracker-collector.js")
|
|
25
|
+
const TUI_DST = path.join(CONFIG_DIR, "tui-plugins/usage-tracker.js")
|
|
26
|
+
const CONFIG_DST = path.join(CONFIG_DIR, "usage-tracker.json")
|
|
27
|
+
|
|
28
|
+
const COLLECTOR_REF = "./plugins/usage-tracker-collector.js"
|
|
29
|
+
const TUI_REF = "./tui-plugins/usage-tracker.js"
|
|
30
|
+
const STATE_DIR = path.join(CONFIG_DIR, "usage-tracker")
|
|
31
|
+
|
|
32
|
+
function parseJsonc(text) {
|
|
33
|
+
const attempts = [
|
|
34
|
+
() => JSON.parse(text),
|
|
35
|
+
() => JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")),
|
|
36
|
+
]
|
|
37
|
+
for (const attempt of attempts) {
|
|
38
|
+
try {
|
|
39
|
+
return attempt()
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function updateConfig(fileName, ref) {
|
|
46
|
+
const filePath = path.join(CONFIG_DIR, fileName)
|
|
47
|
+
if (remove) {
|
|
48
|
+
if (!existsSync(filePath)) return true
|
|
49
|
+
const parsed = parseJsonc(readFileSync(filePath, "utf8"))
|
|
50
|
+
if (!parsed || !Array.isArray(parsed.plugin)) return true
|
|
51
|
+
const next = parsed.plugin.filter((item) => item !== ref)
|
|
52
|
+
if (next.length !== parsed.plugin.length) {
|
|
53
|
+
parsed.plugin = next
|
|
54
|
+
writeFileSync(filePath, JSON.stringify(parsed, null, 2) + "\n")
|
|
55
|
+
console.log(`[usage-limit] ${fileName} 등록 해제: ${ref}`)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!existsSync(filePath)) {
|
|
61
|
+
const minimal = fileName === "tui.json"
|
|
62
|
+
? { $schema: "https://opencode.ai/tui.json", plugin: [ref] }
|
|
63
|
+
: { plugin: [ref] }
|
|
64
|
+
writeFileSync(filePath, JSON.stringify(minimal, null, 2) + "\n")
|
|
65
|
+
console.log(`[usage-limit] ${filePath} 생성 및 등록`)
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parsed = parseJsonc(readFileSync(filePath, "utf8"))
|
|
70
|
+
if (!parsed) {
|
|
71
|
+
console.error(`[usage-limit] ${fileName} 파싱 실패. 수동 등록 필요:`)
|
|
72
|
+
console.error(` "${ref}" 를 "plugin" 배열에 추가하세요.`)
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
const plugins = Array.isArray(parsed.plugin) ? parsed.plugin : []
|
|
76
|
+
if (plugins.includes(ref)) {
|
|
77
|
+
console.log(`[usage-limit] ${fileName}: 이미 등록됨`)
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
parsed.plugin = [...plugins, ref]
|
|
81
|
+
writeFileSync(filePath, JSON.stringify(parsed, null, 2) + "\n")
|
|
82
|
+
console.log(`[usage-limit] ${fileName} 등록: ${ref}`)
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (remove) {
|
|
87
|
+
rmSync(COLLECTOR_DST, { force: true })
|
|
88
|
+
rmSync(TUI_DST, { force: true })
|
|
89
|
+
rmSync(STATE_DIR, { recursive: true, force: true })
|
|
90
|
+
const okMain = updateConfig("opencode.jsonc", COLLECTOR_REF)
|
|
91
|
+
const okTui = updateConfig("tui.json", TUI_REF)
|
|
92
|
+
console.log(`[usage-limit] 제거 완료 (설정 파일 ${CONFIG_DST}은 유지됩니다).`)
|
|
93
|
+
process.exit(okMain && okTui ? 0 : 1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!existsSync(COLLECTOR_SRC) || !existsSync(TUI_SRC)) {
|
|
97
|
+
console.error("[usage-limit] 오류: 패키지 파일이 없습니다. 저장소가 온전한지 확인하세요.")
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
mkdirSync(path.dirname(COLLECTOR_DST), { recursive: true })
|
|
102
|
+
mkdirSync(path.dirname(TUI_DST), { recursive: true })
|
|
103
|
+
copyFileSync(COLLECTOR_SRC, COLLECTOR_DST)
|
|
104
|
+
copyFileSync(TUI_SRC, TUI_DST)
|
|
105
|
+
console.log(`[usage-limit] 수집기 설치: ${COLLECTOR_DST}`)
|
|
106
|
+
console.log(`[usage-limit] TUI 플러그인 설치: ${TUI_DST}`)
|
|
107
|
+
|
|
108
|
+
if (!existsSync(CONFIG_DST)) {
|
|
109
|
+
copyFileSync(CONFIG_SRC, CONFIG_DST)
|
|
110
|
+
console.log(`[usage-limit] 설정 생성: ${CONFIG_DST} (dailyLimit 편집 가능)`)
|
|
111
|
+
} else {
|
|
112
|
+
console.log(`[usage-limit] 기존 설정 유지: ${CONFIG_DST}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const okMain = updateConfig("opencode.jsonc", COLLECTOR_REF)
|
|
116
|
+
const okTui = updateConfig("tui.json", TUI_REF)
|
|
117
|
+
if (okMain && okTui) {
|
|
118
|
+
console.log("[usage-limit] 설치 완료. opencode를 재시작하세요.")
|
|
119
|
+
}
|
|
120
|
+
process.exit(okMain && okTui ? 0 : 1)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "opencode usage-tracker 설정. dailyLimit: 일일 순토큰 한도 (0 = 미지정, 남은 한도 대신 사용량만 표시). plan: free(기본) | go | go-plus",
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"plan": "free",
|
|
5
|
+
"dailyLimit": 3000000,
|
|
6
|
+
"warnThresholds": [0.8, 0.95],
|
|
7
|
+
"cacheWeights": { "read": 0.1, "write": 0.25 },
|
|
8
|
+
"resetTimezone": "Asia/Seoul",
|
|
9
|
+
"pollSeconds": 30,
|
|
10
|
+
"historyDays": 2,
|
|
11
|
+
"rateLimitPatterns": [
|
|
12
|
+
"rate increased too quickly",
|
|
13
|
+
"scale requests more smoothly",
|
|
14
|
+
"ensure system stability",
|
|
15
|
+
"rate limit",
|
|
16
|
+
"rate_limit",
|
|
17
|
+
"too many requests",
|
|
18
|
+
"quota exceeded",
|
|
19
|
+
"usage exceeded"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = path.join(homedir(), ".config/opencode/usage-tracker.json")
|
|
6
|
+
const STATE_DIR = path.join(homedir(), ".config/opencode/usage-tracker")
|
|
7
|
+
const STATE_PATH = path.join(STATE_DIR, "rate-limits.json")
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PATTERNS = [
|
|
10
|
+
"rate increased too quickly",
|
|
11
|
+
"scale requests more smoothly",
|
|
12
|
+
"ensure system stability",
|
|
13
|
+
"rate limit",
|
|
14
|
+
"rate_limit",
|
|
15
|
+
"too many requests",
|
|
16
|
+
"quota exceeded",
|
|
17
|
+
"usage exceeded",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function loadJson(file, fallback) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(file, "utf8"))
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveState(state) {
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
31
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2))
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorText(properties) {
|
|
36
|
+
const seen = new Set()
|
|
37
|
+
const parts = []
|
|
38
|
+
function visit(value, depth) {
|
|
39
|
+
if (value == null || depth > 5 || seen.has(value)) return
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
parts.push(value)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== "object") return
|
|
45
|
+
seen.add(value)
|
|
46
|
+
for (const key of [
|
|
47
|
+
"message",
|
|
48
|
+
"error",
|
|
49
|
+
"title",
|
|
50
|
+
"data",
|
|
51
|
+
"name",
|
|
52
|
+
"statusCode",
|
|
53
|
+
"status_code",
|
|
54
|
+
"responseBody",
|
|
55
|
+
"body",
|
|
56
|
+
"metadata",
|
|
57
|
+
"url",
|
|
58
|
+
"cause",
|
|
59
|
+
"detail",
|
|
60
|
+
"details",
|
|
61
|
+
]) {
|
|
62
|
+
if (key in value) visit(value[key], depth + 1)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
visit(properties, 0)
|
|
66
|
+
return parts.join(" ")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ZEN_SIGNATURES = ["rate increased too quickly", "scale requests more smoothly", "ensure system stability"]
|
|
70
|
+
|
|
71
|
+
function cooldownMinutesFor(text, config) {
|
|
72
|
+
const t = text.toLowerCase()
|
|
73
|
+
if (
|
|
74
|
+
t.includes("nvidia") ||
|
|
75
|
+
t.includes("integrate.api.nvidia.com") ||
|
|
76
|
+
t.includes("nvapi") ||
|
|
77
|
+
(/nim\b/.test(t) && t.includes("too many requests"))
|
|
78
|
+
) {
|
|
79
|
+
return config?.nim?.refillMinutes ?? 40
|
|
80
|
+
}
|
|
81
|
+
if (ZEN_SIGNATURES.some((s) => t.includes(s))) {
|
|
82
|
+
return config?.zen?.cooldownMinutes ?? 3
|
|
83
|
+
}
|
|
84
|
+
return config?.other?.cooldownMinutes ?? 5
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const UsageTrackerCollector = async () => {
|
|
88
|
+
const config = loadJson(CONFIG_PATH, {})
|
|
89
|
+
if (config?.enabled === false) return {}
|
|
90
|
+
const patterns = (config?.rateLimitPatterns ?? DEFAULT_PATTERNS).map((p) => p.toLowerCase())
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
event: async ({ event }) => {
|
|
94
|
+
if (!event?.type || !event.type.includes("error")) return
|
|
95
|
+
const text = errorText(event.properties ?? {})
|
|
96
|
+
if (!text) return
|
|
97
|
+
const lower = text.toLowerCase()
|
|
98
|
+
if (!patterns.some((p) => lower.includes(p))) return
|
|
99
|
+
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
const cooldownMin = cooldownMinutesFor(text, config)
|
|
102
|
+
const sessionID = event.properties?.sessionID ?? ""
|
|
103
|
+
const state = loadJson(STATE_PATH, {})
|
|
104
|
+
const key = sessionID || "global"
|
|
105
|
+
const prev = state[key] ?? {}
|
|
106
|
+
state[key] = {
|
|
107
|
+
lastSeen: now,
|
|
108
|
+
count: (prev.count ?? 0) + 1,
|
|
109
|
+
cooldownUntil: now + cooldownMin * 60_000,
|
|
110
|
+
cooldownMinutes: cooldownMin,
|
|
111
|
+
sessionID,
|
|
112
|
+
snippet: text.slice(0, 300),
|
|
113
|
+
}
|
|
114
|
+
state.lastEventAt = now
|
|
115
|
+
saveState(state)
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default UsageTrackerCollector
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-limit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode plugin that shows today's remaining usage limit as a sidebar gauge, tracks 429 rate limits, and provides a /usage dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"opencode-usage-limit": "bin/opencode-usage-limit.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"tui/usage-tracker.js",
|
|
13
|
+
"config-default.json",
|
|
14
|
+
"bin/opencode-usage-limit.mjs"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"plugin",
|
|
19
|
+
"usage",
|
|
20
|
+
"limit",
|
|
21
|
+
"rate-limit",
|
|
22
|
+
"gauge",
|
|
23
|
+
"tui"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "^1.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@opencode-ai/plugin": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import { jsx } from "@opentui/solid/jsx-runtime"
|
|
3
|
+
import { Database } from "bun:sqlite"
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
|
|
8
|
+
const HOME = homedir()
|
|
9
|
+
const CONFIG_PATH = path.join(HOME, ".config/opencode/usage-tracker.json")
|
|
10
|
+
const RATE_LIMIT_STATE_PATH = path.join(HOME, ".config/opencode/usage-tracker/rate-limits.json")
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG = {
|
|
13
|
+
enabled: true,
|
|
14
|
+
plan: "free",
|
|
15
|
+
dailyLimit: 0,
|
|
16
|
+
warnThresholds: [0.8, 0.95],
|
|
17
|
+
cacheWeights: { read: 0.1, write: 0.25 },
|
|
18
|
+
resetTimezone: "Asia/Seoul",
|
|
19
|
+
pollSeconds: 30,
|
|
20
|
+
historyDays: 2,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// OpenCode Go 요금표 (USD per 1M tokens): [input, output, cacheRead, cacheWrite, monthlyLimit]
|
|
24
|
+
const GO_PRICING = {
|
|
25
|
+
"glm-5.3-flash": [0.15, 0.5, 0.03, 0, 60],
|
|
26
|
+
"glm-5.3": [1.4, 4.4, 0.26, 0, 15],
|
|
27
|
+
"glm-5.2": [1.4, 4.4, 0.26, 0, 60],
|
|
28
|
+
"kimi-k3": [3.0, 15.0, 0.3, 0, 15],
|
|
29
|
+
"kimi-k2.7-code": [0.95, 4.0, 0.19, 0, 60],
|
|
30
|
+
"longcat-2.0": [0.3, 1.2, 0.006, 0, 60],
|
|
31
|
+
"step-5-preview": [1.0, 2.7, 0.1, 0, 15],
|
|
32
|
+
"mimo-v2.6-flash": [0.14, 0.28, 0.0028, 0, 60],
|
|
33
|
+
"mimo-v2.6-pro": [0.435, 0.87, 0.003625, 0, 15],
|
|
34
|
+
"mimo-v2.5": [0.14, 0.28, 0.0028, 0, 60],
|
|
35
|
+
"mimo-v2.5-pro": [0.435, 0.87, 0.003625, 0, 15],
|
|
36
|
+
"minimax-m3.1-flash": [0.15, 0.6, 0.015, 0, 20],
|
|
37
|
+
"minimax-m3": [0.3, 1.2, 0.06, 0, 60],
|
|
38
|
+
"muse-spark-1.3-contributor": [0.1, 0.2, 0.002, 0, 60],
|
|
39
|
+
"muse-spark-1.2-contributor": [0.1, 0.2, 0.002, 0, 60],
|
|
40
|
+
"qwen3.8-max": [2.0, 6.0, 0.25, 2.5, 15],
|
|
41
|
+
"qwen3.8-flash": [0.15, 0.47, 0.016, 0.2, 30],
|
|
42
|
+
"qwen3.7-plus": [0.4, 1.6, 0.04, 0.5, 60],
|
|
43
|
+
"deepseek-v4.1-flash": [0.15, 0.6, 0.003, 0, 80],
|
|
44
|
+
"deepseek-v4-pro": [0.66, 1.98, 0.022, 0, 15],
|
|
45
|
+
"hy4-preview": [0.834, 2.501, 0.042, 0, 30],
|
|
46
|
+
"hy3": [0.14, 0.58, 0.035, 0, 60],
|
|
47
|
+
"grok-4.7": [2.0, 6.0, 0.5, 0, 15],
|
|
48
|
+
"gpt-6-luna": [0.1, 0.5, 0.01, 0.125, 15],
|
|
49
|
+
"gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25, 15],
|
|
50
|
+
"claude-haiku-5-5": [0.1, 0.5, 0.01, 0.125, 15],
|
|
51
|
+
"gemini-4-argon-contributor": [4.0, 20.0, 0.2, 0, 60],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const GO_PLUS_PRICING = {
|
|
55
|
+
"glm-5.3-flash": [0.15, 0.5, 0.03, 0, 180],
|
|
56
|
+
"glm-5.3": [1.4, 4.4, 0.26, 0, 120],
|
|
57
|
+
"glm-5.2": [1.4, 4.4, 0.26, 0, 180],
|
|
58
|
+
"kimi-k3": [3.0, 15.0, 0.3, 0, 60],
|
|
59
|
+
"kimi-k2.7-code": [0.95, 4.0, 0.19, 0, 180],
|
|
60
|
+
"longcat-2.0": [0.3, 1.2, 0.006, 0, 240],
|
|
61
|
+
"step-5-preview": [1.0, 2.7, 0.1, 0, 60],
|
|
62
|
+
"mimo-v2.6-flash": [0.14, 0.28, 0.0028, 0, 120],
|
|
63
|
+
"mimo-v2.6-pro": [0.435, 0.87, 0.003625, 0, 60],
|
|
64
|
+
"mimo-v2.5": [0.14, 0.28, 0.0028, 0, 120],
|
|
65
|
+
"mimo-v2.5-pro": [0.435, 0.87, 0.003625, 0, 60],
|
|
66
|
+
"minimax-m3.1-flash": [0.15, 0.6, 0.015, 0, 80],
|
|
67
|
+
"minimax-m3": [0.3, 1.2, 0.06, 0, 180],
|
|
68
|
+
"muse-spark-1.3-contributor": [0.1, 0.2, 0.002, 0, 120],
|
|
69
|
+
"muse-spark-1.2-contributor": [0.1, 0.2, 0.002, 0, 120],
|
|
70
|
+
"qwen3.8-max": [2.0, 6.0, 0.25, 2.5, 60],
|
|
71
|
+
"qwen3.8-flash": [0.15, 0.47, 0.016, 0.2, 90],
|
|
72
|
+
"qwen3.7-plus": [0.4, 1.6, 0.04, 0.5, 180],
|
|
73
|
+
"deepseek-v4.1-flash": [0.15, 0.6, 0.003, 0, 160],
|
|
74
|
+
"deepseek-v4-pro": [0.66, 1.98, 0.022, 0, 60],
|
|
75
|
+
"hy4-preview": [0.834, 2.501, 0.042, 0, 120],
|
|
76
|
+
"hy3": [0.14, 0.58, 0.035, 0, 240],
|
|
77
|
+
"grok-4.7": [2.0, 6.0, 0.5, 0, 60],
|
|
78
|
+
"gpt-6-luna": [0.1, 0.5, 0.01, 0.125, 60],
|
|
79
|
+
"gpt-5.6-luna": [0.2, 1.2, 0.02, 0.25, 60],
|
|
80
|
+
"claude-haiku-5-5": [0.1, 0.5, 0.01, 0.125, 60],
|
|
81
|
+
"gemini-4-argon-contributor": [4.0, 20.0, 0.2, 0, 240],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadJson(file, fallback) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(file, "utf8"))
|
|
87
|
+
} catch {
|
|
88
|
+
return fallback
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findDatabasePath() {
|
|
93
|
+
const xdg = process.env.XDG_DATA_HOME
|
|
94
|
+
const candidates = [
|
|
95
|
+
xdg ? path.join(xdg, "opencode", "opencode.db") : null,
|
|
96
|
+
path.join(HOME, ".local/share/opencode/opencode.db"),
|
|
97
|
+
path.join(HOME, ".opencode/opencode.db"),
|
|
98
|
+
].filter(Boolean)
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (existsSync(candidate)) return candidate
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function tzOffsetMinutes(tz, ts) {
|
|
106
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
107
|
+
timeZone: tz,
|
|
108
|
+
hour12: false,
|
|
109
|
+
year: "numeric",
|
|
110
|
+
month: "2-digit",
|
|
111
|
+
day: "2-digit",
|
|
112
|
+
hour: "2-digit",
|
|
113
|
+
minute: "2-digit",
|
|
114
|
+
second: "2-digit",
|
|
115
|
+
})
|
|
116
|
+
const parts = dtf.formatToParts(new Date(ts)).reduce((acc, p) => {
|
|
117
|
+
acc[p.type] = p.value
|
|
118
|
+
return acc
|
|
119
|
+
}, {})
|
|
120
|
+
const asUTC = Date.UTC(
|
|
121
|
+
Number(parts.year),
|
|
122
|
+
Number(parts.month) - 1,
|
|
123
|
+
Number(parts.day),
|
|
124
|
+
Number(parts.hour) % 24,
|
|
125
|
+
Number(parts.minute),
|
|
126
|
+
Number(parts.second),
|
|
127
|
+
)
|
|
128
|
+
return Math.round((asUTC - ts) / 60000)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function startOfDay(ts, tz) {
|
|
132
|
+
const dtf = new Intl.DateTimeFormat("en-CA", {
|
|
133
|
+
timeZone: tz,
|
|
134
|
+
year: "numeric",
|
|
135
|
+
month: "2-digit",
|
|
136
|
+
day: "2-digit",
|
|
137
|
+
})
|
|
138
|
+
const [y, m, d] = dtf.format(new Date(ts)).split("-").map(Number)
|
|
139
|
+
const noonUTC = Date.UTC(y, m - 1, d, 12)
|
|
140
|
+
const offset = tzOffsetMinutes(tz, noonUTC)
|
|
141
|
+
return Date.UTC(y, m - 1, d) - offset * 60000
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function startOfMonth(ts, tz) {
|
|
145
|
+
const dtf = new Intl.DateTimeFormat("en-CA", {
|
|
146
|
+
timeZone: tz,
|
|
147
|
+
year: "numeric",
|
|
148
|
+
month: "2-digit",
|
|
149
|
+
})
|
|
150
|
+
const [y, m] = dtf.format(new Date(ts)).split("-").map(Number)
|
|
151
|
+
const noonUTC = Date.UTC(y, m - 1, 1, 12)
|
|
152
|
+
const offset = tzOffsetMinutes(tz, noonUTC)
|
|
153
|
+
return Date.UTC(y, m - 1, 1) - offset * 60000
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fmtTokens(n) {
|
|
157
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"
|
|
158
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
|
|
159
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"
|
|
160
|
+
return String(Math.round(n))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fmtUSD(n) {
|
|
164
|
+
if (n < 0.005) return "$0"
|
|
165
|
+
return "$" + n.toFixed(n < 10 ? 2 : 1)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fmtDuration(ms) {
|
|
169
|
+
const totalMin = Math.max(0, Math.round(ms / 60000))
|
|
170
|
+
const h = Math.floor(totalMin / 60)
|
|
171
|
+
const m = totalMin % 60
|
|
172
|
+
if (h > 0) return `${h}h ${m}m`
|
|
173
|
+
if (m > 0) return `${m}m`
|
|
174
|
+
return "곧"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function bar(remainRatio, width = 10) {
|
|
178
|
+
const clamped = Math.max(0, Math.min(1, remainRatio))
|
|
179
|
+
const filled = Math.round(clamped * width)
|
|
180
|
+
return "[" + "█".repeat(filled) + "░".repeat(width - filled) + "]"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default {
|
|
184
|
+
id: "usage-tracker",
|
|
185
|
+
tui: async (api) => {
|
|
186
|
+
const config = { ...DEFAULT_CONFIG, ...loadJson(CONFIG_PATH, {}) }
|
|
187
|
+
if (config.enabled === false) return
|
|
188
|
+
const cacheWeights = { ...DEFAULT_CONFIG.cacheWeights, ...config.cacheWeights }
|
|
189
|
+
const tz = config.resetTimezone || "Asia/Seoul"
|
|
190
|
+
const dailyLimit = Number(config.dailyLimit) > 0 ? Number(config.dailyLimit) : 0
|
|
191
|
+
const goPlan = config.plan === "go" ? GO_PRICING : config.plan === "go-plus" ? GO_PLUS_PRICING : null
|
|
192
|
+
|
|
193
|
+
const [revision, setRevision] = createSignal(0)
|
|
194
|
+
const [dashVisible, setDashVisible] = createSignal(false)
|
|
195
|
+
const bump = () => setRevision((r) => r + 1)
|
|
196
|
+
|
|
197
|
+
let db = null
|
|
198
|
+
let dbError = ""
|
|
199
|
+
let modelMap = new Map()
|
|
200
|
+
let records = []
|
|
201
|
+
let lastLoadedTs = 0
|
|
202
|
+
let lastToastKey = ""
|
|
203
|
+
let pendingSessionIds = new Set()
|
|
204
|
+
|
|
205
|
+
const openDb = () => {
|
|
206
|
+
if (db) return true
|
|
207
|
+
const dbPath = findDatabasePath()
|
|
208
|
+
if (!dbPath) {
|
|
209
|
+
dbError = "opencode.db를 찾을 수 없음"
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
db = new Database(dbPath, { readonly: true })
|
|
214
|
+
return true
|
|
215
|
+
} catch (error) {
|
|
216
|
+
dbError = "DB 열기 실패: " + (error instanceof Error ? error.message : String(error))
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const ensureModels = (sessionIds) => {
|
|
222
|
+
if (!sessionIds.length) return
|
|
223
|
+
try {
|
|
224
|
+
const placeholders = sessionIds.map(() => "?").join(",")
|
|
225
|
+
const rows = db
|
|
226
|
+
.prepare(`SELECT id, model FROM session WHERE id IN (${placeholders})`)
|
|
227
|
+
.all(...sessionIds)
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
try {
|
|
230
|
+
const model = JSON.parse(row.model || "{}")
|
|
231
|
+
modelMap.set(row.id, {
|
|
232
|
+
providerID: model.providerID ?? "",
|
|
233
|
+
modelID: model.id ?? "",
|
|
234
|
+
})
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
for (const sid of sessionIds) {
|
|
238
|
+
if (!modelMap.has(sid)) pendingSessionIds.add(sid)
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const loadRecords = (sinceTs) => {
|
|
244
|
+
if (!openDb()) return
|
|
245
|
+
const now = Date.now()
|
|
246
|
+
try {
|
|
247
|
+
const rows = db
|
|
248
|
+
.prepare(
|
|
249
|
+
"SELECT session_id, time_created, data FROM part WHERE time_created >= ? AND data LIKE '%\"type\":\"step-finish\"%' ORDER BY time_created ASC",
|
|
250
|
+
)
|
|
251
|
+
.all(sinceTs)
|
|
252
|
+
const newSessions = new Set()
|
|
253
|
+
let maxTs = sinceTs
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
let parsed
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(row.data)
|
|
258
|
+
} catch {
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (parsed?.type !== "step-finish" || !parsed?.tokens) continue
|
|
262
|
+
records.push({
|
|
263
|
+
ts: row.time_created,
|
|
264
|
+
sid: row.session_id,
|
|
265
|
+
input: parsed.tokens.input ?? 0,
|
|
266
|
+
output: parsed.tokens.output ?? 0,
|
|
267
|
+
cacheRead: parsed.tokens.cache?.read ?? 0,
|
|
268
|
+
cacheWrite: parsed.tokens.cache?.write ?? 0,
|
|
269
|
+
})
|
|
270
|
+
if (row.time_created > maxTs) maxTs = row.time_created
|
|
271
|
+
if (!modelMap.has(row.session_id)) newSessions.add(row.session_id)
|
|
272
|
+
}
|
|
273
|
+
ensureModels([...newSessions, ...pendingSessionIds])
|
|
274
|
+
pendingSessionIds = new Set(
|
|
275
|
+
[...pendingSessionIds].filter((sid) => !modelMap.has(sid)),
|
|
276
|
+
)
|
|
277
|
+
lastLoadedTs = Math.max(lastLoadedTs, maxTs, now - 1000)
|
|
278
|
+
} catch (error) {
|
|
279
|
+
dbError = "조회 실패: " + (error instanceof Error ? error.message : String(error))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const refresh = () => {
|
|
284
|
+
const historyMs = (config.historyDays || 2) * 86400000
|
|
285
|
+
loadRecords(lastLoadedTs || Date.now() - historyMs)
|
|
286
|
+
bump()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const netTokens = (r) =>
|
|
290
|
+
r.input +
|
|
291
|
+
r.output +
|
|
292
|
+
r.cacheRead * (cacheWeights.read ?? 0.1) +
|
|
293
|
+
r.cacheWrite * (cacheWeights.write ?? 0.25)
|
|
294
|
+
|
|
295
|
+
const todayAggregate = () => {
|
|
296
|
+
const todayStart = startOfDay(Date.now(), tz)
|
|
297
|
+
const agg = { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, net: 0 }
|
|
298
|
+
for (const r of records) {
|
|
299
|
+
if (r.ts < todayStart) continue
|
|
300
|
+
agg.requests += 1
|
|
301
|
+
agg.input += r.input
|
|
302
|
+
agg.output += r.output
|
|
303
|
+
agg.cacheRead += r.cacheRead
|
|
304
|
+
agg.cacheWrite += r.cacheWrite
|
|
305
|
+
agg.net += netTokens(r)
|
|
306
|
+
}
|
|
307
|
+
return agg
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const goUsage = (from) => {
|
|
311
|
+
let cost = 0
|
|
312
|
+
let limit = 0
|
|
313
|
+
const seen = new Set()
|
|
314
|
+
for (const r of records) {
|
|
315
|
+
if (r.ts < from) continue
|
|
316
|
+
const model = modelMap.get(r.sid)
|
|
317
|
+
if (!model || model.providerID !== "opencode-go") continue
|
|
318
|
+
const price = goPlan[model.modelID]
|
|
319
|
+
if (!price) continue
|
|
320
|
+
cost +=
|
|
321
|
+
(r.input / 1e6) * price[0] +
|
|
322
|
+
(r.output / 1e6) * price[1] +
|
|
323
|
+
(r.cacheRead / 1e6) * price[2] +
|
|
324
|
+
(r.cacheWrite / 1e6) * price[3]
|
|
325
|
+
if (!seen.has(model.modelID)) {
|
|
326
|
+
seen.add(model.modelID)
|
|
327
|
+
limit = Math.max(limit, price[4])
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { cost, limit }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const topModelsToday = (limit = 5) => {
|
|
334
|
+
const todayStart = startOfDay(Date.now(), tz)
|
|
335
|
+
const stats = new Map()
|
|
336
|
+
for (const r of records) {
|
|
337
|
+
if (r.ts < todayStart) continue
|
|
338
|
+
const model = modelMap.get(r.sid) ?? { providerID: "?", modelID: "?" }
|
|
339
|
+
const key = model.modelID || "?"
|
|
340
|
+
const entry = stats.get(key) ?? { net: 0, requests: 0 }
|
|
341
|
+
entry.net += netTokens(r)
|
|
342
|
+
entry.requests += 1
|
|
343
|
+
stats.set(key, entry)
|
|
344
|
+
}
|
|
345
|
+
return [...stats.entries()]
|
|
346
|
+
.sort((a, b) => b[1].net - a[1].net)
|
|
347
|
+
.slice(0, limit)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const activeCooldowns = () => {
|
|
351
|
+
const state = loadJson(RATE_LIMIT_STATE_PATH, {})
|
|
352
|
+
const now = Date.now()
|
|
353
|
+
const out = []
|
|
354
|
+
for (const [key, entry] of Object.entries(state)) {
|
|
355
|
+
if (key === "lastEventAt" || !entry?.cooldownUntil) continue
|
|
356
|
+
if (entry.cooldownUntil <= now) continue
|
|
357
|
+
out.push({
|
|
358
|
+
key,
|
|
359
|
+
minutesLeft: entry.cooldownUntil - now,
|
|
360
|
+
count: entry.count ?? 1,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
return out.sort((a, b) => b.minutesLeft - a.minutesLeft)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const recentRateLimits = (windowMs = 3600000) => {
|
|
367
|
+
const state = loadJson(RATE_LIMIT_STATE_PATH, {})
|
|
368
|
+
const now = Date.now()
|
|
369
|
+
let count = 0
|
|
370
|
+
for (const [key, entry] of Object.entries(state)) {
|
|
371
|
+
if (key === "lastEventAt" || !entry?.lastSeen) continue
|
|
372
|
+
if (now - entry.lastSeen <= windowMs) count += 1
|
|
373
|
+
}
|
|
374
|
+
return count
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const maybeWarn = (message, key) => {
|
|
378
|
+
if (lastToastKey === key) return
|
|
379
|
+
lastToastKey = key
|
|
380
|
+
api.ui.toast({ variant: "warn", message, duration: 6000 })
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const remainColor = (remainRatio, theme) => {
|
|
384
|
+
if (remainRatio <= 0.2) return theme.error ?? theme.accent
|
|
385
|
+
if (remainRatio <= 0.5) return theme.accent
|
|
386
|
+
return theme.text
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const footerLines = (theme) => {
|
|
390
|
+
const now = Date.now()
|
|
391
|
+
const todayStart = startOfDay(now, tz)
|
|
392
|
+
const today = todayAggregate()
|
|
393
|
+
const resetIn = todayStart + 86400000 - now
|
|
394
|
+
const lines = []
|
|
395
|
+
|
|
396
|
+
if (goPlan) {
|
|
397
|
+
const fiveHour = goUsage(now - 5 * 3600000)
|
|
398
|
+
const month = goUsage(startOfMonth(now, tz))
|
|
399
|
+
if (fiveHour.limit > 0) {
|
|
400
|
+
const cap = fiveHour.limit * 0.2
|
|
401
|
+
const remain = Math.max(0, cap - fiveHour.cost)
|
|
402
|
+
const remainRatio = cap > 0 ? remain / cap : 0
|
|
403
|
+
lines.push(
|
|
404
|
+
jsx("text", {
|
|
405
|
+
fg: remainColor(remainRatio, theme),
|
|
406
|
+
children: `⚡ 5h 남음 ${fmtUSD(remain)} (${Math.round(remainRatio * 100)}%)`,
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
lines.push(
|
|
410
|
+
jsx("text", {
|
|
411
|
+
fg: theme.textMuted,
|
|
412
|
+
children: `${bar(remainRatio)} 월 남음 ${fmtUSD(Math.max(0, month.limit - month.cost))}`,
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
415
|
+
if (remainRatio <= 0.2) {
|
|
416
|
+
maybeWarn(
|
|
417
|
+
`5시간 윈도우 한도 임박: 남음 ${fmtUSD(remain)} / ${fmtUSD(cap)}`,
|
|
418
|
+
"go5h-" + Math.round(remainRatio * 20),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else if (dailyLimit > 0) {
|
|
423
|
+
const remain = Math.max(0, dailyLimit - today.net)
|
|
424
|
+
const remainRatio = dailyLimit > 0 ? remain / dailyLimit : 0
|
|
425
|
+
lines.push(
|
|
426
|
+
jsx("text", {
|
|
427
|
+
fg: remainColor(remainRatio, theme),
|
|
428
|
+
children: `⚡ 오늘 남음 ${fmtTokens(remain)} (${Math.round(remainRatio * 100)}%) · ${fmtDuration(resetIn)} 리셋`,
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
lines.push(
|
|
432
|
+
jsx("text", {
|
|
433
|
+
fg: theme.textMuted,
|
|
434
|
+
children: `${bar(remainRatio)} 사용 ${fmtTokens(today.net)} · ${today.requests} req`,
|
|
435
|
+
}),
|
|
436
|
+
)
|
|
437
|
+
if (remainRatio <= 0.05) {
|
|
438
|
+
maybeWarn(
|
|
439
|
+
`일일 한도 소진 임박: ${fmtTokens(remain)} 남음 (사용 ${fmtTokens(today.net)}/${fmtTokens(dailyLimit)})`,
|
|
440
|
+
"daily-" + Math.round(remainRatio * 20),
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
lines.push(
|
|
445
|
+
jsx("text", {
|
|
446
|
+
fg: theme.textMuted,
|
|
447
|
+
children: `⚡ 오늘 ${fmtTokens(today.net)} 순토큰 · ${today.requests} req`,
|
|
448
|
+
}),
|
|
449
|
+
)
|
|
450
|
+
lines.push(
|
|
451
|
+
jsx("text", {
|
|
452
|
+
fg: theme.textMuted,
|
|
453
|
+
children: `리셋 ${fmtDuration(resetIn)} · usage-tracker.json에 dailyLimit 설정 시 남은 한도 표시`,
|
|
454
|
+
}),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const cooldowns = activeCooldowns()
|
|
459
|
+
if (cooldowns.length > 0) {
|
|
460
|
+
lines.push(
|
|
461
|
+
jsx("text", {
|
|
462
|
+
fg: theme.error ?? theme.accent,
|
|
463
|
+
children: `⛔ 429 쿨다운 ${fmtDuration(cooldowns[0].minutesLeft)} · /usage로 상세`,
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
return lines
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const dashboardLines = (theme) => {
|
|
471
|
+
const now = Date.now()
|
|
472
|
+
const today = todayAggregate()
|
|
473
|
+
const resetIn = startOfDay(now, tz) + 86400000 - now
|
|
474
|
+
const lines = []
|
|
475
|
+
|
|
476
|
+
lines.push(jsx("text", { fg: theme.accent, children: "─── 오늘 사용량 ───" }))
|
|
477
|
+
lines.push(
|
|
478
|
+
jsx("text", {
|
|
479
|
+
fg: theme.text,
|
|
480
|
+
children: `순 ${fmtTokens(today.net)} · ${today.requests} req · 리셋 ${fmtDuration(resetIn)}`,
|
|
481
|
+
}),
|
|
482
|
+
)
|
|
483
|
+
lines.push(
|
|
484
|
+
jsx("text", {
|
|
485
|
+
fg: theme.textMuted,
|
|
486
|
+
children: `입력 ${fmtTokens(today.input)} · 출력 ${fmtTokens(today.output)} · 캐시 읽기 ${fmtTokens(today.cacheRead)}`,
|
|
487
|
+
}),
|
|
488
|
+
)
|
|
489
|
+
if (!goPlan && dailyLimit > 0) {
|
|
490
|
+
const remain = Math.max(0, dailyLimit - today.net)
|
|
491
|
+
lines.push(
|
|
492
|
+
jsx("text", {
|
|
493
|
+
fg: remainColor(dailyLimit > 0 ? remain / dailyLimit : 0, theme),
|
|
494
|
+
children: `남은 한도 ${fmtTokens(remain)} / ${fmtTokens(dailyLimit)}`,
|
|
495
|
+
}),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
lines.push(jsx("text", { fg: theme.accent, children: "─── 모델별 오늘 ───" }))
|
|
500
|
+
const models = topModelsToday()
|
|
501
|
+
if (models.length === 0) {
|
|
502
|
+
lines.push(jsx("text", { fg: theme.textMuted, children: "기록 없음" }))
|
|
503
|
+
}
|
|
504
|
+
for (const [name, stat] of models) {
|
|
505
|
+
lines.push(
|
|
506
|
+
jsx("text", {
|
|
507
|
+
fg: theme.textMuted,
|
|
508
|
+
children: `${name.slice(0, 24).padEnd(24)} ${fmtTokens(stat.net).padStart(7)} · ${stat.requests} req`,
|
|
509
|
+
}),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
lines.push(jsx("text", { fg: theme.accent, children: "─── 429 rate limit ───" }))
|
|
514
|
+
const cooldowns = activeCooldowns()
|
|
515
|
+
lines.push(
|
|
516
|
+
jsx("text", {
|
|
517
|
+
fg: theme.textMuted,
|
|
518
|
+
children: `최근 1시간 ${recentRateLimits()}회 · 진행 중 쿨다운 ${cooldowns.length}건`,
|
|
519
|
+
}),
|
|
520
|
+
)
|
|
521
|
+
for (const c of cooldowns.slice(0, 3)) {
|
|
522
|
+
lines.push(
|
|
523
|
+
jsx("text", {
|
|
524
|
+
fg: theme.error ?? theme.accent,
|
|
525
|
+
children: `쿨다운 ${fmtDuration(c.minutesLeft)} · ${c.count}회 누적`,
|
|
526
|
+
}),
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
return lines
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
api.slots.register({
|
|
533
|
+
slots: {
|
|
534
|
+
sidebar_footer: (context, props) =>
|
|
535
|
+
jsx("box", {
|
|
536
|
+
flexDirection: "column",
|
|
537
|
+
paddingLeft: 1,
|
|
538
|
+
paddingRight: 1,
|
|
539
|
+
children: () => {
|
|
540
|
+
revision()
|
|
541
|
+
const theme = context.theme.current
|
|
542
|
+
if (!props?.session_id) return null
|
|
543
|
+
try {
|
|
544
|
+
if (dbError && !db) {
|
|
545
|
+
return [
|
|
546
|
+
jsx("text", { fg: theme.error ?? theme.accent, children: `⚡ usage-tracker: ${dbError}` }),
|
|
547
|
+
]
|
|
548
|
+
}
|
|
549
|
+
const lines = footerLines(theme)
|
|
550
|
+
if (dashVisible()) lines.push(...dashboardLines(theme))
|
|
551
|
+
return lines
|
|
552
|
+
} catch (error) {
|
|
553
|
+
return [
|
|
554
|
+
jsx("text", {
|
|
555
|
+
fg: theme.error ?? theme.accent,
|
|
556
|
+
children: "usage-tracker 렌더 오류: " + (error instanceof Error ? error.message : String(error)),
|
|
557
|
+
}),
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
}),
|
|
562
|
+
},
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
let refreshTimer = null
|
|
566
|
+
const unsubscribers = []
|
|
567
|
+
|
|
568
|
+
refresh()
|
|
569
|
+
|
|
570
|
+
const pollMs = Math.max(5, config.pollSeconds || 30) * 1000
|
|
571
|
+
refreshTimer = setInterval(() => refresh(), pollMs)
|
|
572
|
+
|
|
573
|
+
const partUnsub = api.event.on("message.part.updated", (event) => {
|
|
574
|
+
const part = event?.properties?.part
|
|
575
|
+
if (part?.type === "step-finish") refresh()
|
|
576
|
+
})
|
|
577
|
+
if (partUnsub) unsubscribers.push(partUnsub)
|
|
578
|
+
|
|
579
|
+
const errorUnsub = api.event.on("session.error", () => {
|
|
580
|
+
const state = loadJson(RATE_LIMIT_STATE_PATH, {})
|
|
581
|
+
if (state?.lastEventAt && Date.now() - state.lastEventAt < 10000) {
|
|
582
|
+
refresh()
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
if (errorUnsub) unsubscribers.push(errorUnsub)
|
|
586
|
+
|
|
587
|
+
const statusUnsub = api.event.on("session.status", (event) => {
|
|
588
|
+
if (event?.properties?.status?.type === "idle") refresh()
|
|
589
|
+
})
|
|
590
|
+
if (statusUnsub) unsubscribers.push(statusUnsub)
|
|
591
|
+
|
|
592
|
+
api.keymap.registerLayer({
|
|
593
|
+
priority: 900,
|
|
594
|
+
commands: [
|
|
595
|
+
{
|
|
596
|
+
name: "usage-tracker.toggle",
|
|
597
|
+
title: "Toggle usage dashboard",
|
|
598
|
+
desc: "오늘 사용량/남은 한도/429 쿨다운 대시보드 표시",
|
|
599
|
+
category: "Session",
|
|
600
|
+
namespace: "palette",
|
|
601
|
+
slashName: "usage",
|
|
602
|
+
run: () => {
|
|
603
|
+
const next = !dashVisible()
|
|
604
|
+
setDashVisible(next)
|
|
605
|
+
if (next) refresh()
|
|
606
|
+
api.ui.toast({
|
|
607
|
+
variant: next ? "success" : "info",
|
|
608
|
+
message: next ? "사용량 대시보드를 열었습니다." : "사용량 대시보드를 닫았습니다.",
|
|
609
|
+
duration: 2000,
|
|
610
|
+
})
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
bindings: [],
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
api.lifecycle.onDispose(() => {
|
|
618
|
+
if (refreshTimer) clearInterval(refreshTimer)
|
|
619
|
+
for (const unsub of unsubscribers) {
|
|
620
|
+
try {
|
|
621
|
+
unsub()
|
|
622
|
+
} catch {}
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
db?.close()
|
|
626
|
+
} catch {}
|
|
627
|
+
})
|
|
628
|
+
},
|
|
629
|
+
}
|