@inferhub/usage 0.1.0 → 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.
@@ -22,11 +22,16 @@ try {
22
22
  }
23
23
 
24
24
  try {
25
- const { usage } = await client.fetchAccountUsageAuto({ window: "day" });
26
- const sessionCost =
27
- typeof session?.cost?.total_cost_usd === "number"
28
- ? session.cost.total_cost_usd
29
- : null;
25
+ // Prefer Claude's real session_id so /v1/me/usage can aggregate this conversation.
26
+ const sessionId =
27
+ (typeof session?.session_id === "string" && session.session_id) ||
28
+ (typeof session?.sessionId === "string" && session.sessionId) ||
29
+ undefined;
30
+
31
+ const { usage } = await client.fetchAccountUsageAuto({
32
+ window: "day",
33
+ sessionId,
34
+ });
30
35
  const contextPct =
31
36
  typeof session?.context_window?.used_percentage === "number"
32
37
  ? session.context_window.used_percentage
@@ -34,13 +39,12 @@ try {
34
39
  const model = session?.model?.display_name || session?.model?.id || null;
35
40
  process.stdout.write(
36
41
  client.formatStatusLine(usage, {
37
- sessionCostUsd: sessionCost,
38
42
  contextPct,
39
43
  model,
40
44
  }) + "\n",
41
45
  );
42
46
  } catch (e) {
43
47
  const msg = e instanceof Error ? e.message : String(e);
44
- process.stdout.write("IH · offline (" + msg.slice(0, 48) + ")\n");
48
+ process.stdout.write("InferHub · offline (" + msg.slice(0, 48) + ")\n");
45
49
  process.exitCode = 0;
46
50
  }
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { AccountUsage, FetchUsageOptions, ResolvedAuth, UsageWindowKind } from "./types.js";
2
2
  export declare const DEFAULT_BASE_URL = "https://api.inferhub.dev/v1";
3
3
  export declare function normalizeBaseUrl(input?: string | null): string;
4
- export declare function usageUrl(baseUrl: string, window?: UsageWindowKind, tz?: string): string;
4
+ export declare function usageUrl(baseUrl: string, window?: UsageWindowKind, tz?: string, sessionId?: string): string;
5
5
  /** Resolve API key + base URL from env / agent config files. */
6
6
  export declare function resolveAuth(overrides?: Partial<ResolvedAuth>): ResolvedAuth | null;
7
7
  export declare function fetchAccountUsage(opts: FetchUsageOptions): Promise<AccountUsage>;
package/dist/client.js CHANGED
@@ -12,9 +12,11 @@ export function normalizeBaseUrl(input) {
12
12
  }
13
13
  return u;
14
14
  }
15
- export function usageUrl(baseUrl, window = "day", tz = "UTC") {
15
+ export function usageUrl(baseUrl, window = "day", tz = "UTC", sessionId) {
16
16
  const base = normalizeBaseUrl(baseUrl);
17
17
  const q = new URLSearchParams({ window, tz });
18
+ if (sessionId)
19
+ q.set("session_id", sessionId);
18
20
  return `${base}/me/usage?${q.toString()}`;
19
21
  }
20
22
  /** Resolve API key + base URL from env / agent config files. */
@@ -86,9 +88,9 @@ function tryReadJson(path) {
86
88
  return null;
87
89
  }
88
90
  }
89
- function cacheKey(apiKey, window, tz, baseUrl) {
91
+ function cacheKey(apiKey, window, tz, baseUrl, sessionId = "") {
90
92
  const h = createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
91
- return `${h}|${normalizeBaseUrl(baseUrl)}|${window}|${tz}`;
93
+ return `${h}|${normalizeBaseUrl(baseUrl)}|${window}|${tz}|${sessionId}`;
92
94
  }
93
95
  function diskCachePath(key) {
94
96
  const dir = process.env.CLAUDE_PLUGIN_DATA ||
@@ -106,9 +108,10 @@ function diskCachePath(key) {
106
108
  export async function fetchAccountUsage(opts) {
107
109
  const window = opts.window ?? "day";
108
110
  const tz = opts.tz ?? "UTC";
111
+ const sessionId = opts.sessionId ?? "";
109
112
  const baseUrl = normalizeBaseUrl(opts.baseUrl);
110
113
  const ttlMs = (opts.cacheTtlSeconds ?? 30) * 1000;
111
- const key = cacheKey(opts.apiKey, window, tz, baseUrl);
114
+ const key = cacheKey(opts.apiKey, window, tz, baseUrl, sessionId);
112
115
  const now = Date.now();
113
116
  if (!opts.force) {
114
117
  const hit = mem.get(key);
@@ -128,7 +131,7 @@ export async function fetchAccountUsage(opts) {
128
131
  /* ignore disk cache */
129
132
  }
130
133
  }
131
- const url = usageUrl(baseUrl, window, tz);
134
+ const url = usageUrl(baseUrl, window, tz, sessionId || undefined);
132
135
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
133
136
  if (!fetchImpl) {
134
137
  throw new Error("fetch is not available in this runtime");
@@ -139,6 +142,7 @@ export async function fetchAccountUsage(opts) {
139
142
  Authorization: `Bearer ${opts.apiKey}`,
140
143
  "x-api-key": opts.apiKey,
141
144
  Accept: "application/json",
145
+ ...(sessionId ? { "X-Session-Id": sessionId } : {}),
142
146
  },
143
147
  signal: opts.signal,
144
148
  });
package/dist/format.d.ts CHANGED
@@ -2,11 +2,18 @@ import type { AccountUsage } from "./types.js";
2
2
  export declare function money(usdc: string | number | null | undefined, digits?: number): string;
3
3
  export declare function tokens(n: number | null | undefined): string;
4
4
  export declare function shortModel(model: string | null | undefined): string;
5
- /** Compact one-liner for Claude statusline / Codex Stop summary. */
5
+ /**
6
+ * Compact one-liner for Claude statusline / Codex Stop summary.
7
+ *
8
+ * Shows InferHub-billed figures only. Claude's local `cost.total_cost_usd` is
9
+ * official list-price estimate and is NOT shown unless showEstimate=true.
10
+ */
6
11
  export declare function formatStatusLine(usage: AccountUsage, extras?: {
7
12
  sessionCostUsd?: number | null;
8
13
  contextPct?: number | null;
9
14
  model?: string | null;
15
+ /** When true, include Claude's local cost estimate as `est $…`. Default false. */
16
+ showEstimate?: boolean;
10
17
  }): string;
11
18
  /** Multi-line human report for /usage skills. */
12
19
  export declare function formatReport(usage: AccountUsage): string;
package/dist/format.js CHANGED
@@ -20,12 +20,23 @@ export function shortModel(model) {
20
20
  const parts = model.split("/");
21
21
  return parts[parts.length - 1] || model;
22
22
  }
23
- /** Compact one-liner for Claude statusline / Codex Stop summary. */
23
+ function spendOrActivity(spendUsdc, requests, totalTokens) {
24
+ const spend = Number(spendUsdc ?? 0);
25
+ if (spend > 0)
26
+ return money(spend);
27
+ return `${requests}r/${tokens(totalTokens)}`;
28
+ }
29
+ /**
30
+ * Compact one-liner for Claude statusline / Codex Stop summary.
31
+ *
32
+ * Shows InferHub-billed figures only. Claude's local `cost.total_cost_usd` is
33
+ * official list-price estimate and is NOT shown unless showEstimate=true.
34
+ */
24
35
  export function formatStatusLine(usage, extras) {
25
- const sess = extras?.sessionCostUsd != null && Number.isFinite(extras.sessionCostUsd)
26
- ? money(extras.sessionCostUsd)
36
+ const sess = usage.session
37
+ ? spendOrActivity(usage.session.spend_usdc, usage.session.requests, usage.session.total_tokens)
27
38
  : null;
28
- const day = money(usage.window.spend_usdc);
39
+ const day = spendOrActivity(usage.window.spend_usdc, usage.window.requests, usage.window.total_tokens);
29
40
  const all = money(usage.all_time.spend_usdc);
30
41
  const bal = money(usage.balance.amount_usdc);
31
42
  const top = shortModel(usage.top_model?.model);
@@ -33,7 +44,7 @@ export function formatStatusLine(usage, extras) {
33
44
  const ctx = extras?.contextPct != null && Number.isFinite(extras.contextPct)
34
45
  ? `${Math.round(extras.contextPct)}%ctx`
35
46
  : null;
36
- const parts = ["IH"];
47
+ const parts = ["InferHub"];
37
48
  if (model)
38
49
  parts.push(model);
39
50
  if (sess)
@@ -41,9 +52,16 @@ export function formatStatusLine(usage, extras) {
41
52
  parts.push(`${usage.window.kind} ${day}`);
42
53
  parts.push(`all ${all}`);
43
54
  parts.push(`bal ${bal}`);
44
- parts.push(`top ${top}`);
55
+ if (top && top !== "—")
56
+ parts.push(`top ${top}`);
45
57
  if (ctx)
46
58
  parts.push(ctx);
59
+ if (extras?.showEstimate &&
60
+ extras.sessionCostUsd != null &&
61
+ Number.isFinite(extras.sessionCostUsd) &&
62
+ extras.sessionCostUsd > 0) {
63
+ parts.push(`est ${money(extras.sessionCostUsd)}`);
64
+ }
47
65
  return parts.join(" · ");
48
66
  }
49
67
  /** Multi-line human report for /usage skills. */
@@ -51,10 +69,11 @@ export function formatReport(usage) {
51
69
  const lines = [
52
70
  "InferHub usage",
53
71
  ` Balance: ${money(usage.balance.amount_usdc)} USDC`,
54
- ` ${usage.window.kind} window (${usage.window.tz}): ${money(usage.window.spend_usdc)} · ${usage.window.requests} req · ${tokens(usage.window.total_tokens)} tok`,
55
- ` All-time: ${money(usage.all_time.spend_usdc)} · ${usage.all_time.requests} req · ${tokens(usage.all_time.total_tokens)} tok`,
56
- ` Top model: ${usage.top_model?.model ?? "—"} (${money(usage.top_model?.spend_usdc ?? 0)}, ${usage.top_model?.requests ?? 0} req)`,
57
72
  ];
73
+ if (usage.session) {
74
+ lines.push(` Session: ${money(usage.session.spend_usdc)} · ${usage.session.requests} req · ${tokens(usage.session.total_tokens)} tok (${usage.session.id.slice(0, 12)}…)`);
75
+ }
76
+ lines.push(` ${usage.window.kind} window (${usage.window.tz}): ${money(usage.window.spend_usdc)} · ${usage.window.requests} req · ${tokens(usage.window.total_tokens)} tok`, ` All-time: ${money(usage.all_time.spend_usdc)} · ${usage.all_time.requests} req · ${tokens(usage.all_time.total_tokens)} tok`, ` Top model: ${usage.top_model?.model ?? "—"} (${money(usage.top_model?.spend_usdc ?? 0)}, ${usage.top_model?.requests ?? 0} req)`);
58
77
  if (usage.cache?.cached)
59
78
  lines.push(` (cached, ttl ${usage.cache.ttl_seconds}s)`);
60
79
  return lines.join("\n");
@@ -7,8 +7,9 @@ test("normalizeBaseUrl adds /v1", () => {
7
7
  });
8
8
  test("usageUrl", () => {
9
9
  assert.equal(usageUrl("https://api.inferhub.dev", "day", "UTC"), "https://api.inferhub.dev/v1/me/usage?window=day&tz=UTC");
10
+ assert.equal(usageUrl("https://api.inferhub.dev", "day", "UTC", "abc"), "https://api.inferhub.dev/v1/me/usage?window=day&tz=UTC&session_id=abc");
10
11
  });
11
- test("formatStatusLine", () => {
12
+ test("formatStatusLine is plain language", () => {
12
13
  const usage = {
13
14
  object: "account.usage",
14
15
  currency: "USDC",
@@ -18,34 +19,67 @@ test("formatStatusLine", () => {
18
19
  tz: "UTC",
19
20
  since: "",
20
21
  until: "",
21
- requests: 3,
22
- prompt_tokens: 100,
23
- completion_tokens: 50,
24
- total_tokens: 150,
25
- spend_usdc: "0.012000",
22
+ requests: 86,
23
+ prompt_tokens: 14_000_000,
24
+ completion_tokens: 100_000,
25
+ total_tokens: 14_100_000,
26
+ spend_usdc: "0",
26
27
  },
27
28
  all_time: {
28
- requests: 10,
29
- prompt_tokens: 1000,
30
- completion_tokens: 500,
31
- total_tokens: 1500,
32
- spend_usdc: "0.500000",
29
+ requests: 3890,
30
+ prompt_tokens: 300_000_000,
31
+ completion_tokens: 1_000_000,
32
+ total_tokens: 301_000_000,
33
+ spend_usdc: "0.410000",
34
+ },
35
+ session: {
36
+ id: "sess-1",
37
+ requests: 12,
38
+ prompt_tokens: 200_000,
39
+ completion_tokens: 5_000,
40
+ total_tokens: 205_000,
41
+ spend_usdc: "0",
42
+ since: null,
43
+ until: null,
33
44
  },
34
45
  top_model: {
35
- model: "free/grok/grok-4.5",
36
- requests: 8,
37
- tokens: 1200,
38
- spend_usdc: "0.400000",
46
+ model: "cx/gpt-5.5",
47
+ requests: 212,
48
+ tokens: 239830,
49
+ spend_usdc: "0.137155",
39
50
  },
40
51
  cache: { cached: false, ttl_seconds: 30 },
41
52
  };
42
53
  const line = formatStatusLine(usage, {
43
- sessionCostUsd: 0.03,
44
- contextPct: 12,
45
54
  model: "free/grok/grok-4.5",
55
+ contextPct: 8,
56
+ sessionCostUsd: 2.69,
46
57
  });
47
- assert.match(line, /IH/);
48
- assert.match(line, /bal \$1\.25/);
49
- assert.match(line, /top grok-4\.5/);
58
+ assert.match(line, /^InferHub/);
59
+ assert.match(line, /session 12 req \/ 205\.0k tok \/ \$0/);
60
+ assert.match(line, /today 86 req \/ 14\.1M tok \/ \$0/);
61
+ assert.match(line, /all-time 3,890 req \/ 301\.0M tok \/ \$0\.41/);
62
+ assert.match(line, /balance \$1\.25/);
63
+ assert.match(line, /top gpt-5\.5/);
64
+ assert.match(line, /8% context/);
65
+ assert.doesNotMatch(line, /\bIH\b/);
66
+ assert.doesNotMatch(line, /0r\/0/);
67
+ assert.doesNotMatch(line, /claude-est/); // estimate off by default
68
+ // empty/zero session should be omitted (not "sess 0r/0")
69
+ const noSess = {
70
+ ...usage,
71
+ session: {
72
+ id: "empty",
73
+ requests: 0,
74
+ prompt_tokens: 0,
75
+ completion_tokens: 0,
76
+ total_tokens: 0,
77
+ spend_usdc: "0",
78
+ since: null,
79
+ until: null,
80
+ },
81
+ };
82
+ const line2 = formatStatusLine(noSess, { model: "Grok" });
83
+ assert.doesNotMatch(line2, /session /);
50
84
  assert.equal(money("0.001"), "$0.0010");
51
85
  });
package/dist/types.d.ts CHANGED
@@ -24,6 +24,16 @@ export type AccountUsage = {
24
24
  total_tokens: number;
25
25
  spend_usdc: string;
26
26
  };
27
+ session: {
28
+ id: string;
29
+ requests: number;
30
+ prompt_tokens: number;
31
+ completion_tokens: number;
32
+ total_tokens: number;
33
+ spend_usdc: string;
34
+ since: string | null;
35
+ until: string | null;
36
+ } | null;
27
37
  top_model: {
28
38
  model: string;
29
39
  requests: number;
@@ -41,6 +51,8 @@ export type FetchUsageOptions = {
41
51
  baseUrl?: string;
42
52
  window?: UsageWindowKind;
43
53
  tz?: string;
54
+ /** Conversation session id (X-Session-Id / sessionid.Derive). */
55
+ sessionId?: string;
44
56
  /** Force network; ignore process cache */
45
57
  force?: boolean;
46
58
  /** Client-side process cache TTL seconds (default 30) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inferhub/usage",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Shared InferHub account usage client for coding-agent plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -1,205 +1,213 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { homedir, tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import type { AccountUsage, FetchUsageOptions, ResolvedAuth, UsageWindowKind } from "./types.js";
6
-
7
- export const DEFAULT_BASE_URL = "https://api.inferhub.dev/v1";
8
-
9
- const mem = new Map<string, { expires: number; value: AccountUsage }>();
10
-
11
- export function normalizeBaseUrl(input?: string | null): string {
12
- let u = (input || DEFAULT_BASE_URL).trim().replace(/\/+$/, "");
13
- // Accept API root without /v1
14
- if (!u.endsWith("/v1")) {
15
- u = `${u}/v1`;
16
- }
17
- return u;
18
- }
19
-
20
- export function usageUrl(baseUrl: string, window: UsageWindowKind = "day", tz = "UTC"): string {
21
- const base = normalizeBaseUrl(baseUrl);
22
- const q = new URLSearchParams({ window, tz });
23
- return `${base}/me/usage?${q.toString()}`;
24
- }
25
-
26
- /** Resolve API key + base URL from env / agent config files. */
27
- export function resolveAuth(overrides: Partial<ResolvedAuth> = {}): ResolvedAuth | null {
28
- if (overrides.apiKey) {
29
- return {
30
- apiKey: overrides.apiKey,
31
- baseUrl: normalizeBaseUrl(overrides.baseUrl),
32
- source: "override",
33
- };
34
- }
35
-
36
- const envKey =
37
- process.env.INFERHUB_API_KEY ||
38
- process.env.ANTHROPIC_AUTH_TOKEN ||
39
- process.env.ANTHROPIC_API_KEY ||
40
- process.env.OPENAI_API_KEY;
41
- const envBase =
42
- process.env.INFERHUB_BASE_URL ||
43
- process.env.ANTHROPIC_BASE_URL ||
44
- process.env.OPENAI_BASE_URL;
45
-
46
- if (envKey) {
47
- return {
48
- apiKey: envKey,
49
- baseUrl: normalizeBaseUrl(envBase || DEFAULT_BASE_URL),
50
- source: "env",
51
- };
52
- }
53
-
54
- // OpenCode provider config
55
- const oc = tryReadJson(join(homedir(), ".config", "opencode", "opencode.json"));
56
- const ocKey = oc?.provider?.inferhub?.options?.apiKey;
57
- const ocBase = oc?.provider?.inferhub?.options?.baseURL;
58
- if (typeof ocKey === "string" && ocKey) {
59
- return {
60
- apiKey: ocKey,
61
- baseUrl: normalizeBaseUrl(typeof ocBase === "string" ? ocBase : DEFAULT_BASE_URL),
62
- source: "opencode.json",
63
- };
64
- }
65
-
66
- // Claude settings env
67
- const claude = tryReadJson(join(homedir(), ".claude", "settings.json"));
68
- const cKey =
69
- claude?.env?.ANTHROPIC_AUTH_TOKEN ||
70
- claude?.env?.ANTHROPIC_API_KEY ||
71
- claude?.env?.INFERHUB_API_KEY;
72
- const cBase = claude?.env?.ANTHROPIC_BASE_URL || claude?.env?.INFERHUB_BASE_URL;
73
- if (typeof cKey === "string" && cKey) {
74
- return {
75
- apiKey: cKey,
76
- baseUrl: normalizeBaseUrl(typeof cBase === "string" ? cBase : DEFAULT_BASE_URL),
77
- source: "claude settings",
78
- };
79
- }
80
-
81
- // Codex auth.json
82
- const codexAuth = tryReadJson(join(homedir(), ".codex", "auth.json"));
83
- const codexKey = codexAuth?.OPENAI_API_KEY;
84
- if (typeof codexKey === "string" && codexKey) {
85
- return {
86
- apiKey: codexKey,
87
- baseUrl: normalizeBaseUrl(DEFAULT_BASE_URL),
88
- source: "codex auth.json",
89
- };
90
- }
91
-
92
- return null;
93
- }
94
-
95
- function tryReadJson(path: string): any | null {
96
- try {
97
- if (!existsSync(path)) return null;
98
- return JSON.parse(readFileSync(path, "utf8"));
99
- } catch {
100
- return null;
101
- }
102
- }
103
-
104
- function cacheKey(apiKey: string, window: string, tz: string, baseUrl: string): string {
105
- const h = createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
106
- return `${h}|${normalizeBaseUrl(baseUrl)}|${window}|${tz}`;
107
- }
108
-
109
- function diskCachePath(key: string): string {
110
- const dir =
111
- process.env.CLAUDE_PLUGIN_DATA ||
112
- process.env.INFERHUB_CACHE_DIR ||
113
- join(tmpdir(), "inferhub-usage");
114
- try {
115
- mkdirSync(dir, { recursive: true });
116
- } catch {
117
- /* ignore */
118
- }
119
- const safe = createHash("sha256").update(key).digest("hex").slice(0, 24);
120
- return join(dir, `usage-${safe}.json`);
121
- }
122
-
123
- export async function fetchAccountUsage(opts: FetchUsageOptions): Promise<AccountUsage> {
124
- const window = opts.window ?? "day";
125
- const tz = opts.tz ?? "UTC";
126
- const baseUrl = normalizeBaseUrl(opts.baseUrl);
127
- const ttlMs = (opts.cacheTtlSeconds ?? 30) * 1000;
128
- const key = cacheKey(opts.apiKey, window, tz, baseUrl);
129
- const now = Date.now();
130
-
131
- if (!opts.force) {
132
- const hit = mem.get(key);
133
- if (hit && hit.expires > now) return hit.value;
134
- try {
135
- const p = diskCachePath(key);
136
- if (existsSync(p)) {
137
- const raw = JSON.parse(readFileSync(p, "utf8")) as {
138
- expires: number;
139
- value: AccountUsage;
140
- };
141
- if (raw.expires > now && raw.value?.object === "account.usage") {
142
- mem.set(key, raw);
143
- return raw.value;
144
- }
145
- }
146
- } catch {
147
- /* ignore disk cache */
148
- }
149
- }
150
-
151
- const url = usageUrl(baseUrl, window, tz);
152
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
153
- if (!fetchImpl) {
154
- throw new Error("fetch is not available in this runtime");
155
- }
156
-
157
- const res = await fetchImpl(url, {
158
- method: "GET",
159
- headers: {
160
- Authorization: `Bearer ${opts.apiKey}`,
161
- "x-api-key": opts.apiKey,
162
- Accept: "application/json",
163
- },
164
- signal: opts.signal,
165
- });
166
-
167
- if (!res.ok) {
168
- const body = await res.text().catch(() => "");
169
- throw new Error(`InferHub usage HTTP ${res.status}: ${body.slice(0, 300)}`);
170
- }
171
-
172
- const value = (await res.json()) as AccountUsage;
173
- if (!value || value.object !== "account.usage") {
174
- throw new Error("unexpected usage response shape");
175
- }
176
-
177
- const entry = { expires: now + ttlMs, value };
178
- mem.set(key, entry);
179
- try {
180
- writeFileSync(diskCachePath(key), JSON.stringify(entry));
181
- } catch {
182
- /* ignore */
183
- }
184
- return value;
185
- }
186
-
187
- export async function fetchAccountUsageAuto(
188
- opts: Omit<FetchUsageOptions, "apiKey" | "baseUrl"> & {
189
- apiKey?: string;
190
- baseUrl?: string;
191
- } = {},
192
- ): Promise<{ usage: AccountUsage; auth: ResolvedAuth }> {
193
- const auth = resolveAuth({ apiKey: opts.apiKey, baseUrl: opts.baseUrl });
194
- if (!auth) {
195
- throw new Error(
196
- "No InferHub API key found. Set INFERHUB_API_KEY or configure Claude/Codex/OpenCode via @inferhub/helper.",
197
- );
198
- }
199
- const usage = await fetchAccountUsage({
200
- ...opts,
201
- apiKey: auth.apiKey,
202
- baseUrl: auth.baseUrl,
203
- });
204
- return { usage, auth };
205
- }
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { AccountUsage, FetchUsageOptions, ResolvedAuth, UsageWindowKind } from "./types.js";
6
+
7
+ export const DEFAULT_BASE_URL = "https://api.inferhub.dev/v1";
8
+
9
+ const mem = new Map<string, { expires: number; value: AccountUsage }>();
10
+
11
+ export function normalizeBaseUrl(input?: string | null): string {
12
+ let u = (input || DEFAULT_BASE_URL).trim().replace(/\/+$/, "");
13
+ // Accept API root without /v1
14
+ if (!u.endsWith("/v1")) {
15
+ u = `${u}/v1`;
16
+ }
17
+ return u;
18
+ }
19
+
20
+ export function usageUrl(
21
+ baseUrl: string,
22
+ window: UsageWindowKind = "day",
23
+ tz = "UTC",
24
+ sessionId?: string,
25
+ ): string {
26
+ const base = normalizeBaseUrl(baseUrl);
27
+ const q = new URLSearchParams({ window, tz });
28
+ if (sessionId) q.set("session_id", sessionId);
29
+ return `${base}/me/usage?${q.toString()}`;
30
+ }
31
+
32
+ /** Resolve API key + base URL from env / agent config files. */
33
+ export function resolveAuth(overrides: Partial<ResolvedAuth> = {}): ResolvedAuth | null {
34
+ if (overrides.apiKey) {
35
+ return {
36
+ apiKey: overrides.apiKey,
37
+ baseUrl: normalizeBaseUrl(overrides.baseUrl),
38
+ source: "override",
39
+ };
40
+ }
41
+
42
+ const envKey =
43
+ process.env.INFERHUB_API_KEY ||
44
+ process.env.ANTHROPIC_AUTH_TOKEN ||
45
+ process.env.ANTHROPIC_API_KEY ||
46
+ process.env.OPENAI_API_KEY;
47
+ const envBase =
48
+ process.env.INFERHUB_BASE_URL ||
49
+ process.env.ANTHROPIC_BASE_URL ||
50
+ process.env.OPENAI_BASE_URL;
51
+
52
+ if (envKey) {
53
+ return {
54
+ apiKey: envKey,
55
+ baseUrl: normalizeBaseUrl(envBase || DEFAULT_BASE_URL),
56
+ source: "env",
57
+ };
58
+ }
59
+
60
+ // OpenCode provider config
61
+ const oc = tryReadJson(join(homedir(), ".config", "opencode", "opencode.json"));
62
+ const ocKey = oc?.provider?.inferhub?.options?.apiKey;
63
+ const ocBase = oc?.provider?.inferhub?.options?.baseURL;
64
+ if (typeof ocKey === "string" && ocKey) {
65
+ return {
66
+ apiKey: ocKey,
67
+ baseUrl: normalizeBaseUrl(typeof ocBase === "string" ? ocBase : DEFAULT_BASE_URL),
68
+ source: "opencode.json",
69
+ };
70
+ }
71
+
72
+ // Claude settings env
73
+ const claude = tryReadJson(join(homedir(), ".claude", "settings.json"));
74
+ const cKey =
75
+ claude?.env?.ANTHROPIC_AUTH_TOKEN ||
76
+ claude?.env?.ANTHROPIC_API_KEY ||
77
+ claude?.env?.INFERHUB_API_KEY;
78
+ const cBase = claude?.env?.ANTHROPIC_BASE_URL || claude?.env?.INFERHUB_BASE_URL;
79
+ if (typeof cKey === "string" && cKey) {
80
+ return {
81
+ apiKey: cKey,
82
+ baseUrl: normalizeBaseUrl(typeof cBase === "string" ? cBase : DEFAULT_BASE_URL),
83
+ source: "claude settings",
84
+ };
85
+ }
86
+
87
+ // Codex auth.json
88
+ const codexAuth = tryReadJson(join(homedir(), ".codex", "auth.json"));
89
+ const codexKey = codexAuth?.OPENAI_API_KEY;
90
+ if (typeof codexKey === "string" && codexKey) {
91
+ return {
92
+ apiKey: codexKey,
93
+ baseUrl: normalizeBaseUrl(DEFAULT_BASE_URL),
94
+ source: "codex auth.json",
95
+ };
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ function tryReadJson(path: string): any | null {
102
+ try {
103
+ if (!existsSync(path)) return null;
104
+ return JSON.parse(readFileSync(path, "utf8"));
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function cacheKey(apiKey: string, window: string, tz: string, baseUrl: string, sessionId = ""): string {
111
+ const h = createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
112
+ return `${h}|${normalizeBaseUrl(baseUrl)}|${window}|${tz}|${sessionId}`;
113
+ }
114
+
115
+ function diskCachePath(key: string): string {
116
+ const dir =
117
+ process.env.CLAUDE_PLUGIN_DATA ||
118
+ process.env.INFERHUB_CACHE_DIR ||
119
+ join(tmpdir(), "inferhub-usage");
120
+ try {
121
+ mkdirSync(dir, { recursive: true });
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ const safe = createHash("sha256").update(key).digest("hex").slice(0, 24);
126
+ return join(dir, `usage-${safe}.json`);
127
+ }
128
+
129
+ export async function fetchAccountUsage(opts: FetchUsageOptions): Promise<AccountUsage> {
130
+ const window = opts.window ?? "day";
131
+ const tz = opts.tz ?? "UTC";
132
+ const sessionId = opts.sessionId ?? "";
133
+ const baseUrl = normalizeBaseUrl(opts.baseUrl);
134
+ const ttlMs = (opts.cacheTtlSeconds ?? 30) * 1000;
135
+ const key = cacheKey(opts.apiKey, window, tz, baseUrl, sessionId);
136
+ const now = Date.now();
137
+
138
+ if (!opts.force) {
139
+ const hit = mem.get(key);
140
+ if (hit && hit.expires > now) return hit.value;
141
+ try {
142
+ const p = diskCachePath(key);
143
+ if (existsSync(p)) {
144
+ const raw = JSON.parse(readFileSync(p, "utf8")) as {
145
+ expires: number;
146
+ value: AccountUsage;
147
+ };
148
+ if (raw.expires > now && raw.value?.object === "account.usage") {
149
+ mem.set(key, raw);
150
+ return raw.value;
151
+ }
152
+ }
153
+ } catch {
154
+ /* ignore disk cache */
155
+ }
156
+ }
157
+
158
+ const url = usageUrl(baseUrl, window, tz, sessionId || undefined);
159
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
160
+ if (!fetchImpl) {
161
+ throw new Error("fetch is not available in this runtime");
162
+ }
163
+
164
+ const res = await fetchImpl(url, {
165
+ method: "GET",
166
+ headers: {
167
+ Authorization: `Bearer ${opts.apiKey}`,
168
+ "x-api-key": opts.apiKey,
169
+ Accept: "application/json",
170
+ ...(sessionId ? { "X-Session-Id": sessionId } : {}),
171
+ },
172
+ signal: opts.signal,
173
+ });
174
+
175
+ if (!res.ok) {
176
+ const body = await res.text().catch(() => "");
177
+ throw new Error(`InferHub usage HTTP ${res.status}: ${body.slice(0, 300)}`);
178
+ }
179
+
180
+ const value = (await res.json()) as AccountUsage;
181
+ if (!value || value.object !== "account.usage") {
182
+ throw new Error("unexpected usage response shape");
183
+ }
184
+
185
+ const entry = { expires: now + ttlMs, value };
186
+ mem.set(key, entry);
187
+ try {
188
+ writeFileSync(diskCachePath(key), JSON.stringify(entry));
189
+ } catch {
190
+ /* ignore */
191
+ }
192
+ return value;
193
+ }
194
+
195
+ export async function fetchAccountUsageAuto(
196
+ opts: Omit<FetchUsageOptions, "apiKey" | "baseUrl"> & {
197
+ apiKey?: string;
198
+ baseUrl?: string;
199
+ } = {},
200
+ ): Promise<{ usage: AccountUsage; auth: ResolvedAuth }> {
201
+ const auth = resolveAuth({ apiKey: opts.apiKey, baseUrl: opts.baseUrl });
202
+ if (!auth) {
203
+ throw new Error(
204
+ "No InferHub API key found. Set INFERHUB_API_KEY or configure Claude/Codex/OpenCode via @inferhub/helper.",
205
+ );
206
+ }
207
+ const usage = await fetchAccountUsage({
208
+ ...opts,
209
+ apiKey: auth.apiKey,
210
+ baseUrl: auth.baseUrl,
211
+ });
212
+ return { usage, auth };
213
+ }
@@ -13,9 +13,13 @@ test("usageUrl", () => {
13
13
  usageUrl("https://api.inferhub.dev", "day", "UTC"),
14
14
  "https://api.inferhub.dev/v1/me/usage?window=day&tz=UTC",
15
15
  );
16
+ assert.equal(
17
+ usageUrl("https://api.inferhub.dev", "day", "UTC", "abc"),
18
+ "https://api.inferhub.dev/v1/me/usage?window=day&tz=UTC&session_id=abc",
19
+ );
16
20
  });
17
21
 
18
- test("formatStatusLine", () => {
22
+ test("formatStatusLine is plain language", () => {
19
23
  const usage: AccountUsage = {
20
24
  object: "account.usage",
21
25
  currency: "USDC",
@@ -25,34 +29,71 @@ test("formatStatusLine", () => {
25
29
  tz: "UTC",
26
30
  since: "",
27
31
  until: "",
28
- requests: 3,
29
- prompt_tokens: 100,
30
- completion_tokens: 50,
31
- total_tokens: 150,
32
- spend_usdc: "0.012000",
32
+ requests: 86,
33
+ prompt_tokens: 14_000_000,
34
+ completion_tokens: 100_000,
35
+ total_tokens: 14_100_000,
36
+ spend_usdc: "0",
33
37
  },
34
38
  all_time: {
35
- requests: 10,
36
- prompt_tokens: 1000,
37
- completion_tokens: 500,
38
- total_tokens: 1500,
39
- spend_usdc: "0.500000",
39
+ requests: 3890,
40
+ prompt_tokens: 300_000_000,
41
+ completion_tokens: 1_000_000,
42
+ total_tokens: 301_000_000,
43
+ spend_usdc: "0.410000",
44
+ },
45
+ session: {
46
+ id: "sess-1",
47
+ requests: 12,
48
+ prompt_tokens: 200_000,
49
+ completion_tokens: 5_000,
50
+ total_tokens: 205_000,
51
+ spend_usdc: "0",
52
+ since: null,
53
+ until: null,
40
54
  },
41
55
  top_model: {
42
- model: "free/grok/grok-4.5",
43
- requests: 8,
44
- tokens: 1200,
45
- spend_usdc: "0.400000",
56
+ model: "cx/gpt-5.5",
57
+ requests: 212,
58
+ tokens: 239830,
59
+ spend_usdc: "0.137155",
46
60
  },
47
61
  cache: { cached: false, ttl_seconds: 30 },
48
62
  };
63
+
49
64
  const line = formatStatusLine(usage, {
50
- sessionCostUsd: 0.03,
51
- contextPct: 12,
52
65
  model: "free/grok/grok-4.5",
66
+ contextPct: 8,
67
+ sessionCostUsd: 2.69,
53
68
  });
54
- assert.match(line, /IH/);
55
- assert.match(line, /bal \$1\.25/);
56
- assert.match(line, /top grok-4\.5/);
69
+
70
+ assert.match(line, /^InferHub/);
71
+ assert.match(line, /session 12 req \/ 205\.0k tok \/ \$0/);
72
+ assert.match(line, /today 86 req \/ 14\.1M tok \/ \$0/);
73
+ assert.match(line, /all-time 3,890 req \/ 301\.0M tok \/ \$0\.41/);
74
+ assert.match(line, /balance \$1\.25/);
75
+ assert.match(line, /top gpt-5\.5/);
76
+ assert.match(line, /8% context/);
77
+ assert.doesNotMatch(line, /\bIH\b/);
78
+ assert.doesNotMatch(line, /0r\/0/);
79
+ assert.doesNotMatch(line, /claude-est/); // estimate off by default
80
+
81
+ // empty/zero session should be omitted (not "sess 0r/0")
82
+ const noSess: AccountUsage = {
83
+ ...usage,
84
+ session: {
85
+ id: "empty",
86
+ requests: 0,
87
+ prompt_tokens: 0,
88
+ completion_tokens: 0,
89
+ total_tokens: 0,
90
+ spend_usdc: "0",
91
+ since: null,
92
+ until: null,
93
+ },
94
+ };
95
+ const line2 = formatStatusLine(noSess, { model: "Grok" });
96
+ assert.doesNotMatch(line2, /session /);
97
+
57
98
  assert.equal(money("0.001"), "$0.0010");
58
99
  });
package/src/format.ts CHANGED
@@ -20,20 +20,44 @@ export function shortModel(model: string | null | undefined): string {
20
20
  return parts[parts.length - 1] || model;
21
21
  }
22
22
 
23
- /** Compact one-liner for Claude statusline / Codex Stop summary. */
23
+ function spendOrActivity(
24
+ spendUsdc: string | number | null | undefined,
25
+ requests: number,
26
+ totalTokens: number,
27
+ ): string {
28
+ const spend = Number(spendUsdc ?? 0);
29
+ if (spend > 0) return money(spend);
30
+ return `${requests}r/${tokens(totalTokens)}`;
31
+ }
32
+
33
+ /**
34
+ * Compact one-liner for Claude statusline / Codex Stop summary.
35
+ *
36
+ * Shows InferHub-billed figures only. Claude's local `cost.total_cost_usd` is
37
+ * official list-price estimate and is NOT shown unless showEstimate=true.
38
+ */
24
39
  export function formatStatusLine(
25
40
  usage: AccountUsage,
26
41
  extras?: {
27
42
  sessionCostUsd?: number | null;
28
43
  contextPct?: number | null;
29
44
  model?: string | null;
45
+ /** When true, include Claude's local cost estimate as `est $…`. Default false. */
46
+ showEstimate?: boolean;
30
47
  },
31
48
  ): string {
32
- const sess =
33
- extras?.sessionCostUsd != null && Number.isFinite(extras.sessionCostUsd)
34
- ? money(extras.sessionCostUsd)
35
- : null;
36
- const day = money(usage.window.spend_usdc);
49
+ const sess = usage.session
50
+ ? spendOrActivity(
51
+ usage.session.spend_usdc,
52
+ usage.session.requests,
53
+ usage.session.total_tokens,
54
+ )
55
+ : null;
56
+ const day = spendOrActivity(
57
+ usage.window.spend_usdc,
58
+ usage.window.requests,
59
+ usage.window.total_tokens,
60
+ );
37
61
  const all = money(usage.all_time.spend_usdc);
38
62
  const bal = money(usage.balance.amount_usdc);
39
63
  const top = shortModel(usage.top_model?.model);
@@ -43,14 +67,24 @@ export function formatStatusLine(
43
67
  ? `${Math.round(extras.contextPct)}%ctx`
44
68
  : null;
45
69
 
46
- const parts: string[] = ["IH"];
70
+ const parts: string[] = ["InferHub"];
47
71
  if (model) parts.push(model);
48
72
  if (sess) parts.push(`sess ${sess}`);
49
73
  parts.push(`${usage.window.kind} ${day}`);
50
74
  parts.push(`all ${all}`);
51
75
  parts.push(`bal ${bal}`);
52
- parts.push(`top ${top}`);
76
+ if (top && top !== "—") parts.push(`top ${top}`);
53
77
  if (ctx) parts.push(ctx);
78
+
79
+ if (
80
+ extras?.showEstimate &&
81
+ extras.sessionCostUsd != null &&
82
+ Number.isFinite(extras.sessionCostUsd) &&
83
+ extras.sessionCostUsd > 0
84
+ ) {
85
+ parts.push(`est ${money(extras.sessionCostUsd)}`);
86
+ }
87
+
54
88
  return parts.join(" · ");
55
89
  }
56
90
 
@@ -59,10 +93,17 @@ export function formatReport(usage: AccountUsage): string {
59
93
  const lines = [
60
94
  "InferHub usage",
61
95
  ` Balance: ${money(usage.balance.amount_usdc)} USDC`,
96
+ ];
97
+ if (usage.session) {
98
+ lines.push(
99
+ ` Session: ${money(usage.session.spend_usdc)} · ${usage.session.requests} req · ${tokens(usage.session.total_tokens)} tok (${usage.session.id.slice(0, 12)}…)`,
100
+ );
101
+ }
102
+ lines.push(
62
103
  ` ${usage.window.kind} window (${usage.window.tz}): ${money(usage.window.spend_usdc)} · ${usage.window.requests} req · ${tokens(usage.window.total_tokens)} tok`,
63
104
  ` All-time: ${money(usage.all_time.spend_usdc)} · ${usage.all_time.requests} req · ${tokens(usage.all_time.total_tokens)} tok`,
64
105
  ` Top model: ${usage.top_model?.model ?? "—"} (${money(usage.top_model?.spend_usdc ?? 0)}, ${usage.top_model?.requests ?? 0} req)`,
65
- ];
106
+ );
66
107
  if (usage.cache?.cached) lines.push(` (cached, ttl ${usage.cache.ttl_seconds}s)`);
67
108
  return lines.join("\n");
68
109
  }
package/src/types.ts CHANGED
@@ -25,6 +25,16 @@ export type AccountUsage = {
25
25
  total_tokens: number;
26
26
  spend_usdc: string;
27
27
  };
28
+ session: {
29
+ id: string;
30
+ requests: number;
31
+ prompt_tokens: number;
32
+ completion_tokens: number;
33
+ total_tokens: number;
34
+ spend_usdc: string;
35
+ since: string | null;
36
+ until: string | null;
37
+ } | null;
28
38
  top_model: {
29
39
  model: string;
30
40
  requests: number;
@@ -43,6 +53,8 @@ export type FetchUsageOptions = {
43
53
  baseUrl?: string;
44
54
  window?: UsageWindowKind;
45
55
  tz?: string;
56
+ /** Conversation session id (X-Session-Id / sessionid.Derive). */
57
+ sessionId?: string;
46
58
  /** Force network; ignore process cache */
47
59
  force?: boolean;
48
60
  /** Client-side process cache TTL seconds (default 30) */