@hanzo/usage 0.1.1 → 0.1.2

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,138 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Generic apiToken provider factory — TypeScript port of CodexBarCore's
3
+ // APITokenFetchStrategy. A provider whose usage/credits/balance is exposed
4
+ // behind a single pasteable API key becomes ~30 lines: describe how to build
5
+ // the request from settings, and how to map the JSON body into a snapshot.
6
+ //
7
+ // The factory owns everything mechanical — key resolution (settings.apiKey or
8
+ // env fallback), the HTTP call, status checking, JSON parsing, and stamping
9
+ // providerId/updatedAt — so each provider file only expresses what is unique
10
+ // to it: its endpoint, its auth header, and its response shape.
11
+
12
+ import type {
13
+ ProviderDescriptor,
14
+ ProviderFetchContext,
15
+ ProviderFetchResult,
16
+ ProviderFetchStrategy,
17
+ ProviderMetadata,
18
+ ProviderSettings,
19
+ } from '../provider.js'
20
+ import { forMode } from '../provider.js'
21
+ import type { CreditsSnapshot, UsageSnapshot } from '../types.js'
22
+ import type { HttpRequest } from '../host.js'
23
+
24
+ /** Everything a provider's `map` yields except the mechanical providerId/updatedAt. */
25
+ export type ApiTokenUsage = Omit<UsageSnapshot, 'providerId' | 'updatedAt'>
26
+
27
+ export interface ApiTokenResult {
28
+ usage: ApiTokenUsage
29
+ credits?: Omit<CreditsSnapshot, 'updatedAt'>
30
+ }
31
+
32
+ export interface ApiTokenProviderConfig {
33
+ id: string
34
+ metadata: ProviderMetadata
35
+ /** Source label surfaced to the UI (defaults to the display name). */
36
+ sourceLabel?: string
37
+ /** Env var names to fall back to when settings.apiKey is absent. */
38
+ envKeys?: string[]
39
+ /** Env var names that supply a base URL for self-hosted gateways. */
40
+ baseUrlEnv?: string[]
41
+ /** Require a resolvable base URL (self-hosted gateways) to be available. */
42
+ requireBaseUrl?: boolean
43
+ /** Build the HTTP request. `settings.apiKey` (and resolved `baseUrl`) are present. */
44
+ request: (settings: ProviderSettings, ctx: ProviderFetchContext) => HttpRequest
45
+ /** Map a decoded 200 body into a usage/credits result. */
46
+ map: (json: unknown, now: Date, settings: ProviderSettings) => ApiTokenResult
47
+ }
48
+
49
+ /** Resolve a base URL from explicit settings, then from host env fallbacks. */
50
+ export const resolveBaseUrl = (
51
+ ctx: ProviderFetchContext,
52
+ envKeys: readonly string[] = [],
53
+ ): string | undefined => {
54
+ const fromSettings = ctx.settings?.baseUrl
55
+ if (typeof fromSettings === 'string' && fromSettings.length > 0) return fromSettings
56
+ for (const name of envKeys) {
57
+ const value = ctx.host.env(name)
58
+ if (value) return value
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ /** Resolve an API key from explicit settings, then from host env fallbacks. */
64
+ export const resolveApiKey = (
65
+ ctx: ProviderFetchContext,
66
+ envKeys: readonly string[] = [],
67
+ ): string | undefined => {
68
+ const fromSettings = ctx.settings?.apiKey
69
+ if (typeof fromSettings === 'string' && fromSettings.length > 0) return fromSettings
70
+ for (const name of envKeys) {
71
+ const value = ctx.host.env(name)
72
+ if (value) return value
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ export const makeApiTokenStrategy = (config: ApiTokenProviderConfig): ProviderFetchStrategy => {
78
+ const label = config.sourceLabel ?? config.metadata.displayName
79
+ return {
80
+ id: `${config.id}.apiToken`,
81
+ kind: 'apiToken',
82
+ async isAvailable(ctx) {
83
+ if (!resolveApiKey(ctx, config.envKeys)) return false
84
+ if (config.requireBaseUrl && !resolveBaseUrl(ctx, config.baseUrlEnv)) return false
85
+ return true
86
+ },
87
+ async fetch(ctx): Promise<ProviderFetchResult> {
88
+ const apiKey = resolveApiKey(ctx, config.envKeys)
89
+ if (!apiKey) throw new Error(`${config.id}: no API key`)
90
+ const baseUrl = resolveBaseUrl(ctx, config.baseUrlEnv)
91
+ if (config.requireBaseUrl && !baseUrl) throw new Error(`${config.id}: no base URL`)
92
+ const settings: ProviderSettings = { ...ctx.settings, apiKey, ...(baseUrl ? { baseUrl } : {}) }
93
+ const req = config.request(settings, ctx)
94
+ const res = await ctx.host.http({ method: 'GET', timeoutMs: 30_000, ...req })
95
+ if (res.status !== 200) throw new Error(`${config.id} HTTP ${res.status}`)
96
+ let body: unknown
97
+ try {
98
+ body = JSON.parse(res.text)
99
+ } catch {
100
+ throw new Error(`${config.id}: invalid JSON body`)
101
+ }
102
+ const now = ctx.host.now()
103
+ const iso = now.toISOString()
104
+ const { usage, credits } = config.map(body, now, settings)
105
+ return {
106
+ usage: { ...usage, providerId: config.id, updatedAt: iso },
107
+ credits: credits ? { ...credits, updatedAt: iso } : undefined,
108
+ sourceLabel: label,
109
+ strategyId: `${config.id}.apiToken`,
110
+ strategyKind: 'apiToken',
111
+ }
112
+ },
113
+ // API-key providers have exactly one source; nothing to fall back to.
114
+ shouldFallback() {
115
+ return false
116
+ },
117
+ }
118
+ }
119
+
120
+ /** Build a single-strategy apiToken ProviderDescriptor. */
121
+ export const makeApiTokenProvider = (config: ApiTokenProviderConfig): ProviderDescriptor => {
122
+ const strategy = makeApiTokenStrategy(config)
123
+ return {
124
+ id: config.id,
125
+ metadata: config.metadata,
126
+ strategies: (mode) => forMode([strategy], mode),
127
+ }
128
+ }
129
+
130
+ /** Numeric coercion shared by mappers; non-finite/absent → undefined. */
131
+ export const numberOrUndefined = (v: unknown): number | undefined =>
132
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined
133
+
134
+ /** Bearer auth header helper. */
135
+ export const bearer = (key: string): Record<string, string> => ({
136
+ Authorization: `Bearer ${key}`,
137
+ Accept: 'application/json',
138
+ })
@@ -0,0 +1,132 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // BYO — bring-your-own OpenAI-compatible endpoint {baseUrl, apiKey}. This is the
3
+ // self-hosted / BYO-GPU tracking lane: point it at any gateway that speaks the
4
+ // OpenAI API (vLLM, LiteLLM, Hanzo Engine, a self-hosted cloud). It first tries a
5
+ // LiteLLM-style /key/info for real spend/budget; failing that it falls back to a
6
+ // /v1/models liveness probe and reports identity + reachability only
7
+ // (dataConfidence 'unknown').
8
+ //
9
+ // NOTE: BYO devices registered into Hanzo Cloud are metered natively by the cloud
10
+ // ledger (run-for-pay) regardless of what this local probe can read — this lane is
11
+ // for endpoints Hanzo does not itself operate.
12
+
13
+ import type {
14
+ ProviderDescriptor,
15
+ ProviderFetchContext,
16
+ ProviderFetchResult,
17
+ ProviderFetchStrategy,
18
+ } from '../provider.js'
19
+ import { forMode } from '../provider.js'
20
+ import type { UsageSnapshot } from '../types.js'
21
+ import { bearer, resolveApiKey, resolveBaseUrl } from './api-token.js'
22
+
23
+ const ENV_KEYS = ['BYO_API_KEY']
24
+ const ENV_URL = ['BYO_BASE_URL']
25
+
26
+ const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
27
+ const obj = (v: unknown): Record<string, unknown> =>
28
+ v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
29
+ const stripV1 = (base: string): string => base.replace(/\/v1\/?$/, '').replace(/\/$/, '')
30
+
31
+ const keyInfo = async (
32
+ ctx: ProviderFetchContext,
33
+ base: string,
34
+ key: string,
35
+ now: Date,
36
+ ): Promise<UsageSnapshot | undefined> => {
37
+ const res = await ctx.host.http({
38
+ url: `${stripV1(base)}/key/info`,
39
+ headers: bearer(key),
40
+ timeoutMs: 15_000,
41
+ })
42
+ if (res.status !== 200) return undefined
43
+ let info: Record<string, unknown>
44
+ try {
45
+ info = obj(obj(JSON.parse(res.text)).info)
46
+ } catch {
47
+ return undefined
48
+ }
49
+ if (Object.keys(info).length === 0) return undefined
50
+ const spend = num(info.spend)
51
+ return {
52
+ providerId: 'byo',
53
+ identity: {
54
+ providerId: 'byo',
55
+ loginMethod: 'api',
56
+ accountEmail: typeof info.user_id === 'string' ? info.user_id : undefined,
57
+ accountOrganization: typeof info.team_id === 'string' ? info.team_id : undefined,
58
+ plan: typeof info.key_name === 'string' ? info.key_name : undefined,
59
+ },
60
+ providerCost: { used: spend, currencyCode: 'USD', period: 'key', updatedAt: now.toISOString() },
61
+ subscriptionExpiresAt: typeof info.expires === 'string' ? info.expires : undefined,
62
+ dataConfidence: 'exact',
63
+ updatedAt: now.toISOString(),
64
+ }
65
+ }
66
+
67
+ const modelsLiveness = async (
68
+ ctx: ProviderFetchContext,
69
+ base: string,
70
+ key: string,
71
+ now: Date,
72
+ ): Promise<UsageSnapshot> => {
73
+ const root = stripV1(base)
74
+ const res = await ctx.host.http({
75
+ url: `${root}/v1/models`,
76
+ headers: bearer(key),
77
+ timeoutMs: 15_000,
78
+ })
79
+ if (res.status !== 200) throw new Error(`byo: /v1/models HTTP ${res.status}`)
80
+ let count: number | undefined
81
+ try {
82
+ const data = obj(JSON.parse(res.text)).data
83
+ if (Array.isArray(data)) count = data.length
84
+ } catch {
85
+ // liveness only — a 200 is enough even if the body is not the expected shape
86
+ }
87
+ return {
88
+ providerId: 'byo',
89
+ identity: {
90
+ providerId: 'byo',
91
+ loginMethod: 'api',
92
+ accountOrganization: count !== undefined ? `${count} models` : undefined,
93
+ },
94
+ dataConfidence: 'unknown',
95
+ updatedAt: now.toISOString(),
96
+ }
97
+ }
98
+
99
+ const byoStrategy: ProviderFetchStrategy = {
100
+ id: 'byo.apiToken',
101
+ kind: 'apiToken',
102
+ async isAvailable(ctx) {
103
+ return Boolean(resolveApiKey(ctx, ENV_KEYS) && resolveBaseUrl(ctx, ENV_URL))
104
+ },
105
+ async fetch(ctx): Promise<ProviderFetchResult> {
106
+ const key = resolveApiKey(ctx, ENV_KEYS)
107
+ const base = resolveBaseUrl(ctx, ENV_URL)
108
+ if (!key || !base) throw new Error('byo: baseUrl and apiKey are required')
109
+ const now = ctx.host.now()
110
+ const info = await keyInfo(ctx, base, key, now)
111
+ if (info) {
112
+ return { usage: info, sourceLabel: 'BYO /key/info', strategyId: this.id, strategyKind: this.kind }
113
+ }
114
+ const usage = await modelsLiveness(ctx, base, key, now)
115
+ return { usage, sourceLabel: 'BYO liveness', strategyId: this.id, strategyKind: this.kind }
116
+ },
117
+ shouldFallback() {
118
+ return false
119
+ },
120
+ }
121
+
122
+ export const byoProvider: ProviderDescriptor = {
123
+ id: 'byo',
124
+ metadata: {
125
+ displayName: 'BYO endpoint',
126
+ sessionLabel: 'Key spend',
127
+ weeklyLabel: 'Liveness',
128
+ dashboardUrl: 'https://docs.hanzo.ai/docs/gateway',
129
+ color: '#64748b',
130
+ },
131
+ strategies: (mode) => forMode([byoStrategy], mode),
132
+ }
@@ -0,0 +1,100 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Deepgram provider — port of CodexBarCore/Providers/Deepgram. Deepgram exposes a
3
+ // usage-breakdown API (audio hours, tokens, requests), no balance/quota. When no
4
+ // project is configured we enumerate projects and aggregate. Auth is the Deepgram
5
+ // `Token <key>` scheme (NOT Bearer).
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 { UsageSnapshot } from '../types.js'
15
+ import { resolveApiKey } from './api-token.js'
16
+
17
+ const ENV_KEYS = ['DEEPGRAM_API_KEY']
18
+ const ENV_URL = 'DEEPGRAM_API_URL'
19
+ const ENV_PROJECT = 'DEEPGRAM_PROJECT_ID'
20
+ const DEFAULT_BASE = 'https://api.deepgram.com/v1'
21
+
22
+ interface UsageResult {
23
+ requests?: number
24
+ total_hours?: number
25
+ tokens_in?: number
26
+ tokens_out?: number
27
+ }
28
+
29
+ const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
30
+
31
+ const deepgramStrategy: ProviderFetchStrategy = {
32
+ id: 'deepgram.apiToken',
33
+ kind: 'apiToken',
34
+ async isAvailable(ctx) {
35
+ return Boolean(resolveApiKey(ctx, ENV_KEYS))
36
+ },
37
+ async fetch(ctx: ProviderFetchContext): Promise<ProviderFetchResult> {
38
+ const key = resolveApiKey(ctx, ENV_KEYS)
39
+ if (!key) throw new Error('deepgram: no API key')
40
+ const base = ctx.settings?.baseUrl ?? ctx.host.env(ENV_URL) ?? DEFAULT_BASE
41
+ const headers = { Authorization: `Token ${key}`, Accept: 'application/json' }
42
+ const get = async (path: string): Promise<Record<string, unknown>> => {
43
+ const res = await ctx.host.http({ url: `${base}${path}`, headers, timeoutMs: 30_000 })
44
+ if (res.status !== 200) throw new Error(`deepgram ${path} HTTP ${res.status}`)
45
+ return JSON.parse(res.text) as Record<string, unknown>
46
+ }
47
+ const configured =
48
+ (typeof ctx.settings?.projectId === 'string' ? ctx.settings.projectId : undefined) ??
49
+ ctx.host.env(ENV_PROJECT)
50
+ let projectIds: string[]
51
+ if (configured) {
52
+ projectIds = [configured]
53
+ } else {
54
+ const body = await get('/projects')
55
+ const projects = Array.isArray(body.projects) ? body.projects : []
56
+ projectIds = projects
57
+ .map((p) => (p as { project_id?: string }).project_id)
58
+ .filter((id): id is string => Boolean(id))
59
+ }
60
+ let hours = 0
61
+ let tokens = 0
62
+ let requests = 0
63
+ for (const id of projectIds) {
64
+ const body = await get(`/projects/${id}/usage/breakdown`)
65
+ for (const r of (Array.isArray(body.results) ? body.results : []) as UsageResult[]) {
66
+ hours += num(r.total_hours)
67
+ tokens += num(r.tokens_in) + num(r.tokens_out)
68
+ requests += num(r.requests)
69
+ }
70
+ }
71
+ const now = ctx.host.now().toISOString()
72
+ const usage: UsageSnapshot = {
73
+ providerId: 'deepgram',
74
+ identity: {
75
+ providerId: 'deepgram',
76
+ loginMethod: 'api',
77
+ accountOrganization: projectIds.length === 1 ? projectIds[0] : `${projectIds.length} projects`,
78
+ },
79
+ totals: { tokens, requests },
80
+ dataConfidence: tokens > 0 || requests > 0 || hours > 0 ? 'exact' : 'unknown',
81
+ updatedAt: now,
82
+ }
83
+ return { usage, sourceLabel: 'Deepgram usage', strategyId: this.id, strategyKind: this.kind }
84
+ },
85
+ shouldFallback() {
86
+ return false
87
+ },
88
+ }
89
+
90
+ export const deepgramProvider: ProviderDescriptor = {
91
+ id: 'deepgram',
92
+ metadata: {
93
+ displayName: 'Deepgram',
94
+ sessionLabel: 'Usage',
95
+ weeklyLabel: 'Requests',
96
+ dashboardUrl: 'https://console.deepgram.com/usage',
97
+ color: '#13ef93',
98
+ },
99
+ strategies: (mode) => forMode([deepgramStrategy], mode),
100
+ }
@@ -0,0 +1,97 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Groq provider — port of CodexBarCore/Providers/Groq. Groq exposes NO balance or
3
+ // quota; its only usage signal is an enterprise Prometheus metrics API reporting
4
+ // current throughput RATES. We faithfully report those (requests/min, tokens/min)
5
+ // as an honest throughput snapshot — not a cumulative total (Groq gives no total).
6
+ // Four parallel PromQL queries against {base}/metrics/prometheus/api/v1/query.
7
+
8
+ import type {
9
+ ProviderDescriptor,
10
+ ProviderFetchContext,
11
+ ProviderFetchResult,
12
+ ProviderFetchStrategy,
13
+ } from '../provider.js'
14
+ import { forMode } from '../provider.js'
15
+ import type { UsageSnapshot } from '../types.js'
16
+ import { bearer, resolveApiKey } from './api-token.js'
17
+
18
+ const ENV_KEYS = ['GROQ_API_KEY']
19
+ const ENV_URL = 'GROQ_API_URL'
20
+ const DEFAULT_BASE = 'https://api.groq.com/v1'
21
+
22
+ const QUERIES = {
23
+ requests: 'sum(model_project_id_status_code:requests:rate5m)',
24
+ tokensIn: 'sum(model_project_id:tokens_in:rate5m)',
25
+ tokensOut: 'sum(model_project_id:tokens_out:rate5m)',
26
+ } as const
27
+
28
+ interface PrometheusResponse {
29
+ status?: string
30
+ data?: { result?: Array<{ value?: [number, string | number] }> }
31
+ }
32
+
33
+ /** Sum the scalar value across every returned Prometheus series (per-second rate). */
34
+ const sumRate = (body: PrometheusResponse): number => {
35
+ if (body.status !== 'success') return 0
36
+ let total = 0
37
+ for (const series of body.data?.result ?? []) {
38
+ const raw = series.value?.[1]
39
+ const n = typeof raw === 'number' ? raw : Number(raw)
40
+ if (Number.isFinite(n)) total += n
41
+ }
42
+ return total
43
+ }
44
+
45
+ const groqStrategy: ProviderFetchStrategy = {
46
+ id: 'groq.apiToken',
47
+ kind: 'apiToken',
48
+ async isAvailable(ctx) {
49
+ return Boolean(resolveApiKey(ctx, ENV_KEYS))
50
+ },
51
+ async fetch(ctx: ProviderFetchContext): Promise<ProviderFetchResult> {
52
+ const key = resolveApiKey(ctx, ENV_KEYS)
53
+ if (!key) throw new Error('groq: no API key')
54
+ const base = ctx.settings?.baseUrl ?? ctx.host.env(ENV_URL) ?? DEFAULT_BASE
55
+ const query = async (promql: string): Promise<PrometheusResponse> => {
56
+ const res = await ctx.host.http({
57
+ url: `${base}/metrics/prometheus/api/v1/query?query=${encodeURIComponent(promql)}`,
58
+ headers: bearer(key),
59
+ timeoutMs: 30_000,
60
+ })
61
+ if (res.status !== 200) throw new Error(`groq metrics HTTP ${res.status}`)
62
+ return JSON.parse(res.text) as PrometheusResponse
63
+ }
64
+ const [requests, tokensIn, tokensOut] = await Promise.all([
65
+ query(QUERIES.requests),
66
+ query(QUERIES.tokensIn),
67
+ query(QUERIES.tokensOut),
68
+ ])
69
+ // Per-second rates → per-minute throughput (Groq exposes no cumulative total).
70
+ const requestsPerMin = Math.round(sumRate(requests) * 60)
71
+ const tokensPerMin = Math.round((sumRate(tokensIn) + sumRate(tokensOut)) * 60)
72
+ const now = ctx.host.now().toISOString()
73
+ const usage: UsageSnapshot = {
74
+ providerId: 'groq',
75
+ identity: { providerId: 'groq', loginMethod: 'api' },
76
+ totals: { tokens: tokensPerMin, requests: requestsPerMin },
77
+ dataConfidence: 'estimated',
78
+ updatedAt: now,
79
+ }
80
+ return { usage, sourceLabel: 'Groq metrics', strategyId: this.id, strategyKind: this.kind }
81
+ },
82
+ shouldFallback() {
83
+ return false
84
+ },
85
+ }
86
+
87
+ export const groqProvider: ProviderDescriptor = {
88
+ id: 'groq',
89
+ metadata: {
90
+ displayName: 'Groq',
91
+ sessionLabel: 'Throughput (req/min)',
92
+ weeklyLabel: 'Tokens/min',
93
+ dashboardUrl: 'https://console.groq.com/metrics',
94
+ color: '#f55036',
95
+ },
96
+ strategies: (mode) => forMode([groqStrategy], mode),
97
+ }