@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.
@@ -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
+ }