@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,77 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Tauri host — used by hanzo desktop and the hanzo app launcher. Requires the
3
+ // fs + http plugins with scope covering the provider config paths
4
+ // (~/.codex, ~/.claude, ~/.hanzo) and the provider API domains.
5
+
6
+ import type { UsageHost } from '../host.js'
7
+
8
+ type TauriFs = {
9
+ readTextFile(path: string): Promise<string>
10
+ readDir(path: string): Promise<Array<{ name: string }>>
11
+ writeTextFile(path: string, contents: string): Promise<void>
12
+ mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>
13
+ }
14
+
15
+ /**
16
+ * Build a UsageHost from Tauri plugin modules. Modules are passed in (not
17
+ * imported) so this file stays dependency-free for web/Node bundles:
18
+ *
19
+ * import * as fs from '@tauri-apps/plugin-fs'
20
+ * import { fetch } from '@tauri-apps/plugin-http'
21
+ * import { homeDir } from '@tauri-apps/api/path'
22
+ * const host = await createTauriHost({ fs, fetch, homeDir })
23
+ */
24
+ export const createTauriHost = async (deps: {
25
+ fs: TauriFs
26
+ fetch: typeof fetch
27
+ homeDir: () => Promise<string>
28
+ }): Promise<UsageHost> => {
29
+ const home = (await deps.homeDir()).replace(/\/$/, '')
30
+ return {
31
+ async readTextFile(path) {
32
+ try {
33
+ return await deps.fs.readTextFile(path)
34
+ } catch {
35
+ return undefined
36
+ }
37
+ },
38
+ async listDir(path) {
39
+ try {
40
+ return (await deps.fs.readDir(path)).map((e) => e.name)
41
+ } catch {
42
+ return []
43
+ }
44
+ },
45
+ async writeTextFile(path, contents) {
46
+ const dir = path.slice(0, path.lastIndexOf('/'))
47
+ try {
48
+ await deps.fs.mkdir(dir, { recursive: true })
49
+ } catch {
50
+ // exists
51
+ }
52
+ await deps.fs.writeTextFile(path, contents)
53
+ },
54
+ async http(req) {
55
+ const controller = new AbortController()
56
+ const timeout = setTimeout(() => controller.abort(), req.timeoutMs ?? 30_000)
57
+ try {
58
+ const res = await deps.fetch(req.url, {
59
+ method: req.method ?? 'GET',
60
+ headers: req.headers,
61
+ body: req.body,
62
+ signal: controller.signal,
63
+ })
64
+ const headers: Record<string, string> = {}
65
+ res.headers.forEach((v, k) => {
66
+ headers[k] = v
67
+ })
68
+ return { status: res.status, headers, text: await res.text() }
69
+ } finally {
70
+ clearTimeout(timeout)
71
+ }
72
+ },
73
+ env: () => undefined,
74
+ homeDir: () => home,
75
+ now: () => new Date(),
76
+ }
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ export * from './types.js'
3
+ export * from './host.js'
4
+ export * from './provider.js'
5
+ export * from './store.js'
6
+ export { codexProvider } from './providers/codex.js'
7
+ export { claudeProvider } from './providers/claude.js'
8
+ export { hanzoProvider } from './providers/hanzo.js'
9
+
10
+ import type { ProviderDescriptor } from './provider.js'
11
+ import { codexProvider } from './providers/codex.js'
12
+ import { claudeProvider } from './providers/claude.js'
13
+ import { hanzoProvider } from './providers/hanzo.js'
14
+
15
+ /** All built-in providers, registry-style like CodexBar's descriptor registry. */
16
+ export const providerRegistry: Record<string, ProviderDescriptor> = {
17
+ hanzo: hanzoProvider,
18
+ codex: codexProvider,
19
+ claude: claudeProvider,
20
+ }
21
+
22
+ export const allProviders: ProviderDescriptor[] = Object.values(providerRegistry)
@@ -0,0 +1,125 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Provider abstraction — TypeScript port of CodexBarCore's ProviderDescriptor /
3
+ // ProviderFetchPlan / ProviderFetchPipeline (priority-ordered strategies with
4
+ // availability checks and fallback).
5
+
6
+ import type { CreditsSnapshot, UsageSnapshot } from './types.js'
7
+ import type { UsageHost } from './host.js'
8
+
9
+ export type ProviderSourceMode = 'auto' | 'web' | 'cli' | 'oauth' | 'api'
10
+ export type ProviderFetchKind = 'cli' | 'web' | 'oauth' | 'apiToken' | 'localProbe'
11
+
12
+ export interface ProviderSettings {
13
+ /** API key / secret for apiToken strategies. */
14
+ apiKey?: string
15
+ /** Base URL override (e.g. self-hosted gateway). */
16
+ baseUrl?: string
17
+ /** Bearer-token resolver for cloud (IAM) strategies. */
18
+ getToken?: () => Promise<string | undefined>
19
+ /** Extra provider-specific settings. */
20
+ [key: string]: unknown
21
+ }
22
+
23
+ export interface ProviderFetchContext {
24
+ host: UsageHost
25
+ sourceMode: ProviderSourceMode
26
+ settings?: ProviderSettings
27
+ verbose?: boolean
28
+ }
29
+
30
+ export interface ProviderFetchResult {
31
+ usage: UsageSnapshot
32
+ credits?: CreditsSnapshot
33
+ /** Human-readable label of the source that produced the data. */
34
+ sourceLabel: string
35
+ strategyId: string
36
+ strategyKind: ProviderFetchKind
37
+ }
38
+
39
+ export interface ProviderFetchStrategy {
40
+ /** e.g. "codex.oauth", "claude.web" */
41
+ id: string
42
+ kind: ProviderFetchKind
43
+ isAvailable(ctx: ProviderFetchContext): Promise<boolean>
44
+ fetch(ctx: ProviderFetchContext): Promise<ProviderFetchResult>
45
+ /** Whether the pipeline should try the next strategy after this error. */
46
+ shouldFallback?(error: unknown, ctx: ProviderFetchContext): boolean
47
+ }
48
+
49
+ export interface ProviderFetchAttempt {
50
+ strategyId: string
51
+ ok: boolean
52
+ error?: string
53
+ skipped?: boolean
54
+ }
55
+
56
+ export interface ProviderFetchOutcome {
57
+ result?: ProviderFetchResult
58
+ error?: unknown
59
+ attempts: ProviderFetchAttempt[]
60
+ }
61
+
62
+ export interface ProviderMetadata {
63
+ displayName: string
64
+ /** Label for the primary lane, e.g. "5h limit" / "Session". */
65
+ sessionLabel: string
66
+ /** Label for the secondary lane, e.g. "Weekly limit". */
67
+ weeklyLabel: string
68
+ supportsCredits?: boolean
69
+ defaultEnabled?: boolean
70
+ dashboardUrl?: string
71
+ statusPageUrl?: string
72
+ /** Brand accent as CSS color. */
73
+ color?: string
74
+ }
75
+
76
+ export interface ProviderDescriptor {
77
+ id: string
78
+ metadata: ProviderMetadata
79
+ /** Strategies in priority order for a given source mode. */
80
+ strategies(mode: ProviderSourceMode): ProviderFetchStrategy[]
81
+ }
82
+
83
+ const matchesMode = (kind: ProviderFetchKind, mode: ProviderSourceMode): boolean => {
84
+ if (mode === 'auto') return true
85
+ if (mode === 'web') return kind === 'web'
86
+ if (mode === 'cli') return kind === 'cli' || kind === 'localProbe'
87
+ if (mode === 'oauth') return kind === 'oauth'
88
+ return kind === 'apiToken'
89
+ }
90
+
91
+ /** Filter a full priority list down to the requested source mode. */
92
+ export const forMode = (
93
+ all: ProviderFetchStrategy[],
94
+ mode: ProviderSourceMode,
95
+ ): ProviderFetchStrategy[] => all.filter((s) => matchesMode(s.kind, mode))
96
+
97
+ /** Run strategies in priority order with availability checks and fallback. */
98
+ export const runPipeline = async (
99
+ descriptor: ProviderDescriptor,
100
+ ctx: ProviderFetchContext,
101
+ ): Promise<ProviderFetchOutcome> => {
102
+ const attempts: ProviderFetchAttempt[] = []
103
+ let lastError: unknown
104
+ for (const strategy of descriptor.strategies(ctx.sourceMode)) {
105
+ if (!(await strategy.isAvailable(ctx))) {
106
+ attempts.push({ strategyId: strategy.id, ok: false, skipped: true })
107
+ continue
108
+ }
109
+ try {
110
+ const result = await strategy.fetch(ctx)
111
+ attempts.push({ strategyId: strategy.id, ok: true })
112
+ return { result, attempts }
113
+ } catch (error) {
114
+ attempts.push({
115
+ strategyId: strategy.id,
116
+ ok: false,
117
+ error: error instanceof Error ? error.message : String(error),
118
+ })
119
+ lastError = error
120
+ const fallback = strategy.shouldFallback?.(error, ctx) ?? true
121
+ if (!fallback) break
122
+ }
123
+ }
124
+ return { error: lastError ?? new Error(`${descriptor.id}: no strategy available`), attempts }
125
+ }
@@ -0,0 +1,178 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Claude (Anthropic) provider — port of CodexBarCore/Providers/Claude.
3
+ // OAuth strategy reads Claude Code credentials (~/.claude/.credentials.json)
4
+ // and calls the Anthropic OAuth usage endpoint; web strategy uses a claude.ai
5
+ // sessionKey cookie supplied via settings.
6
+
7
+ import type {
8
+ ProviderDescriptor,
9
+ ProviderFetchContext,
10
+ ProviderFetchResult,
11
+ ProviderFetchStrategy,
12
+ } from '../provider.js'
13
+ import { forMode } from '../provider.js'
14
+ import type { NamedRateWindow, RateWindow, UsageSnapshot } from '../types.js'
15
+ import { readJsonFile } from '../host.js'
16
+
17
+ interface ClaudeCredentialsFile {
18
+ claudeAiOauth?: {
19
+ accessToken?: string
20
+ expiresAt?: number
21
+ subscriptionType?: string
22
+ }
23
+ }
24
+
25
+ interface ClaudeUsageWindow {
26
+ utilization?: number
27
+ used_percent?: number
28
+ resets_at?: string
29
+ }
30
+
31
+ interface ClaudeUsageResponse {
32
+ five_hour?: ClaudeUsageWindow | null
33
+ seven_day?: ClaudeUsageWindow | null
34
+ seven_day_sonnet?: ClaudeUsageWindow | null
35
+ seven_day_opus?: ClaudeUsageWindow | null
36
+ extra_usage?: { used_cents?: number; limit_cents?: number } | null
37
+ rate_limit_tier?: string
38
+ subscriptionType?: string
39
+ }
40
+
41
+ const WINDOW_MINUTES: Record<string, number> = {
42
+ five_hour: 300,
43
+ seven_day: 10_080,
44
+ seven_day_sonnet: 10_080,
45
+ seven_day_opus: 10_080,
46
+ }
47
+
48
+ const toWindow = (key: string, w: ClaudeUsageWindow | null | undefined): RateWindow | undefined => {
49
+ const used = w?.utilization ?? w?.used_percent
50
+ if (typeof used !== 'number') return undefined
51
+ return { usedPercent: used, windowMinutes: WINDOW_MINUTES[key], resetsAt: w?.resets_at }
52
+ }
53
+
54
+ const mapUsage = (
55
+ body: ClaudeUsageResponse,
56
+ now: string,
57
+ loginMethod: string,
58
+ plan?: string,
59
+ ): UsageSnapshot => {
60
+ const extras: NamedRateWindow[] = []
61
+ for (const [key, title] of [
62
+ ['seven_day_sonnet', 'Sonnet weekly'],
63
+ ['seven_day_opus', 'Opus weekly'],
64
+ ] as const) {
65
+ const window = toWindow(key, body[key])
66
+ if (window) extras.push({ id: key, title, window, usageKnown: true })
67
+ }
68
+ return {
69
+ providerId: 'claude',
70
+ primary: toWindow('five_hour', body.five_hour),
71
+ secondary: toWindow('seven_day', body.seven_day),
72
+ extraRateWindows: extras.length ? extras : undefined,
73
+ identity: {
74
+ providerId: 'claude',
75
+ plan: plan ?? body.subscriptionType ?? body.rate_limit_tier,
76
+ loginMethod,
77
+ },
78
+ providerCost:
79
+ body.extra_usage && typeof body.extra_usage.used_cents === 'number'
80
+ ? {
81
+ used: body.extra_usage.used_cents / 100,
82
+ limit:
83
+ typeof body.extra_usage.limit_cents === 'number'
84
+ ? body.extra_usage.limit_cents / 100
85
+ : undefined,
86
+ currencyCode: 'USD',
87
+ period: 'monthly',
88
+ updatedAt: now,
89
+ }
90
+ : undefined,
91
+ dataConfidence: 'percentOnly',
92
+ updatedAt: now,
93
+ }
94
+ }
95
+
96
+ const credentialPaths = ['~/.claude/.credentials.json', '~/.config/claude/.credentials.json']
97
+
98
+ const readCredentials = async (ctx: ProviderFetchContext) => {
99
+ for (const path of credentialPaths) {
100
+ const file = await readJsonFile<ClaudeCredentialsFile>(ctx.host, path)
101
+ if (file?.claudeAiOauth?.accessToken) return file.claudeAiOauth
102
+ }
103
+ return undefined
104
+ }
105
+
106
+ const oauthStrategy: ProviderFetchStrategy = {
107
+ id: 'claude.oauth',
108
+ kind: 'oauth',
109
+ async isAvailable(ctx) {
110
+ return Boolean(await readCredentials(ctx))
111
+ },
112
+ async fetch(ctx): Promise<ProviderFetchResult> {
113
+ const creds = await readCredentials(ctx)
114
+ if (!creds?.accessToken) throw new Error('claude: no OAuth credentials')
115
+ const res = await ctx.host.http({
116
+ url: 'https://api.anthropic.com/api/oauth/usage',
117
+ headers: {
118
+ Authorization: `Bearer ${creds.accessToken}`,
119
+ 'anthropic-beta': 'oauth-2025-04-20',
120
+ Accept: 'application/json',
121
+ },
122
+ timeoutMs: 30_000,
123
+ })
124
+ if (res.status !== 200) throw new Error(`claude usage HTTP ${res.status}`)
125
+ const body = JSON.parse(res.text) as ClaudeUsageResponse
126
+ return {
127
+ usage: mapUsage(body, ctx.host.now().toISOString(), 'oauth', creds.subscriptionType),
128
+ sourceLabel: 'Claude OAuth',
129
+ strategyId: this.id,
130
+ strategyKind: this.kind,
131
+ }
132
+ },
133
+ }
134
+
135
+ /** Web strategy: settings.cookieHeader must hold `sessionKey=sk-ant-…`. */
136
+ const webStrategy: ProviderFetchStrategy = {
137
+ id: 'claude.web',
138
+ kind: 'web',
139
+ async isAvailable(ctx) {
140
+ return typeof ctx.settings?.cookieHeader === 'string'
141
+ },
142
+ async fetch(ctx): Promise<ProviderFetchResult> {
143
+ const cookie = String(ctx.settings?.cookieHeader)
144
+ const get = async (path: string) => {
145
+ const res = await ctx.host.http({
146
+ url: `https://claude.ai/api${path}`,
147
+ headers: { Cookie: cookie, Accept: 'application/json' },
148
+ timeoutMs: 30_000,
149
+ })
150
+ if (res.status !== 200) throw new Error(`claude.ai${path} HTTP ${res.status}`)
151
+ return JSON.parse(res.text)
152
+ }
153
+ const orgs = (await get('/organizations')) as Array<{ uuid: string }>
154
+ const orgId = orgs[0]?.uuid
155
+ if (!orgId) throw new Error('claude: no organization for session')
156
+ const body = (await get(`/organizations/${orgId}/usage`)) as ClaudeUsageResponse
157
+ return {
158
+ usage: mapUsage(body, ctx.host.now().toISOString(), 'web'),
159
+ sourceLabel: 'claude.ai session',
160
+ strategyId: this.id,
161
+ strategyKind: this.kind,
162
+ }
163
+ },
164
+ }
165
+
166
+ export const claudeProvider: ProviderDescriptor = {
167
+ id: 'claude',
168
+ metadata: {
169
+ displayName: 'Claude',
170
+ sessionLabel: 'Session (5h)',
171
+ weeklyLabel: 'Weekly limit',
172
+ defaultEnabled: true,
173
+ dashboardUrl: 'https://claude.ai/settings/usage',
174
+ statusPageUrl: 'https://status.anthropic.com',
175
+ color: '#d97757',
176
+ },
177
+ strategies: (mode) => forMode([oauthStrategy, webStrategy], mode),
178
+ }
@@ -0,0 +1,126 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Codex (OpenAI) provider — port of CodexBarCore/Providers/Codex (OAuth strategy).
3
+ // Reads CLI credentials from $CODEX_HOME/auth.json (default ~/.codex/auth.json)
4
+ // and calls the ChatGPT backend usage endpoint.
5
+
6
+ import type {
7
+ ProviderDescriptor,
8
+ ProviderFetchContext,
9
+ ProviderFetchResult,
10
+ ProviderFetchStrategy,
11
+ } from '../provider.js'
12
+ import { forMode } from '../provider.js'
13
+ import type { CreditsSnapshot, RateWindow, UsageSnapshot } from '../types.js'
14
+ import { expandHome, readJsonFile } from '../host.js'
15
+
16
+ interface CodexAuthFile {
17
+ tokens?: {
18
+ access_token?: string
19
+ account_id?: string
20
+ }
21
+ OPENAI_API_KEY?: string
22
+ }
23
+
24
+ interface CodexWindow {
25
+ used_percent?: number
26
+ reset_at?: number
27
+ limit_window_seconds?: number
28
+ }
29
+
30
+ interface CodexUsageResponse {
31
+ plan_type?: string
32
+ rate_limit?: {
33
+ primary_window?: CodexWindow
34
+ secondary_window?: CodexWindow
35
+ }
36
+ credits?: { has_credits?: boolean; unlimited?: boolean; balance?: number }
37
+ additional_rate_limits?: Array<{
38
+ limit_name?: string
39
+ rate_limit?: { primary_window?: CodexWindow }
40
+ }>
41
+ }
42
+
43
+ const codexHome = (ctx: ProviderFetchContext): string =>
44
+ ctx.host.env('CODEX_HOME') ?? expandHome(ctx.host, '~/.codex')
45
+
46
+ const toWindow = (w: CodexWindow | undefined): RateWindow | undefined => {
47
+ if (!w || typeof w.used_percent !== 'number') return undefined
48
+ return {
49
+ usedPercent: w.used_percent,
50
+ windowMinutes: w.limit_window_seconds ? Math.round(w.limit_window_seconds / 60) : undefined,
51
+ resetsAt: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : undefined,
52
+ }
53
+ }
54
+
55
+ const oauthStrategy: ProviderFetchStrategy = {
56
+ id: 'codex.oauth',
57
+ kind: 'oauth',
58
+ async isAvailable(ctx) {
59
+ const auth = await readJsonFile<CodexAuthFile>(ctx.host, `${codexHome(ctx)}/auth.json`)
60
+ return Boolean(auth?.tokens?.access_token)
61
+ },
62
+ async fetch(ctx): Promise<ProviderFetchResult> {
63
+ const auth = await readJsonFile<CodexAuthFile>(ctx.host, `${codexHome(ctx)}/auth.json`)
64
+ const token = auth?.tokens?.access_token
65
+ if (!token) throw new Error('codex: no access token in auth.json')
66
+ const headers: Record<string, string> = {
67
+ Authorization: `Bearer ${token}`,
68
+ Accept: 'application/json',
69
+ 'User-Agent': 'HanzoUsage',
70
+ }
71
+ if (auth?.tokens?.account_id) headers['ChatGPT-Account-Id'] = auth.tokens.account_id
72
+ const res = await ctx.host.http({
73
+ url: 'https://chatgpt.com/backend-api/wham/usage',
74
+ headers,
75
+ timeoutMs: 30_000,
76
+ })
77
+ if (res.status !== 200) throw new Error(`codex usage HTTP ${res.status}`)
78
+ const body = JSON.parse(res.text) as CodexUsageResponse
79
+ const now = ctx.host.now().toISOString()
80
+ const usage: UsageSnapshot = {
81
+ providerId: 'codex',
82
+ primary: toWindow(body.rate_limit?.primary_window),
83
+ secondary: toWindow(body.rate_limit?.secondary_window),
84
+ extraRateWindows: (body.additional_rate_limits ?? [])
85
+ .map((extra) => {
86
+ const window = toWindow(extra.rate_limit?.primary_window)
87
+ if (!window || !extra.limit_name) return undefined
88
+ return { id: extra.limit_name, title: extra.limit_name, window, usageKnown: true }
89
+ })
90
+ .filter((w): w is NonNullable<typeof w> => Boolean(w)),
91
+ identity: { providerId: 'codex', plan: body.plan_type, loginMethod: 'oauth' },
92
+ dataConfidence: 'percentOnly',
93
+ updatedAt: now,
94
+ }
95
+ let credits: CreditsSnapshot | undefined
96
+ if (body.credits?.has_credits) {
97
+ credits = {
98
+ remaining: body.credits.balance ?? 0,
99
+ unlimited: body.credits.unlimited,
100
+ updatedAt: now,
101
+ }
102
+ }
103
+ return {
104
+ usage,
105
+ credits,
106
+ sourceLabel: 'OpenAI OAuth',
107
+ strategyId: this.id,
108
+ strategyKind: this.kind,
109
+ }
110
+ },
111
+ }
112
+
113
+ export const codexProvider: ProviderDescriptor = {
114
+ id: 'codex',
115
+ metadata: {
116
+ displayName: 'Codex',
117
+ sessionLabel: '5h limit',
118
+ weeklyLabel: 'Weekly limit',
119
+ supportsCredits: true,
120
+ defaultEnabled: true,
121
+ dashboardUrl: 'https://chatgpt.com/codex/settings/usage',
122
+ statusPageUrl: 'https://status.openai.com',
123
+ color: '#10a37f',
124
+ },
125
+ strategies: (mode) => forMode([oauthStrategy], mode),
126
+ }