@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,168 @@
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
+ import { forMode } from '../provider.js';
10
+ import { expandHome } from '../host.js';
11
+ // ---- cloud (commerce ledger) ----
12
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
13
+ const cloudStrategy = {
14
+ id: 'hanzo.cloud',
15
+ kind: 'apiToken',
16
+ async isAvailable(ctx) {
17
+ return Boolean(ctx.settings?.getToken || ctx.settings?.apiKey);
18
+ },
19
+ async fetch(ctx) {
20
+ const base = ctx.settings?.baseUrl ?? 'https://api.hanzo.ai';
21
+ const token = (await ctx.settings?.getToken?.()) ?? ctx.settings?.apiKey;
22
+ if (!token)
23
+ throw new Error('hanzo: no token for cloud usage');
24
+ const res = await ctx.host.http({
25
+ url: `${base}/v1/billing/usage`,
26
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
27
+ timeoutMs: 30_000,
28
+ });
29
+ if (res.status !== 200)
30
+ throw new Error(`hanzo billing usage HTTP ${res.status}`);
31
+ const body = JSON.parse(res.text);
32
+ // Defensive roll-up: accept either precomputed totals or a ledger of entries.
33
+ const entries = Array.isArray(body.usage)
34
+ ? body.usage
35
+ : Array.isArray(body.data)
36
+ ? body.data
37
+ : [];
38
+ let tokens = num(body.total_tokens ?? body.totalTokens);
39
+ let spendCents = num(body.spend_cents ?? body.spendCents);
40
+ let requests = num(body.total_requests ?? body.requests);
41
+ for (const e of entries) {
42
+ tokens += num(e.total_tokens ?? e.totalTokens ?? e.tokens);
43
+ spendCents += num(e.spend_cents ?? e.spendCents ?? e.cost_cents);
44
+ requests += num(e.requests ?? e.request_count ?? 1) - (e.requests === undefined && e.request_count === undefined ? 1 : 0);
45
+ }
46
+ const now = ctx.host.now().toISOString();
47
+ const usage = {
48
+ providerId: 'hanzo',
49
+ identity: { providerId: 'hanzo', loginMethod: 'iam' },
50
+ providerCost: {
51
+ used: spendCents / 100,
52
+ currencyCode: 'USD',
53
+ period: 'monthly',
54
+ updatedAt: now,
55
+ },
56
+ totals: { tokens, requests },
57
+ dataConfidence: 'exact',
58
+ updatedAt: now,
59
+ };
60
+ return {
61
+ usage,
62
+ sourceLabel: 'Hanzo Cloud',
63
+ strategyId: this.id,
64
+ strategyKind: this.kind,
65
+ };
66
+ },
67
+ };
68
+ const devHome = async (ctx) => {
69
+ const candidates = [
70
+ ctx.host.env('HANZO_HOME'),
71
+ ctx.host.env('CODEX_HOME'),
72
+ expandHome(ctx.host, '~/.hanzo'),
73
+ expandHome(ctx.host, '~/.codex'),
74
+ ].filter((c) => Boolean(c));
75
+ for (const dir of candidates) {
76
+ if ((await ctx.host.listDir(`${dir}/sessions`)).length > 0)
77
+ return dir;
78
+ }
79
+ return undefined;
80
+ };
81
+ /** Newest entry of a directory listing of numeric names (years, months, days). */
82
+ const newest = (names) => names.filter((n) => /^\d+$/.test(n)).sort().at(-1);
83
+ const toRateWindow = (w, now) => {
84
+ if (!w || typeof w.used_percent !== 'number')
85
+ return undefined;
86
+ return {
87
+ usedPercent: w.used_percent,
88
+ windowMinutes: w.window_minutes,
89
+ resetsAt: typeof w.resets_in_seconds === 'number'
90
+ ? new Date(now.getTime() + w.resets_in_seconds * 1000).toISOString()
91
+ : undefined,
92
+ };
93
+ };
94
+ const devStrategy = {
95
+ id: 'hanzo.dev',
96
+ kind: 'localProbe',
97
+ async isAvailable(ctx) {
98
+ return Boolean(await devHome(ctx));
99
+ },
100
+ async fetch(ctx) {
101
+ const home = await devHome(ctx);
102
+ if (!home)
103
+ throw new Error('hanzo: no dev CLI home with sessions');
104
+ const sessions = `${home}/sessions`;
105
+ const year = newest(await ctx.host.listDir(sessions));
106
+ const month = year && newest(await ctx.host.listDir(`${sessions}/${year}`));
107
+ const day = month && newest(await ctx.host.listDir(`${sessions}/${year}/${month}`));
108
+ if (!day)
109
+ throw new Error('hanzo: no dev sessions found');
110
+ const dayDir = `${sessions}/${year}/${month}/${day}`;
111
+ const rollouts = (await ctx.host.listDir(dayDir))
112
+ .filter((f) => f.endsWith('.jsonl'))
113
+ .sort();
114
+ const latest = rollouts.at(-1);
115
+ if (!latest)
116
+ throw new Error('hanzo: no rollout files today');
117
+ const text = (await ctx.host.readTextFile(`${dayDir}/${latest}`)) ?? '';
118
+ let payload;
119
+ for (const line of text.split('\n')) {
120
+ if (!line.includes('"token_count"'))
121
+ continue;
122
+ try {
123
+ const item = JSON.parse(line);
124
+ if (item.payload?.type === 'token_count')
125
+ payload = item.payload;
126
+ }
127
+ catch {
128
+ // tolerate partial trailing lines
129
+ }
130
+ }
131
+ const now = ctx.host.now();
132
+ const totals = payload?.info?.total_token_usage;
133
+ const usage = {
134
+ providerId: 'hanzo',
135
+ primary: toRateWindow(payload?.rate_limits?.primary, now),
136
+ secondary: toRateWindow(payload?.rate_limits?.secondary, now),
137
+ identity: { providerId: 'hanzo', loginMethod: 'dev-cli' },
138
+ totals: totals
139
+ ? {
140
+ tokens: totals.total_tokens ?? 0,
141
+ inputTokens: totals.input_tokens ?? 0,
142
+ outputTokens: totals.output_tokens ?? 0,
143
+ cachedInputTokens: totals.cached_input_tokens ?? 0,
144
+ }
145
+ : undefined,
146
+ dataConfidence: totals ? 'exact' : 'unknown',
147
+ updatedAt: now.toISOString(),
148
+ };
149
+ return {
150
+ usage,
151
+ sourceLabel: 'Hanzo Dev CLI',
152
+ strategyId: this.id,
153
+ strategyKind: this.kind,
154
+ };
155
+ },
156
+ };
157
+ export const hanzoProvider = {
158
+ id: 'hanzo',
159
+ metadata: {
160
+ displayName: 'Hanzo',
161
+ sessionLabel: '5h limit',
162
+ weeklyLabel: 'Weekly limit',
163
+ defaultEnabled: true,
164
+ dashboardUrl: 'https://console.hanzo.ai/billing/usage',
165
+ color: '#ff2d55',
166
+ },
167
+ strategies: (mode) => forMode([cloudStrategy, devStrategy], mode),
168
+ };
@@ -0,0 +1,2 @@
1
+ import type { UsageStore, UsageStoreState } from './store.js';
2
+ export declare const useUsage: (store: UsageStore) => UsageStoreState;
package/dist/react.js ADDED
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // React binding — one hook, no extra state library.
3
+ import { useSyncExternalStore } from 'react';
4
+ export const useUsage = (store) => useSyncExternalStore((onChange) => store.subscribe(onChange), () => store.getState(), () => store.getState());
@@ -0,0 +1,49 @@
1
+ import type { PlanUtilizationSeriesHistory, CreditsSnapshot, UsageSnapshot } from './types.js';
2
+ import type { ProviderDescriptor, ProviderFetchAttempt, ProviderSettings, ProviderSourceMode } from './provider.js';
3
+ import type { UsageHost } from './host.js';
4
+ export interface ProviderState {
5
+ snapshot?: UsageSnapshot;
6
+ credits?: CreditsSnapshot;
7
+ error?: string;
8
+ sourceLabel?: string;
9
+ attempts?: ProviderFetchAttempt[];
10
+ refreshing: boolean;
11
+ }
12
+ export interface UsageStoreState {
13
+ providers: Record<string, ProviderState>;
14
+ refreshing: boolean;
15
+ lastRefreshAt?: string;
16
+ }
17
+ export interface UsageStoreOptions {
18
+ host: UsageHost;
19
+ providers: ProviderDescriptor[];
20
+ sourceMode?: ProviderSourceMode;
21
+ settings?: Record<string, ProviderSettings>;
22
+ /** Poll interval in ms; default 5 minutes (CodexBar's default). */
23
+ intervalMs?: number;
24
+ /** Directory for history JSON files; omit to keep history in memory only. */
25
+ historyDir?: string;
26
+ }
27
+ type Listener = () => void;
28
+ export declare class UsageStore {
29
+ private readonly opts;
30
+ private state;
31
+ private listeners;
32
+ private timer;
33
+ private history;
34
+ constructor(opts: UsageStoreOptions);
35
+ getState(): UsageStoreState;
36
+ subscribe(listener: Listener): () => void;
37
+ private emit;
38
+ private setProvider;
39
+ refresh(providerId?: string): Promise<void>;
40
+ private refreshProvider;
41
+ start(): void;
42
+ stop(): void;
43
+ getHistory(providerId: string): PlanUtilizationSeriesHistory[];
44
+ private captureHistory;
45
+ private historyPath;
46
+ private loadHistory;
47
+ private persistHistory;
48
+ }
49
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,156 @@
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
+ import { runPipeline } from './provider.js';
6
+ const HISTORY_MAX_ENTRIES = 2000;
7
+ export class UsageStore {
8
+ opts;
9
+ state = { providers: {}, refreshing: false };
10
+ listeners = new Set();
11
+ timer;
12
+ history = new Map();
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ for (const p of opts.providers) {
16
+ this.state.providers[p.id] = { refreshing: false };
17
+ }
18
+ }
19
+ getState() {
20
+ return this.state;
21
+ }
22
+ subscribe(listener) {
23
+ this.listeners.add(listener);
24
+ return () => this.listeners.delete(listener);
25
+ }
26
+ emit(next) {
27
+ this.state = { ...this.state, ...next };
28
+ for (const l of this.listeners)
29
+ l();
30
+ }
31
+ setProvider(id, patch) {
32
+ this.emit({
33
+ providers: {
34
+ ...this.state.providers,
35
+ [id]: { ...this.state.providers[id], refreshing: false, ...patch },
36
+ },
37
+ });
38
+ }
39
+ async refresh(providerId) {
40
+ const targets = this.opts.providers.filter((p) => !providerId || p.id === providerId);
41
+ this.emit({ refreshing: true });
42
+ await Promise.all(targets.map((p) => this.refreshProvider(p)));
43
+ this.emit({ refreshing: false, lastRefreshAt: this.opts.host.now().toISOString() });
44
+ }
45
+ async refreshProvider(descriptor) {
46
+ this.setProvider(descriptor.id, { refreshing: true });
47
+ const ctx = {
48
+ host: this.opts.host,
49
+ sourceMode: this.opts.sourceMode ?? 'auto',
50
+ settings: this.opts.settings?.[descriptor.id],
51
+ };
52
+ const outcome = await runPipeline(descriptor, ctx);
53
+ if (outcome.result) {
54
+ this.setProvider(descriptor.id, {
55
+ snapshot: outcome.result.usage,
56
+ credits: outcome.result.credits,
57
+ sourceLabel: outcome.result.sourceLabel,
58
+ attempts: outcome.attempts,
59
+ error: undefined,
60
+ });
61
+ await this.captureHistory(outcome.result.usage);
62
+ }
63
+ else {
64
+ // Keep stale data over flapping — errors annotate, they don't erase.
65
+ this.setProvider(descriptor.id, {
66
+ error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error),
67
+ attempts: outcome.attempts,
68
+ });
69
+ }
70
+ }
71
+ start() {
72
+ if (this.timer)
73
+ return;
74
+ const interval = this.opts.intervalMs ?? 5 * 60 * 1000;
75
+ this.timer = setInterval(() => void this.refresh(), interval);
76
+ void this.refresh();
77
+ }
78
+ stop() {
79
+ if (this.timer)
80
+ clearInterval(this.timer);
81
+ this.timer = undefined;
82
+ }
83
+ // ---- plan-utilization history (sparkline data) ----
84
+ getHistory(providerId) {
85
+ return this.history.get(providerId) ?? [];
86
+ }
87
+ async captureHistory(snapshot) {
88
+ const lanes = [
89
+ {
90
+ name: 'session',
91
+ windowMinutes: snapshot.primary?.windowMinutes ?? 300,
92
+ usedPercent: snapshot.primary?.usedPercent,
93
+ resetsAt: snapshot.primary?.resetsAt,
94
+ },
95
+ {
96
+ name: 'weekly',
97
+ windowMinutes: snapshot.secondary?.windowMinutes ?? 10_080,
98
+ usedPercent: snapshot.secondary?.usedPercent,
99
+ resetsAt: snapshot.secondary?.resetsAt,
100
+ },
101
+ ];
102
+ let series = this.history.get(snapshot.providerId);
103
+ if (!series) {
104
+ series = await this.loadHistory(snapshot.providerId);
105
+ this.history.set(snapshot.providerId, series);
106
+ }
107
+ const capturedAt = snapshot.updatedAt;
108
+ // Hour-bucketed dedup, as the Swift store does.
109
+ const bucket = capturedAt.slice(0, 13);
110
+ for (const lane of lanes) {
111
+ if (typeof lane.usedPercent !== 'number')
112
+ continue;
113
+ let s = series.find((x) => x.name === lane.name);
114
+ if (!s) {
115
+ s = { name: lane.name, windowMinutes: lane.windowMinutes, entries: [] };
116
+ series.push(s);
117
+ }
118
+ const last = s.entries.at(-1);
119
+ if (last && last.capturedAt.slice(0, 13) === bucket) {
120
+ last.usedPercent = lane.usedPercent;
121
+ last.resetsAt = lane.resetsAt;
122
+ }
123
+ else {
124
+ s.entries.push({ capturedAt, usedPercent: lane.usedPercent, resetsAt: lane.resetsAt });
125
+ if (s.entries.length > HISTORY_MAX_ENTRIES)
126
+ s.entries.splice(0, s.entries.length - HISTORY_MAX_ENTRIES);
127
+ }
128
+ }
129
+ await this.persistHistory(snapshot.providerId, series);
130
+ }
131
+ historyPath(providerId) {
132
+ return this.opts.historyDir ? `${this.opts.historyDir}/${providerId}.json` : undefined;
133
+ }
134
+ async loadHistory(providerId) {
135
+ const path = this.historyPath(providerId);
136
+ if (!path)
137
+ return [];
138
+ const text = await this.opts.host.readTextFile(path);
139
+ if (!text)
140
+ return [];
141
+ try {
142
+ const file = JSON.parse(text);
143
+ return file.version === 1 ? file.unscoped : [];
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ }
149
+ async persistHistory(providerId, series) {
150
+ const path = this.historyPath(providerId);
151
+ if (!path)
152
+ return;
153
+ const file = { version: 1, unscoped: series };
154
+ await this.opts.host.writeTextFile(path, JSON.stringify(file));
155
+ }
156
+ }
@@ -0,0 +1,132 @@
1
+ /** Universal quota unit — one rate-limit window (session/5h, weekly, …). */
2
+ export interface RateWindow {
3
+ /** 0–100 percent used. */
4
+ usedPercent: number;
5
+ /** Window length in minutes (300 = 5h, 10080 = weekly). */
6
+ windowMinutes?: number;
7
+ /** Absolute reset time, ISO 8601. */
8
+ resetsAt?: string;
9
+ /** Textual reset description when only scraped text is available. */
10
+ resetDescription?: string;
11
+ /** Rolling-recovery providers: percent that regenerates next. */
12
+ nextRegenPercent?: number;
13
+ /** True when a lane was fabricated (e.g. provider returned null for it). */
14
+ isSyntheticPlaceholder?: boolean;
15
+ }
16
+ export declare const remainingPercent: (w: RateWindow) => number;
17
+ export interface NamedRateWindow {
18
+ id: string;
19
+ title: string;
20
+ window: RateWindow;
21
+ usageKnown: boolean;
22
+ }
23
+ export type UsageDataConfidence = 'exact' | 'estimated' | 'percentOnly' | 'unknown';
24
+ export interface ProviderIdentity {
25
+ providerId: string;
26
+ accountEmail?: string;
27
+ accountOrganization?: string;
28
+ loginMethod?: string;
29
+ plan?: string;
30
+ }
31
+ /** Per-provider fetch result — the central value of the system. */
32
+ export interface UsageSnapshot {
33
+ providerId: string;
34
+ /** Session / 5h lane. */
35
+ primary?: RateWindow;
36
+ /** Weekly lane. */
37
+ secondary?: RateWindow;
38
+ tertiary?: RateWindow;
39
+ /** Model-specific or auxiliary windows. */
40
+ extraRateWindows?: NamedRateWindow[];
41
+ identity?: ProviderIdentity;
42
+ dataConfidence: UsageDataConfidence;
43
+ subscriptionExpiresAt?: string;
44
+ subscriptionRenewsAt?: string;
45
+ providerCost?: ProviderCostSnapshot;
46
+ /** Absolute token totals when the source reports them (exact confidence). */
47
+ totals?: UsageTotals;
48
+ updatedAt: string;
49
+ }
50
+ export interface UsageTotals {
51
+ tokens: number;
52
+ inputTokens?: number;
53
+ outputTokens?: number;
54
+ cachedInputTokens?: number;
55
+ requests?: number;
56
+ }
57
+ export interface CreditEvent {
58
+ id: string;
59
+ date: string;
60
+ service: string;
61
+ creditsUsed: number;
62
+ }
63
+ export interface CreditsSnapshot {
64
+ remaining: number;
65
+ unlimited?: boolean;
66
+ events?: CreditEvent[];
67
+ updatedAt: string;
68
+ }
69
+ /** Generic spend/budget meter used by API providers. */
70
+ export interface ProviderCostSnapshot {
71
+ used: number;
72
+ limit?: number;
73
+ currencyCode: string;
74
+ period?: string;
75
+ resetsAt?: string;
76
+ updatedAt: string;
77
+ }
78
+ export interface ModelBreakdown {
79
+ modelName: string;
80
+ costUSD: number;
81
+ totalTokens: number;
82
+ requestCount: number;
83
+ }
84
+ export interface CostUsageDailyEntry {
85
+ /** "YYYY-MM-DD" */
86
+ date: string;
87
+ inputTokens: number;
88
+ cacheReadTokens: number;
89
+ cacheCreationTokens: number;
90
+ outputTokens: number;
91
+ totalTokens: number;
92
+ requestCount: number;
93
+ costUSD: number;
94
+ modelsUsed: string[];
95
+ modelBreakdowns?: ModelBreakdown[];
96
+ }
97
+ export interface CostUsageTokenSnapshot {
98
+ providerId: string;
99
+ sessionTokens: number;
100
+ sessionCostUSD: number;
101
+ sessionRequests: number;
102
+ last30DaysTokens: number;
103
+ last30DaysCostUSD: number;
104
+ last30DaysRequests: number;
105
+ currencyCode: string;
106
+ historyDays: number;
107
+ daily: CostUsageDailyEntry[];
108
+ updatedAt: string;
109
+ }
110
+ export interface PlanUtilizationEntry {
111
+ capturedAt: string;
112
+ usedPercent: number;
113
+ resetsAt?: string;
114
+ }
115
+ export interface PlanUtilizationSeriesHistory {
116
+ /** "session" | "weekly" | named lane */
117
+ name: string;
118
+ windowMinutes: number;
119
+ entries: PlanUtilizationEntry[];
120
+ }
121
+ export interface PlanUtilizationHistoryFile {
122
+ version: 1;
123
+ preferredAccountKey?: string;
124
+ unscoped: PlanUtilizationSeriesHistory[];
125
+ accounts?: Record<string, PlanUtilizationSeriesHistory[]>;
126
+ }
127
+ export type StatusIndicator = 'none' | 'minor' | 'major' | 'critical' | 'unknown';
128
+ export interface ProviderStatus {
129
+ indicator: StatusIndicator;
130
+ description?: string;
131
+ updatedAt: string;
132
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
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
+ export const remainingPercent = (w) => Math.max(0, 100 - w.usedPercent);
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@hanzo/usage",
3
+ "version": "0.1.0",
4
+ "description": "Track AI provider usage, rate limits, and spend across Hanzo products. Headless core (fork of CodexBar, ported to TypeScript).",
5
+ "license": "MIT",
6
+ "author": "Hanzo AI Inc",
7
+ "homepage": "https://github.com/hanzoai/usage",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hanzoai/usage.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./react": {
22
+ "types": "./dist/react.d.ts",
23
+ "default": "./dist/react.js"
24
+ },
25
+ "./node": {
26
+ "types": "./dist/hosts/node.d.ts",
27
+ "default": "./dist/hosts/node.js"
28
+ },
29
+ "./tauri": {
30
+ "types": "./dist/hosts/tauri.d.ts",
31
+ "default": "./dist/hosts/tauri.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "sideEffects": false,
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "test": "vitest run",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "peerDependencies": {
45
+ "react": ">=18"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "react": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^24.0.0",
54
+ "@types/react": "^19.0.0",
55
+ "react": "^19.0.0",
56
+ "typescript": "^5.7.0",
57
+ "vitest": "^3.0.0"
58
+ }
59
+ }
package/src/host.ts ADDED
@@ -0,0 +1,49 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Host abstraction — the port of CodexBar's "Host APIs" concept. Providers never
3
+ // touch the filesystem, network, or environment directly; they go through a
4
+ // UsageHost so the same provider code runs in Node (chat/console/dev), Tauri
5
+ // (desktop/app), and tests.
6
+
7
+ export interface HttpRequest {
8
+ url: string
9
+ method?: string
10
+ headers?: Record<string, string>
11
+ body?: string
12
+ timeoutMs?: number
13
+ }
14
+
15
+ export interface HttpResponse {
16
+ status: number
17
+ headers: Record<string, string>
18
+ text: string
19
+ }
20
+
21
+ export interface UsageHost {
22
+ /** Read a UTF-8 text file; returns undefined when missing/unreadable. */
23
+ readTextFile(path: string): Promise<string | undefined>
24
+ /** List directory entries (names, not paths); [] when missing. */
25
+ listDir(path: string): Promise<string[]>
26
+ /** Write a UTF-8 text file, creating parent directories. */
27
+ writeTextFile(path: string, contents: string): Promise<void>
28
+ http(req: HttpRequest): Promise<HttpResponse>
29
+ env(name: string): string | undefined
30
+ homeDir(): string
31
+ now(): Date
32
+ }
33
+
34
+ /** Expand a leading `~/` against the host home directory. */
35
+ export const expandHome = (host: UsageHost, path: string): string =>
36
+ path.startsWith('~/') ? `${host.homeDir()}/${path.slice(2)}` : path
37
+
38
+ export const readJsonFile = async <T>(
39
+ host: UsageHost,
40
+ path: string,
41
+ ): Promise<T | undefined> => {
42
+ const text = await host.readTextFile(expandHome(host, path))
43
+ if (text === undefined) return undefined
44
+ try {
45
+ return JSON.parse(text) as T
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
@@ -0,0 +1,50 @@
1
+ // Copyright (c) 2026 Hanzo AI Inc. MIT License.
2
+ // Node host — used by chat's Express API, console/app server routes, and CLIs.
3
+
4
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
5
+ import { dirname } from 'node:path'
6
+ import { homedir } from 'node:os'
7
+ import type { HttpRequest, HttpResponse, UsageHost } from '../host.js'
8
+
9
+ export const nodeHost: UsageHost = {
10
+ async readTextFile(path) {
11
+ try {
12
+ return await readFile(path, 'utf8')
13
+ } catch {
14
+ return undefined
15
+ }
16
+ },
17
+ async listDir(path) {
18
+ try {
19
+ return await readdir(path)
20
+ } catch {
21
+ return []
22
+ }
23
+ },
24
+ async writeTextFile(path, contents) {
25
+ await mkdir(dirname(path), { recursive: true })
26
+ await writeFile(path, contents, 'utf8')
27
+ },
28
+ async http(req: HttpRequest): Promise<HttpResponse> {
29
+ const controller = new AbortController()
30
+ const timeout = setTimeout(() => controller.abort(), req.timeoutMs ?? 30_000)
31
+ try {
32
+ const res = await fetch(req.url, {
33
+ method: req.method ?? 'GET',
34
+ headers: req.headers,
35
+ body: req.body,
36
+ signal: controller.signal,
37
+ })
38
+ const headers: Record<string, string> = {}
39
+ res.headers.forEach((v, k) => {
40
+ headers[k] = v
41
+ })
42
+ return { status: res.status, headers, text: await res.text() }
43
+ } finally {
44
+ clearTimeout(timeout)
45
+ }
46
+ },
47
+ env: (name) => process.env[name],
48
+ homeDir: () => homedir(),
49
+ now: () => new Date(),
50
+ }