@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,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)
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|