@hanzo/usage 0.1.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/dist/host.d.ts +27 -0
- package/dist/host.js +18 -0
- package/dist/hosts/node.d.ts +2 -0
- package/dist/hosts/node.js +50 -0
- package/dist/hosts/tauri.d.ts +26 -0
- package/dist/hosts/tauri.js +67 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +18 -0
- package/dist/provider.d.ts +71 -0
- package/dist/provider.js +45 -0
- package/dist/providers/claude.d.ts +2 -0
- package/dist/providers/claude.js +138 -0
- package/dist/providers/codex.d.ts +2 -0
- package/dist/providers/codex.js +91 -0
- package/dist/providers/hanzo.d.ts +2 -0
- package/dist/providers/hanzo.js +168 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +4 -0
- package/dist/store.d.ts +49 -0
- package/dist/store.js +156 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +4 -0
- package/package.json +59 -0
- package/src/host.ts +49 -0
- package/src/hosts/node.ts +50 -0
- package/src/hosts/tauri.ts +77 -0
- package/src/index.ts +22 -0
- package/src/provider.ts +125 -0
- package/src/providers/claude.ts +178 -0
- package/src/providers/codex.ts +126 -0
- package/src/providers/hanzo.ts +203 -0
- package/src/react.ts +12 -0
- package/src/store.ts +209 -0
- package/src/types.ts +160 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hanzo AI Inc. MIT License.
|
|
2
|
+
// Hanzo provider — two lanes:
|
|
3
|
+
// 1. hanzo.cloud — commerce billing usage (`GET {base}/v1/billing/usage`),
|
|
4
|
+
// the same source of truth console and chat bill against.
|
|
5
|
+
// 2. hanzo.dev — local probe of Hanzo Dev CLI rollouts
|
|
6
|
+
// ($HANZO_HOME|$CODEX_HOME|~/.hanzo|~/.codex sessions/YYYY/MM/DD/*.jsonl):
|
|
7
|
+
// the last persisted token_count event carries token totals and the
|
|
8
|
+
// latest rate-limit snapshot, exactly what the dev app-server replays.
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ProviderDescriptor,
|
|
12
|
+
ProviderFetchContext,
|
|
13
|
+
ProviderFetchResult,
|
|
14
|
+
ProviderFetchStrategy,
|
|
15
|
+
} from '../provider.js'
|
|
16
|
+
import { forMode } from '../provider.js'
|
|
17
|
+
import type { RateWindow, UsageSnapshot } from '../types.js'
|
|
18
|
+
import { expandHome } from '../host.js'
|
|
19
|
+
|
|
20
|
+
// ---- cloud (commerce ledger) ----
|
|
21
|
+
|
|
22
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
23
|
+
|
|
24
|
+
const cloudStrategy: ProviderFetchStrategy = {
|
|
25
|
+
id: 'hanzo.cloud',
|
|
26
|
+
kind: 'apiToken',
|
|
27
|
+
async isAvailable(ctx) {
|
|
28
|
+
return Boolean(ctx.settings?.getToken || ctx.settings?.apiKey)
|
|
29
|
+
},
|
|
30
|
+
async fetch(ctx): Promise<ProviderFetchResult> {
|
|
31
|
+
const base = ctx.settings?.baseUrl ?? 'https://api.hanzo.ai'
|
|
32
|
+
const token = (await ctx.settings?.getToken?.()) ?? ctx.settings?.apiKey
|
|
33
|
+
if (!token) throw new Error('hanzo: no token for cloud usage')
|
|
34
|
+
const res = await ctx.host.http({
|
|
35
|
+
url: `${base}/v1/billing/usage`,
|
|
36
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
37
|
+
timeoutMs: 30_000,
|
|
38
|
+
})
|
|
39
|
+
if (res.status !== 200) throw new Error(`hanzo billing usage HTTP ${res.status}`)
|
|
40
|
+
const body = JSON.parse(res.text) as Record<string, unknown>
|
|
41
|
+
// Defensive roll-up: accept either precomputed totals or a ledger of entries.
|
|
42
|
+
const entries = Array.isArray(body.usage)
|
|
43
|
+
? (body.usage as Array<Record<string, unknown>>)
|
|
44
|
+
: Array.isArray(body.data)
|
|
45
|
+
? (body.data as Array<Record<string, unknown>>)
|
|
46
|
+
: []
|
|
47
|
+
let tokens = num(body.total_tokens ?? body.totalTokens)
|
|
48
|
+
let spendCents = num(body.spend_cents ?? body.spendCents)
|
|
49
|
+
let requests = num(body.total_requests ?? body.requests)
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
tokens += num(e.total_tokens ?? e.totalTokens ?? e.tokens)
|
|
52
|
+
spendCents += num(e.spend_cents ?? e.spendCents ?? e.cost_cents)
|
|
53
|
+
requests += num(e.requests ?? e.request_count ?? 1) - (e.requests === undefined && e.request_count === undefined ? 1 : 0)
|
|
54
|
+
}
|
|
55
|
+
const now = ctx.host.now().toISOString()
|
|
56
|
+
const usage: UsageSnapshot = {
|
|
57
|
+
providerId: 'hanzo',
|
|
58
|
+
identity: { providerId: 'hanzo', loginMethod: 'iam' },
|
|
59
|
+
providerCost: {
|
|
60
|
+
used: spendCents / 100,
|
|
61
|
+
currencyCode: 'USD',
|
|
62
|
+
period: 'monthly',
|
|
63
|
+
updatedAt: now,
|
|
64
|
+
},
|
|
65
|
+
totals: { tokens, requests },
|
|
66
|
+
dataConfidence: 'exact',
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
usage,
|
|
71
|
+
sourceLabel: 'Hanzo Cloud',
|
|
72
|
+
strategyId: this.id,
|
|
73
|
+
strategyKind: this.kind,
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- local dev CLI probe ----
|
|
79
|
+
|
|
80
|
+
interface TokenCountRateLimitWindow {
|
|
81
|
+
used_percent?: number
|
|
82
|
+
window_minutes?: number
|
|
83
|
+
resets_in_seconds?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface TokenCountPayload {
|
|
87
|
+
type?: string
|
|
88
|
+
info?: {
|
|
89
|
+
total_token_usage?: {
|
|
90
|
+
input_tokens?: number
|
|
91
|
+
cached_input_tokens?: number
|
|
92
|
+
output_tokens?: number
|
|
93
|
+
total_tokens?: number
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
rate_limits?: {
|
|
97
|
+
primary?: TokenCountRateLimitWindow
|
|
98
|
+
secondary?: TokenCountRateLimitWindow
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const devHome = async (ctx: ProviderFetchContext): Promise<string | undefined> => {
|
|
103
|
+
const candidates = [
|
|
104
|
+
ctx.host.env('HANZO_HOME'),
|
|
105
|
+
ctx.host.env('CODEX_HOME'),
|
|
106
|
+
expandHome(ctx.host, '~/.hanzo'),
|
|
107
|
+
expandHome(ctx.host, '~/.codex'),
|
|
108
|
+
].filter((c): c is string => Boolean(c))
|
|
109
|
+
for (const dir of candidates) {
|
|
110
|
+
if ((await ctx.host.listDir(`${dir}/sessions`)).length > 0) return dir
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Newest entry of a directory listing of numeric names (years, months, days). */
|
|
116
|
+
const newest = (names: string[]): string | undefined =>
|
|
117
|
+
names.filter((n) => /^\d+$/.test(n)).sort().at(-1)
|
|
118
|
+
|
|
119
|
+
const toRateWindow = (
|
|
120
|
+
w: TokenCountRateLimitWindow | undefined,
|
|
121
|
+
now: Date,
|
|
122
|
+
): RateWindow | undefined => {
|
|
123
|
+
if (!w || typeof w.used_percent !== 'number') return undefined
|
|
124
|
+
return {
|
|
125
|
+
usedPercent: w.used_percent,
|
|
126
|
+
windowMinutes: w.window_minutes,
|
|
127
|
+
resetsAt:
|
|
128
|
+
typeof w.resets_in_seconds === 'number'
|
|
129
|
+
? new Date(now.getTime() + w.resets_in_seconds * 1000).toISOString()
|
|
130
|
+
: undefined,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const devStrategy: ProviderFetchStrategy = {
|
|
135
|
+
id: 'hanzo.dev',
|
|
136
|
+
kind: 'localProbe',
|
|
137
|
+
async isAvailable(ctx) {
|
|
138
|
+
return Boolean(await devHome(ctx))
|
|
139
|
+
},
|
|
140
|
+
async fetch(ctx): Promise<ProviderFetchResult> {
|
|
141
|
+
const home = await devHome(ctx)
|
|
142
|
+
if (!home) throw new Error('hanzo: no dev CLI home with sessions')
|
|
143
|
+
const sessions = `${home}/sessions`
|
|
144
|
+
const year = newest(await ctx.host.listDir(sessions))
|
|
145
|
+
const month = year && newest(await ctx.host.listDir(`${sessions}/${year}`))
|
|
146
|
+
const day = month && newest(await ctx.host.listDir(`${sessions}/${year}/${month}`))
|
|
147
|
+
if (!day) throw new Error('hanzo: no dev sessions found')
|
|
148
|
+
const dayDir = `${sessions}/${year}/${month}/${day}`
|
|
149
|
+
const rollouts = (await ctx.host.listDir(dayDir))
|
|
150
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
151
|
+
.sort()
|
|
152
|
+
const latest = rollouts.at(-1)
|
|
153
|
+
if (!latest) throw new Error('hanzo: no rollout files today')
|
|
154
|
+
const text = (await ctx.host.readTextFile(`${dayDir}/${latest}`)) ?? ''
|
|
155
|
+
let payload: TokenCountPayload | undefined
|
|
156
|
+
for (const line of text.split('\n')) {
|
|
157
|
+
if (!line.includes('"token_count"')) continue
|
|
158
|
+
try {
|
|
159
|
+
const item = JSON.parse(line) as { payload?: TokenCountPayload }
|
|
160
|
+
if (item.payload?.type === 'token_count') payload = item.payload
|
|
161
|
+
} catch {
|
|
162
|
+
// tolerate partial trailing lines
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const now = ctx.host.now()
|
|
166
|
+
const totals = payload?.info?.total_token_usage
|
|
167
|
+
const usage: UsageSnapshot = {
|
|
168
|
+
providerId: 'hanzo',
|
|
169
|
+
primary: toRateWindow(payload?.rate_limits?.primary, now),
|
|
170
|
+
secondary: toRateWindow(payload?.rate_limits?.secondary, now),
|
|
171
|
+
identity: { providerId: 'hanzo', loginMethod: 'dev-cli' },
|
|
172
|
+
totals: totals
|
|
173
|
+
? {
|
|
174
|
+
tokens: totals.total_tokens ?? 0,
|
|
175
|
+
inputTokens: totals.input_tokens ?? 0,
|
|
176
|
+
outputTokens: totals.output_tokens ?? 0,
|
|
177
|
+
cachedInputTokens: totals.cached_input_tokens ?? 0,
|
|
178
|
+
}
|
|
179
|
+
: undefined,
|
|
180
|
+
dataConfidence: totals ? 'exact' : 'unknown',
|
|
181
|
+
updatedAt: now.toISOString(),
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
usage,
|
|
185
|
+
sourceLabel: 'Hanzo Dev CLI',
|
|
186
|
+
strategyId: this.id,
|
|
187
|
+
strategyKind: this.kind,
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const hanzoProvider: ProviderDescriptor = {
|
|
193
|
+
id: 'hanzo',
|
|
194
|
+
metadata: {
|
|
195
|
+
displayName: 'Hanzo',
|
|
196
|
+
sessionLabel: '5h limit',
|
|
197
|
+
weeklyLabel: 'Weekly limit',
|
|
198
|
+
defaultEnabled: true,
|
|
199
|
+
dashboardUrl: 'https://console.hanzo.ai/billing/usage',
|
|
200
|
+
color: '#ff2d55',
|
|
201
|
+
},
|
|
202
|
+
strategies: (mode) => forMode([cloudStrategy, devStrategy], mode),
|
|
203
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hanzo AI Inc. MIT License.
|
|
2
|
+
// React binding — one hook, no extra state library.
|
|
3
|
+
|
|
4
|
+
import { useSyncExternalStore } from 'react'
|
|
5
|
+
import type { UsageStore, UsageStoreState } from './store.js'
|
|
6
|
+
|
|
7
|
+
export const useUsage = (store: UsageStore): UsageStoreState =>
|
|
8
|
+
useSyncExternalStore(
|
|
9
|
+
(onChange) => store.subscribe(onChange),
|
|
10
|
+
() => store.getState(),
|
|
11
|
+
() => store.getState(),
|
|
12
|
+
)
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hanzo AI Inc. MIT License.
|
|
2
|
+
// UsageStore — port of CodexBar's UsageStore + AdaptiveRefreshPolicy: central
|
|
3
|
+
// observable state, concurrent refresh across providers, poll timer, and
|
|
4
|
+
// plan-utilization history capture (schema-compatible with the Swift app).
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PlanUtilizationHistoryFile,
|
|
8
|
+
PlanUtilizationSeriesHistory,
|
|
9
|
+
CreditsSnapshot,
|
|
10
|
+
UsageSnapshot,
|
|
11
|
+
} from './types.js'
|
|
12
|
+
import type {
|
|
13
|
+
ProviderDescriptor,
|
|
14
|
+
ProviderFetchAttempt,
|
|
15
|
+
ProviderFetchContext,
|
|
16
|
+
ProviderSettings,
|
|
17
|
+
ProviderSourceMode,
|
|
18
|
+
} from './provider.js'
|
|
19
|
+
import { runPipeline } from './provider.js'
|
|
20
|
+
import type { UsageHost } from './host.js'
|
|
21
|
+
|
|
22
|
+
export interface ProviderState {
|
|
23
|
+
snapshot?: UsageSnapshot
|
|
24
|
+
credits?: CreditsSnapshot
|
|
25
|
+
error?: string
|
|
26
|
+
sourceLabel?: string
|
|
27
|
+
attempts?: ProviderFetchAttempt[]
|
|
28
|
+
refreshing: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UsageStoreState {
|
|
32
|
+
providers: Record<string, ProviderState>
|
|
33
|
+
refreshing: boolean
|
|
34
|
+
lastRefreshAt?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UsageStoreOptions {
|
|
38
|
+
host: UsageHost
|
|
39
|
+
providers: ProviderDescriptor[]
|
|
40
|
+
sourceMode?: ProviderSourceMode
|
|
41
|
+
settings?: Record<string, ProviderSettings>
|
|
42
|
+
/** Poll interval in ms; default 5 minutes (CodexBar's default). */
|
|
43
|
+
intervalMs?: number
|
|
44
|
+
/** Directory for history JSON files; omit to keep history in memory only. */
|
|
45
|
+
historyDir?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Listener = () => void
|
|
49
|
+
|
|
50
|
+
const HISTORY_MAX_ENTRIES = 2000
|
|
51
|
+
|
|
52
|
+
export class UsageStore {
|
|
53
|
+
private readonly opts: UsageStoreOptions
|
|
54
|
+
private state: UsageStoreState = { providers: {}, refreshing: false }
|
|
55
|
+
private listeners = new Set<Listener>()
|
|
56
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
57
|
+
private history = new Map<string, PlanUtilizationSeriesHistory[]>()
|
|
58
|
+
|
|
59
|
+
constructor(opts: UsageStoreOptions) {
|
|
60
|
+
this.opts = opts
|
|
61
|
+
for (const p of opts.providers) {
|
|
62
|
+
this.state.providers[p.id] = { refreshing: false }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getState(): UsageStoreState {
|
|
67
|
+
return this.state
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
subscribe(listener: Listener): () => void {
|
|
71
|
+
this.listeners.add(listener)
|
|
72
|
+
return () => this.listeners.delete(listener)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private emit(next: Partial<UsageStoreState>): void {
|
|
76
|
+
this.state = { ...this.state, ...next }
|
|
77
|
+
for (const l of this.listeners) l()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private setProvider(id: string, patch: Partial<ProviderState>): void {
|
|
81
|
+
this.emit({
|
|
82
|
+
providers: {
|
|
83
|
+
...this.state.providers,
|
|
84
|
+
[id]: { ...this.state.providers[id], refreshing: false, ...patch },
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async refresh(providerId?: string): Promise<void> {
|
|
90
|
+
const targets = this.opts.providers.filter((p) => !providerId || p.id === providerId)
|
|
91
|
+
this.emit({ refreshing: true })
|
|
92
|
+
await Promise.all(targets.map((p) => this.refreshProvider(p)))
|
|
93
|
+
this.emit({ refreshing: false, lastRefreshAt: this.opts.host.now().toISOString() })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async refreshProvider(descriptor: ProviderDescriptor): Promise<void> {
|
|
97
|
+
this.setProvider(descriptor.id, { refreshing: true })
|
|
98
|
+
const ctx: ProviderFetchContext = {
|
|
99
|
+
host: this.opts.host,
|
|
100
|
+
sourceMode: this.opts.sourceMode ?? 'auto',
|
|
101
|
+
settings: this.opts.settings?.[descriptor.id],
|
|
102
|
+
}
|
|
103
|
+
const outcome = await runPipeline(descriptor, ctx)
|
|
104
|
+
if (outcome.result) {
|
|
105
|
+
this.setProvider(descriptor.id, {
|
|
106
|
+
snapshot: outcome.result.usage,
|
|
107
|
+
credits: outcome.result.credits,
|
|
108
|
+
sourceLabel: outcome.result.sourceLabel,
|
|
109
|
+
attempts: outcome.attempts,
|
|
110
|
+
error: undefined,
|
|
111
|
+
})
|
|
112
|
+
await this.captureHistory(outcome.result.usage)
|
|
113
|
+
} else {
|
|
114
|
+
// Keep stale data over flapping — errors annotate, they don't erase.
|
|
115
|
+
this.setProvider(descriptor.id, {
|
|
116
|
+
error:
|
|
117
|
+
outcome.error instanceof Error ? outcome.error.message : String(outcome.error),
|
|
118
|
+
attempts: outcome.attempts,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
start(): void {
|
|
124
|
+
if (this.timer) return
|
|
125
|
+
const interval = this.opts.intervalMs ?? 5 * 60 * 1000
|
|
126
|
+
this.timer = setInterval(() => void this.refresh(), interval)
|
|
127
|
+
void this.refresh()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop(): void {
|
|
131
|
+
if (this.timer) clearInterval(this.timer)
|
|
132
|
+
this.timer = undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- plan-utilization history (sparkline data) ----
|
|
136
|
+
|
|
137
|
+
getHistory(providerId: string): PlanUtilizationSeriesHistory[] {
|
|
138
|
+
return this.history.get(providerId) ?? []
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async captureHistory(snapshot: UsageSnapshot): Promise<void> {
|
|
142
|
+
const lanes: Array<{ name: string; windowMinutes: number; usedPercent?: number; resetsAt?: string }> = [
|
|
143
|
+
{
|
|
144
|
+
name: 'session',
|
|
145
|
+
windowMinutes: snapshot.primary?.windowMinutes ?? 300,
|
|
146
|
+
usedPercent: snapshot.primary?.usedPercent,
|
|
147
|
+
resetsAt: snapshot.primary?.resetsAt,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'weekly',
|
|
151
|
+
windowMinutes: snapshot.secondary?.windowMinutes ?? 10_080,
|
|
152
|
+
usedPercent: snapshot.secondary?.usedPercent,
|
|
153
|
+
resetsAt: snapshot.secondary?.resetsAt,
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
let series = this.history.get(snapshot.providerId)
|
|
157
|
+
if (!series) {
|
|
158
|
+
series = await this.loadHistory(snapshot.providerId)
|
|
159
|
+
this.history.set(snapshot.providerId, series)
|
|
160
|
+
}
|
|
161
|
+
const capturedAt = snapshot.updatedAt
|
|
162
|
+
// Hour-bucketed dedup, as the Swift store does.
|
|
163
|
+
const bucket = capturedAt.slice(0, 13)
|
|
164
|
+
for (const lane of lanes) {
|
|
165
|
+
if (typeof lane.usedPercent !== 'number') continue
|
|
166
|
+
let s = series.find((x) => x.name === lane.name)
|
|
167
|
+
if (!s) {
|
|
168
|
+
s = { name: lane.name, windowMinutes: lane.windowMinutes, entries: [] }
|
|
169
|
+
series.push(s)
|
|
170
|
+
}
|
|
171
|
+
const last = s.entries.at(-1)
|
|
172
|
+
if (last && last.capturedAt.slice(0, 13) === bucket) {
|
|
173
|
+
last.usedPercent = lane.usedPercent
|
|
174
|
+
last.resetsAt = lane.resetsAt
|
|
175
|
+
} else {
|
|
176
|
+
s.entries.push({ capturedAt, usedPercent: lane.usedPercent, resetsAt: lane.resetsAt })
|
|
177
|
+
if (s.entries.length > HISTORY_MAX_ENTRIES) s.entries.splice(0, s.entries.length - HISTORY_MAX_ENTRIES)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await this.persistHistory(snapshot.providerId, series)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private historyPath(providerId: string): string | undefined {
|
|
184
|
+
return this.opts.historyDir ? `${this.opts.historyDir}/${providerId}.json` : undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async loadHistory(providerId: string): Promise<PlanUtilizationSeriesHistory[]> {
|
|
188
|
+
const path = this.historyPath(providerId)
|
|
189
|
+
if (!path) return []
|
|
190
|
+
const text = await this.opts.host.readTextFile(path)
|
|
191
|
+
if (!text) return []
|
|
192
|
+
try {
|
|
193
|
+
const file = JSON.parse(text) as PlanUtilizationHistoryFile
|
|
194
|
+
return file.version === 1 ? file.unscoped : []
|
|
195
|
+
} catch {
|
|
196
|
+
return []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async persistHistory(
|
|
201
|
+
providerId: string,
|
|
202
|
+
series: PlanUtilizationSeriesHistory[],
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const path = this.historyPath(providerId)
|
|
205
|
+
if (!path) return
|
|
206
|
+
const file: PlanUtilizationHistoryFile = { version: 1, unscoped: series }
|
|
207
|
+
await this.opts.host.writeTextFile(path, JSON.stringify(file))
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hanzo AI Inc. MIT License.
|
|
2
|
+
// TypeScript port of CodexBarCore's data model (UsageFetcher.swift, CreditsModels.swift,
|
|
3
|
+
// CostUsageModels.swift). Wire-compatible JSON where the Swift app persists snapshots.
|
|
4
|
+
|
|
5
|
+
/** Universal quota unit — one rate-limit window (session/5h, weekly, …). */
|
|
6
|
+
export interface RateWindow {
|
|
7
|
+
/** 0–100 percent used. */
|
|
8
|
+
usedPercent: number
|
|
9
|
+
/** Window length in minutes (300 = 5h, 10080 = weekly). */
|
|
10
|
+
windowMinutes?: number
|
|
11
|
+
/** Absolute reset time, ISO 8601. */
|
|
12
|
+
resetsAt?: string
|
|
13
|
+
/** Textual reset description when only scraped text is available. */
|
|
14
|
+
resetDescription?: string
|
|
15
|
+
/** Rolling-recovery providers: percent that regenerates next. */
|
|
16
|
+
nextRegenPercent?: number
|
|
17
|
+
/** True when a lane was fabricated (e.g. provider returned null for it). */
|
|
18
|
+
isSyntheticPlaceholder?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const remainingPercent = (w: RateWindow): number =>
|
|
22
|
+
Math.max(0, 100 - w.usedPercent)
|
|
23
|
+
|
|
24
|
+
export interface NamedRateWindow {
|
|
25
|
+
id: string
|
|
26
|
+
title: string
|
|
27
|
+
window: RateWindow
|
|
28
|
+
usageKnown: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UsageDataConfidence = 'exact' | 'estimated' | 'percentOnly' | 'unknown'
|
|
32
|
+
|
|
33
|
+
export interface ProviderIdentity {
|
|
34
|
+
providerId: string
|
|
35
|
+
accountEmail?: string
|
|
36
|
+
accountOrganization?: string
|
|
37
|
+
loginMethod?: string
|
|
38
|
+
plan?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Per-provider fetch result — the central value of the system. */
|
|
42
|
+
export interface UsageSnapshot {
|
|
43
|
+
providerId: string
|
|
44
|
+
/** Session / 5h lane. */
|
|
45
|
+
primary?: RateWindow
|
|
46
|
+
/** Weekly lane. */
|
|
47
|
+
secondary?: RateWindow
|
|
48
|
+
tertiary?: RateWindow
|
|
49
|
+
/** Model-specific or auxiliary windows. */
|
|
50
|
+
extraRateWindows?: NamedRateWindow[]
|
|
51
|
+
identity?: ProviderIdentity
|
|
52
|
+
dataConfidence: UsageDataConfidence
|
|
53
|
+
subscriptionExpiresAt?: string
|
|
54
|
+
subscriptionRenewsAt?: string
|
|
55
|
+
providerCost?: ProviderCostSnapshot
|
|
56
|
+
/** Absolute token totals when the source reports them (exact confidence). */
|
|
57
|
+
totals?: UsageTotals
|
|
58
|
+
updatedAt: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UsageTotals {
|
|
62
|
+
tokens: number
|
|
63
|
+
inputTokens?: number
|
|
64
|
+
outputTokens?: number
|
|
65
|
+
cachedInputTokens?: number
|
|
66
|
+
requests?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CreditEvent {
|
|
70
|
+
id: string
|
|
71
|
+
date: string
|
|
72
|
+
service: string
|
|
73
|
+
creditsUsed: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CreditsSnapshot {
|
|
77
|
+
remaining: number
|
|
78
|
+
unlimited?: boolean
|
|
79
|
+
events?: CreditEvent[]
|
|
80
|
+
updatedAt: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Generic spend/budget meter used by API providers. */
|
|
84
|
+
export interface ProviderCostSnapshot {
|
|
85
|
+
used: number
|
|
86
|
+
limit?: number
|
|
87
|
+
currencyCode: string
|
|
88
|
+
period?: string
|
|
89
|
+
resetsAt?: string
|
|
90
|
+
updatedAt: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Cost usage (offline JSONL session-log scanning, à la ccusage) ----
|
|
94
|
+
|
|
95
|
+
export interface ModelBreakdown {
|
|
96
|
+
modelName: string
|
|
97
|
+
costUSD: number
|
|
98
|
+
totalTokens: number
|
|
99
|
+
requestCount: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CostUsageDailyEntry {
|
|
103
|
+
/** "YYYY-MM-DD" */
|
|
104
|
+
date: string
|
|
105
|
+
inputTokens: number
|
|
106
|
+
cacheReadTokens: number
|
|
107
|
+
cacheCreationTokens: number
|
|
108
|
+
outputTokens: number
|
|
109
|
+
totalTokens: number
|
|
110
|
+
requestCount: number
|
|
111
|
+
costUSD: number
|
|
112
|
+
modelsUsed: string[]
|
|
113
|
+
modelBreakdowns?: ModelBreakdown[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CostUsageTokenSnapshot {
|
|
117
|
+
providerId: string
|
|
118
|
+
sessionTokens: number
|
|
119
|
+
sessionCostUSD: number
|
|
120
|
+
sessionRequests: number
|
|
121
|
+
last30DaysTokens: number
|
|
122
|
+
last30DaysCostUSD: number
|
|
123
|
+
last30DaysRequests: number
|
|
124
|
+
currencyCode: string
|
|
125
|
+
historyDays: number
|
|
126
|
+
daily: CostUsageDailyEntry[]
|
|
127
|
+
updatedAt: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- History (sparklines) — schema-compatible with PlanUtilizationHistoryStore v1 ----
|
|
131
|
+
|
|
132
|
+
export interface PlanUtilizationEntry {
|
|
133
|
+
capturedAt: string
|
|
134
|
+
usedPercent: number
|
|
135
|
+
resetsAt?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PlanUtilizationSeriesHistory {
|
|
139
|
+
/** "session" | "weekly" | named lane */
|
|
140
|
+
name: string
|
|
141
|
+
windowMinutes: number
|
|
142
|
+
entries: PlanUtilizationEntry[]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PlanUtilizationHistoryFile {
|
|
146
|
+
version: 1
|
|
147
|
+
preferredAccountKey?: string
|
|
148
|
+
unscoped: PlanUtilizationSeriesHistory[]
|
|
149
|
+
accounts?: Record<string, PlanUtilizationSeriesHistory[]>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- Provider status (statuspage.io etc.) ----
|
|
153
|
+
|
|
154
|
+
export type StatusIndicator = 'none' | 'minor' | 'major' | 'critical' | 'unknown'
|
|
155
|
+
|
|
156
|
+
export interface ProviderStatus {
|
|
157
|
+
indicator: StatusIndicator
|
|
158
|
+
description?: string
|
|
159
|
+
updatedAt: string
|
|
160
|
+
}
|