axusage 3.5.0 → 3.7.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.
Files changed (37) hide show
  1. package/README.md +22 -3
  2. package/dist/adapters/claude.d.ts +2 -9
  3. package/dist/adapters/claude.js +40 -53
  4. package/dist/adapters/codex.d.ts +2 -8
  5. package/dist/adapters/codex.js +30 -42
  6. package/dist/adapters/copilot.d.ts +2 -7
  7. package/dist/adapters/copilot.js +32 -43
  8. package/dist/adapters/gemini.d.ts +2 -8
  9. package/dist/adapters/gemini.js +26 -38
  10. package/dist/adapters/parse-claude-usage.js +1 -0
  11. package/dist/adapters/parse-codex-usage.js +1 -0
  12. package/dist/adapters/parse-copilot-usage.js +2 -0
  13. package/dist/adapters/parse-gemini-usage.js +1 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/commands/fetch-service-usage.d.ts +8 -2
  16. package/dist/commands/fetch-service-usage.js +76 -9
  17. package/dist/commands/serve-command.js +5 -2
  18. package/dist/commands/usage-command.d.ts +1 -0
  19. package/dist/commands/usage-command.js +8 -8
  20. package/dist/config/credential-sources.d.ts +16 -11
  21. package/dist/config/credential-sources.js +48 -27
  22. package/dist/server/routes.js +6 -2
  23. package/dist/services/get-instance-access-token.d.ts +20 -0
  24. package/dist/services/{get-service-access-token.js → get-instance-access-token.js} +31 -52
  25. package/dist/services/resolve-service-instances.d.ts +13 -0
  26. package/dist/services/resolve-service-instances.js +24 -0
  27. package/dist/services/service-adapter-registry.d.ts +3 -12
  28. package/dist/services/service-adapter-registry.js +14 -14
  29. package/dist/types/domain.d.ts +9 -3
  30. package/dist/utils/calculate-usage-rate.d.ts +1 -1
  31. package/dist/utils/calculate-usage-rate.js +1 -2
  32. package/dist/utils/format-prometheus-metrics.d.ts +2 -2
  33. package/dist/utils/format-prometheus-metrics.js +22 -5
  34. package/dist/utils/format-service-usage.d.ts +1 -1
  35. package/dist/utils/format-service-usage.js +26 -17
  36. package/package.json +6 -6
  37. package/dist/services/get-service-access-token.d.ts +0 -28
package/README.md CHANGED
@@ -112,12 +112,31 @@ Credential source config is read from:
112
112
  - Config file path shown in `axusage --help`
113
113
  - `AXUSAGE_SOURCES` environment variable (JSON), which overrides file config
114
114
 
115
+ ### Multi-Instance Configuration
116
+
117
+ To monitor multiple accounts for the same service, use an array of instance configs:
118
+
119
+ ```json
120
+ {
121
+ "claude": [
122
+ { "source": "vault", "name": "work", "displayName": "Claude (Work)" },
123
+ {
124
+ "source": "vault",
125
+ "name": "personal",
126
+ "displayName": "Claude (Personal)"
127
+ }
128
+ ]
129
+ }
130
+ ```
131
+
132
+ Each instance resolves credentials independently. Named credentials require vault to be configured (`AXVAULT` env). Single-instance configs can use string shorthand (`"auto"`, `"local"`, `"vault"`) or object form.
133
+
115
134
  ## Examples
116
135
 
117
136
  ### Extract service and utilization (TSV + awk)
118
137
 
119
138
  ```bash
120
- axusage --format tsv | tail -n +2 | awk -F'\t' '{print $1, $4"%"}'
139
+ axusage --format tsv | tail -n +2 | awk -F'\t' '{print $1, $5"%"}'
121
140
  ```
122
141
 
123
142
  ### Count windows by service (TSV + cut/sort/uniq)
@@ -129,7 +148,7 @@ axusage --format tsv | tail -n +2 | cut -f1 | sort | uniq -c
129
148
  ### Filter by utilization threshold (TSV + awk)
130
149
 
131
150
  ```bash
132
- axusage --format tsv | tail -n +2 | awk -F'\t' '$4 > 50 {print $1, $3, $4"%"}'
151
+ axusage --format tsv | tail -n +2 | awk -F'\t' '$5 > 50 {print $1, $4, $5"%"}'
133
152
  ```
134
153
 
135
154
  ### Extract utilization as JSON (JSON + jq)
@@ -180,7 +199,7 @@ AXUSAGE_PORT=9090 AXUSAGE_INTERVAL=60 axusage serve
180
199
  ### Endpoints
181
200
 
182
201
  - `GET /metrics` — Prometheus text exposition (`text/plain; version=0.0.4`). Serves cached data immediately; triggers a background refresh when stale. Returns 503 when all services are currently failing.
183
- - `GET /usage` — JSON array of usage objects (one per service). Waits for a fresh snapshot when stale. Returns 503 if no data is available. Date fields (e.g. `resetsAt`) are serialized as ISO 8601 strings.
202
+ - `GET /usage` — JSON array of usage objects (one per service instance; multi-instance configs produce multiple entries per service type). Waits for a fresh snapshot when stale. Returns 503 if no data is available. Date fields (e.g. `resetsAt`) are serialized as ISO 8601 strings.
184
203
  - `GET /health` — JSON health status with version, last refresh time, tracked services, and errors. Always responds immediately from cached state without triggering a refresh.
185
204
 
186
205
  ### Container Deployment
@@ -1,9 +1,2 @@
1
- import type { ServiceAdapter } from "../types/domain.js";
2
- /**
3
- * Claude service adapter using direct API access.
4
- *
5
- * This adapter uses the OAuth token from Claude Code's credential store
6
- * (Keychain on macOS, credentials file elsewhere) to make direct API calls
7
- * to the Anthropic usage endpoint.
8
- */
9
- export declare const claudeAdapter: ServiceAdapter;
1
+ import type { ServiceUsageFetcher } from "../types/domain.js";
2
+ export declare const claudeUsageFetcher: ServiceUsageFetcher;
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod";
2
2
  import { ApiError } from "../types/domain.js";
3
- import { getServiceAccessToken } from "../services/get-service-access-token.js";
4
3
  import { UsageResponse as UsageResponseSchema } from "../types/usage.js";
5
4
  import { coalesceClaudeUsageResponse } from "./coalesce-claude-usage-response.js";
6
5
  import { toServiceUsageData } from "./parse-claude-usage.js";
@@ -33,64 +32,52 @@ async function fetchPlanType(accessToken) {
33
32
  return undefined;
34
33
  }
35
34
  }
36
- /**
37
- * Claude service adapter using direct API access.
38
- *
39
- * This adapter uses the OAuth token from Claude Code's credential store
40
- * (Keychain on macOS, credentials file elsewhere) to make direct API calls
41
- * to the Anthropic usage endpoint.
42
- */
43
- export const claudeAdapter = {
44
- name: "Claude",
45
- async fetchUsage() {
46
- const accessToken = await getServiceAccessToken("claude");
47
- if (!accessToken) {
35
+ /** Fetch Claude usage data using a pre-resolved access token */
36
+ async function fetchClaudeUsageWithToken(accessToken) {
37
+ try {
38
+ const [response, planType] = await Promise.all([
39
+ fetch(USAGE_API_URL, {
40
+ headers: {
41
+ Authorization: `Bearer ${accessToken}`,
42
+ "anthropic-beta": ANTHROPIC_BETA_HEADER,
43
+ },
44
+ }),
45
+ fetchPlanType(accessToken),
46
+ ]);
47
+ if (!response.ok) {
48
+ const errorText = await response.text().catch(() => "");
48
49
  return {
49
50
  ok: false,
50
- error: new ApiError("No Claude credentials found. Run 'claude' to authenticate."),
51
- };
52
- }
53
- try {
54
- const [response, planType] = await Promise.all([
55
- fetch(USAGE_API_URL, {
56
- headers: {
57
- Authorization: `Bearer ${accessToken}`,
58
- "anthropic-beta": ANTHROPIC_BETA_HEADER,
59
- },
60
- }),
61
- fetchPlanType(accessToken),
62
- ]);
63
- if (!response.ok) {
64
- const errorText = await response.text().catch(() => "");
65
- return {
66
- ok: false,
67
- error: new ApiError(`Claude API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
68
- };
69
- }
70
- const data = await response.json();
71
- const parseResult = UsageResponseSchema.safeParse(coalesceClaudeUsageResponse(data) ?? data);
72
- if (!parseResult.success) {
73
- /* eslint-disable unicorn/no-null -- JSON.stringify requires null for no replacer */
74
- console.error("Raw API response:", JSON.stringify(data, null, 2));
75
- console.error("Validation errors:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
76
- /* eslint-enable unicorn/no-null */
77
- return {
78
- ok: false,
79
- error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
80
- };
81
- }
82
- const usageData = toServiceUsageData(parseResult.data);
83
- return {
84
- ok: true,
85
- value: planType ? { ...usageData, planType } : usageData,
51
+ error: new ApiError(`Claude API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
86
52
  };
87
53
  }
88
- catch (error) {
89
- const message = error instanceof Error ? error.message : String(error);
54
+ const data = await response.json();
55
+ const parseResult = UsageResponseSchema.safeParse(coalesceClaudeUsageResponse(data) ?? data);
56
+ if (!parseResult.success) {
57
+ /* eslint-disable unicorn/no-null -- JSON.stringify requires null for no replacer */
58
+ console.error("Raw API response:", JSON.stringify(data, null, 2));
59
+ console.error("Validation errors:", JSON.stringify(z.treeifyError(parseResult.error), null, 2));
60
+ /* eslint-enable unicorn/no-null */
90
61
  return {
91
62
  ok: false,
92
- error: new ApiError(`Failed to fetch Claude usage: ${message}`),
63
+ error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
93
64
  };
94
65
  }
95
- },
66
+ const usageData = toServiceUsageData(parseResult.data);
67
+ return {
68
+ ok: true,
69
+ value: planType ? { ...usageData, planType } : usageData,
70
+ };
71
+ }
72
+ catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ return {
75
+ ok: false,
76
+ error: new ApiError(`Failed to fetch Claude usage: ${message}`),
77
+ };
78
+ }
79
+ }
80
+ export const claudeUsageFetcher = {
81
+ name: "Claude",
82
+ fetchUsageWithToken: fetchClaudeUsageWithToken,
96
83
  };
@@ -1,8 +1,2 @@
1
- import type { ServiceAdapter } from "../types/domain.js";
2
- /**
3
- * ChatGPT service adapter using direct API access.
4
- *
5
- * Uses the OAuth token from Codex CLI's credential store (~/.codex/auth.json)
6
- * to make direct API calls to ChatGPT's usage endpoint.
7
- */
8
- export declare const codexAdapter: ServiceAdapter;
1
+ import type { ServiceUsageFetcher } from "../types/domain.js";
2
+ export declare const codexUsageFetcher: ServiceUsageFetcher;
@@ -1,55 +1,43 @@
1
1
  import { ApiError } from "../types/domain.js";
2
- import { getServiceAccessToken } from "../services/get-service-access-token.js";
3
2
  import { CodexUsageResponse as CodexUsageResponseSchema } from "../types/codex.js";
4
3
  import { toServiceUsageData } from "./parse-codex-usage.js";
5
4
  const API_URL = "https://chatgpt.com/backend-api/wham/usage";
6
- /**
7
- * ChatGPT service adapter using direct API access.
8
- *
9
- * Uses the OAuth token from Codex CLI's credential store (~/.codex/auth.json)
10
- * to make direct API calls to ChatGPT's usage endpoint.
11
- */
12
- export const codexAdapter = {
13
- name: "ChatGPT",
14
- async fetchUsage() {
15
- const accessToken = await getServiceAccessToken("codex");
16
- if (!accessToken) {
5
+ /** Fetch ChatGPT usage data using a pre-resolved access token */
6
+ async function fetchCodexUsageWithToken(accessToken) {
7
+ try {
8
+ const response = await fetch(API_URL, {
9
+ headers: {
10
+ Authorization: `Bearer ${accessToken}`,
11
+ },
12
+ });
13
+ if (!response.ok) {
14
+ const errorText = await response.text().catch(() => "");
17
15
  return {
18
16
  ok: false,
19
- error: new ApiError("No Codex credentials found. Run 'codex' to authenticate."),
17
+ error: new ApiError(`ChatGPT API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
20
18
  };
21
19
  }
22
- try {
23
- const response = await fetch(API_URL, {
24
- headers: {
25
- Authorization: `Bearer ${accessToken}`,
26
- },
27
- });
28
- if (!response.ok) {
29
- const errorText = await response.text().catch(() => "");
30
- return {
31
- ok: false,
32
- error: new ApiError(`ChatGPT API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
33
- };
34
- }
35
- const data = await response.json();
36
- const parseResult = CodexUsageResponseSchema.safeParse(data);
37
- if (!parseResult.success) {
38
- return {
39
- ok: false,
40
- error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
41
- };
42
- }
43
- return {
44
- ok: true,
45
- value: toServiceUsageData(parseResult.data),
46
- };
47
- }
48
- catch (error) {
20
+ const data = await response.json();
21
+ const parseResult = CodexUsageResponseSchema.safeParse(data);
22
+ if (!parseResult.success) {
49
23
  return {
50
24
  ok: false,
51
- error: new ApiError(`Failed to fetch ChatGPT usage: ${error instanceof Error ? error.message : String(error)}`),
25
+ error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
52
26
  };
53
27
  }
54
- },
28
+ return {
29
+ ok: true,
30
+ value: toServiceUsageData(parseResult.data),
31
+ };
32
+ }
33
+ catch (error) {
34
+ return {
35
+ ok: false,
36
+ error: new ApiError(`Failed to fetch ChatGPT usage: ${error instanceof Error ? error.message : String(error)}`),
37
+ };
38
+ }
39
+ }
40
+ export const codexUsageFetcher = {
41
+ name: "ChatGPT",
42
+ fetchUsageWithToken: fetchCodexUsageWithToken,
55
43
  };
@@ -1,7 +1,2 @@
1
- import type { ServiceAdapter } from "../types/domain.js";
2
- /**
3
- * GitHub Copilot service adapter using token-based API access.
4
- *
5
- * Credentials resolved via getServiceAccessToken (vault, local axauth, gh CLI).
6
- */
7
- export declare const copilotAdapter: ServiceAdapter;
1
+ import type { ServiceUsageFetcher } from "../types/domain.js";
2
+ export declare const copilotUsageFetcher: ServiceUsageFetcher;
@@ -1,58 +1,47 @@
1
1
  import { ApiError } from "../types/domain.js";
2
2
  import { CopilotUsageResponse as CopilotUsageResponseSchema } from "../types/copilot.js";
3
3
  import { toServiceUsageData } from "./parse-copilot-usage.js";
4
- import { getServiceAccessToken } from "../services/get-service-access-token.js";
5
4
  // Internal/undocumented GitHub API used by VS Code, JetBrains, and other
6
5
  // first-party Copilot integrations. May change without notice.
7
6
  const API_URL = "https://api.github.com/copilot_internal/user";
8
- /**
9
- * GitHub Copilot service adapter using token-based API access.
10
- *
11
- * Credentials resolved via getServiceAccessToken (vault, local axauth, gh CLI).
12
- */
13
- export const copilotAdapter = {
14
- name: "GitHub Copilot",
15
- async fetchUsage() {
16
- const accessToken = await getServiceAccessToken("copilot");
17
- if (!accessToken) {
7
+ /** Fetch GitHub Copilot usage data using a pre-resolved access token */
8
+ async function fetchCopilotUsageWithToken(accessToken) {
9
+ try {
10
+ const response = await fetch(API_URL, {
11
+ headers: {
12
+ Authorization: `Bearer ${accessToken}`,
13
+ Accept: "application/json",
14
+ },
15
+ });
16
+ if (!response.ok) {
17
+ const errorText = await response.text().catch(() => "");
18
18
  return {
19
19
  ok: false,
20
- error: new ApiError("No GitHub Copilot credentials found. Run 'gh auth login' to authenticate."),
20
+ error: new ApiError(`GitHub Copilot API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
21
21
  };
22
22
  }
23
- try {
24
- const response = await fetch(API_URL, {
25
- headers: {
26
- Authorization: `Bearer ${accessToken}`,
27
- Accept: "application/json",
28
- },
29
- });
30
- if (!response.ok) {
31
- const errorText = await response.text().catch(() => "");
32
- return {
33
- ok: false,
34
- error: new ApiError(`GitHub Copilot API request failed: ${String(response.status)} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`, response.status),
35
- };
36
- }
37
- const data = await response.json();
38
- const parseResult = CopilotUsageResponseSchema.safeParse(data);
39
- if (!parseResult.success) {
40
- return {
41
- ok: false,
42
- error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
43
- };
44
- }
45
- return {
46
- ok: true,
47
- value: toServiceUsageData(parseResult.data),
48
- };
49
- }
50
- catch (error) {
51
- const message = error instanceof Error ? error.message : String(error);
23
+ const data = await response.json();
24
+ const parseResult = CopilotUsageResponseSchema.safeParse(data);
25
+ if (!parseResult.success) {
52
26
  return {
53
27
  ok: false,
54
- error: new ApiError(`Failed to fetch GitHub Copilot usage: ${message}`),
28
+ error: new ApiError(`Invalid response format: ${parseResult.error.message}`, undefined, data),
55
29
  };
56
30
  }
57
- },
31
+ return {
32
+ ok: true,
33
+ value: toServiceUsageData(parseResult.data),
34
+ };
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ return {
39
+ ok: false,
40
+ error: new ApiError(`Failed to fetch GitHub Copilot usage: ${message}`),
41
+ };
42
+ }
43
+ }
44
+ export const copilotUsageFetcher = {
45
+ name: "GitHub Copilot",
46
+ fetchUsageWithToken: fetchCopilotUsageWithToken,
58
47
  };
@@ -1,8 +1,2 @@
1
- import type { ServiceAdapter } from "../types/domain.js";
2
- /**
3
- * Gemini service adapter using direct API access.
4
- *
5
- * Uses the OAuth token from Gemini CLI's credential store (~/.gemini/oauth_creds.json)
6
- * to make direct API calls to Google's quota endpoint.
7
- */
8
- export declare const geminiAdapter: ServiceAdapter;
1
+ import type { ServiceUsageFetcher } from "../types/domain.js";
2
+ export declare const geminiUsageFetcher: ServiceUsageFetcher;
@@ -1,43 +1,31 @@
1
1
  import { ApiError } from "../types/domain.js";
2
2
  import { fetchGeminiQuota, fetchGeminiProject, } from "../services/gemini-api.js";
3
- import { getServiceAccessToken } from "../services/get-service-access-token.js";
4
3
  import { toServiceUsageData } from "./parse-gemini-usage.js";
5
- /**
6
- * Gemini service adapter using direct API access.
7
- *
8
- * Uses the OAuth token from Gemini CLI's credential store (~/.gemini/oauth_creds.json)
9
- * to make direct API calls to Google's quota endpoint.
10
- */
11
- export const geminiAdapter = {
12
- name: "Gemini",
13
- async fetchUsage() {
14
- const accessToken = await getServiceAccessToken("gemini");
15
- if (!accessToken) {
16
- return {
17
- ok: false,
18
- error: new ApiError("No Gemini credentials found. Run 'gemini' to authenticate."),
19
- };
20
- }
21
- try {
22
- // Discover project ID for more accurate quotas (best effort)
23
- const projectId = await fetchGeminiProject(accessToken);
24
- // Fetch quota data
25
- const quotaResult = await fetchGeminiQuota(accessToken, projectId);
26
- if (!quotaResult.ok) {
27
- return quotaResult;
28
- }
29
- return {
30
- ok: true,
31
- value: toServiceUsageData(quotaResult.value),
32
- };
4
+ /** Fetch Gemini usage data using a pre-resolved access token */
5
+ async function fetchGeminiUsageWithToken(accessToken) {
6
+ try {
7
+ // Discover project ID for more accurate quotas (best effort)
8
+ const projectId = await fetchGeminiProject(accessToken);
9
+ // Fetch quota data
10
+ const quotaResult = await fetchGeminiQuota(accessToken, projectId);
11
+ if (!quotaResult.ok) {
12
+ return quotaResult;
33
13
  }
34
- catch (error) {
35
- return {
36
- ok: false,
37
- error: error instanceof ApiError
38
- ? error
39
- : new ApiError(`Failed to fetch Gemini usage: ${error instanceof Error ? error.message : String(error)}`),
40
- };
41
- }
42
- },
14
+ return {
15
+ ok: true,
16
+ value: toServiceUsageData(quotaResult.value),
17
+ };
18
+ }
19
+ catch (error) {
20
+ return {
21
+ ok: false,
22
+ error: error instanceof ApiError
23
+ ? error
24
+ : new ApiError(`Failed to fetch Gemini usage: ${error instanceof Error ? error.message : String(error)}`),
25
+ };
26
+ }
27
+ }
28
+ export const geminiUsageFetcher = {
29
+ name: "Gemini",
30
+ fetchUsageWithToken: fetchGeminiUsageWithToken,
43
31
  };
@@ -27,6 +27,7 @@ const parseResetTimestamp = (timestamp) => {
27
27
  export function toServiceUsageData(response) {
28
28
  return {
29
29
  service: "Claude",
30
+ serviceType: "claude",
30
31
  windows: [
31
32
  {
32
33
  name: "5-Hour Usage",
@@ -15,6 +15,7 @@ export function toUsageWindow(name, window) {
15
15
  export function toServiceUsageData(response) {
16
16
  return {
17
17
  service: "ChatGPT",
18
+ serviceType: "codex",
18
19
  planType: response.plan_type,
19
20
  windows: [
20
21
  toUsageWindow("Primary Window (~5 hours)", response.rate_limit.primary_window),
@@ -29,6 +29,7 @@ export function toServiceUsageData(response) {
29
29
  if (premium_interactions.unlimited) {
30
30
  return {
31
31
  service: "GitHub Copilot",
32
+ serviceType: "copilot",
32
33
  planType: response.copilot_plan,
33
34
  windows: [
34
35
  {
@@ -48,6 +49,7 @@ export function toServiceUsageData(response) {
48
49
  : (used / premium_interactions.entitlement) * 100;
49
50
  return {
50
51
  service: "GitHub Copilot",
52
+ serviceType: "copilot",
51
53
  planType: response.copilot_plan,
52
54
  windows: [
53
55
  {
@@ -145,6 +145,7 @@ export function toServiceUsageData(response, planType) {
145
145
  const windows = quotaPools.map((pool) => poolToUsageWindow(pool));
146
146
  return {
147
147
  service: "Gemini",
148
+ serviceType: "gemini",
148
149
  planType,
149
150
  windows,
150
151
  };
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command()
28
28
  .default("text"))
29
29
  .option("--auth-setup <service>", "set up authentication for a service (directs to appropriate CLI)")
30
30
  .option("--auth-status [service]", "check authentication status for services")
31
- .addHelpText("after", () => `\nExamples:\n # Fetch usage for all services\n ${packageJson.name}\n\n # JSON output for a single service\n ${packageJson.name} --service claude --format json\n\n # TSV output for piping to cut, awk, sort\n ${packageJson.name} --format tsv | tail -n +2 | awk -F'\\t' '{print $1, $4"%"}'\n\n # Filter Prometheus metrics with standard tools\n ${packageJson.name} --format prometheus | grep axusage_utilization_percent\n\n # Check authentication status for all services\n ${packageJson.name} --auth-status\n\nSources config file: ${getCredentialSourcesPath()}\n(or set AXUSAGE_SOURCES to JSON to bypass file)\n\n${formatRequiresHelpText()}\nOverride CLI paths: AXUSAGE_CLAUDE_PATH, AXUSAGE_CODEX_PATH, AXUSAGE_GEMINI_PATH, AXUSAGE_GH_PATH\n`);
31
+ .addHelpText("after", () => `\nExamples:\n # Fetch usage for all services\n ${packageJson.name}\n\n # JSON output for a single service\n ${packageJson.name} --service claude --format json\n\n # TSV output for piping to cut, awk, sort\n ${packageJson.name} --format tsv | tail -n +2 | awk -F'\\t' '{print $1, $5"%"}'\n\n # Filter Prometheus metrics with standard tools\n ${packageJson.name} --format prometheus | grep axusage_utilization_percent\n\n # Check authentication status for all services\n ${packageJson.name} --auth-status\n\nSources config file: ${getCredentialSourcesPath()}\n(or set AXUSAGE_SOURCES to JSON to bypass file)\n\n${formatRequiresHelpText()}\nOverride CLI paths: AXUSAGE_CLAUDE_PATH, AXUSAGE_CODEX_PATH, AXUSAGE_GEMINI_PATH, AXUSAGE_GH_PATH\n`);
32
32
  program
33
33
  .command("serve")
34
34
  .description("Start HTTP server exposing Prometheus metrics at /metrics and usage JSON at /usage")
@@ -1,7 +1,13 @@
1
- import type { ApiError, Result, ServiceUsageData } from "../types/domain.js";
1
+ import type { ServiceResult } from "../types/domain.js";
2
2
  export type UsageCommandOptions = {
3
3
  readonly service?: string;
4
4
  readonly format?: "text" | "tsv" | "json" | "prometheus";
5
5
  };
6
6
  export declare function selectServicesToQuery(service?: string): string[];
7
- export declare function fetchServiceUsage(serviceName: string): Promise<Result<ServiceUsageData, ApiError>>;
7
+ /**
8
+ * Fetch usage for all instances of a service type.
9
+ *
10
+ * Resolves tokens and fetches usage for each configured instance in parallel.
11
+ * Produces N results with resolved display names and stable instance IDs.
12
+ */
13
+ export declare function fetchServiceInstanceUsage(serviceType: string): Promise<ServiceResult[]>;
@@ -1,20 +1,87 @@
1
1
  import { ApiError as ApiErrorClass } from "../types/domain.js";
2
- import { getAvailableServices, getServiceAdapter, } from "../services/service-adapter-registry.js";
2
+ import { getAvailableServices, getServiceUsageFetcher, } from "../services/service-adapter-registry.js";
3
+ import { getServiceInstanceConfigs } from "../config/credential-sources.js";
4
+ import { getInstanceAccessToken } from "../services/get-instance-access-token.js";
5
+ import { resolveInstanceDisplayName } from "../services/resolve-service-instances.js";
3
6
  export function selectServicesToQuery(service) {
4
7
  const normalized = service?.toLowerCase();
5
8
  if (!service || normalized === "all")
6
9
  return getAvailableServices();
7
10
  return [service];
8
11
  }
9
- export async function fetchServiceUsage(serviceName) {
10
- const adapter = getServiceAdapter(serviceName);
11
- if (!adapter) {
12
+ /**
13
+ * Derive a stable instance identifier for metrics labeling.
14
+ * Uses credential name when available, otherwise falls back to service type.
15
+ */
16
+ function deriveInstanceId(serviceType, credentialName, index, total) {
17
+ if (credentialName)
18
+ return credentialName;
19
+ if (total === 1)
20
+ return serviceType;
21
+ return `${serviceType}-${String(index + 1)}`;
22
+ }
23
+ /**
24
+ * Fetch usage for all instances of a service type.
25
+ *
26
+ * Resolves tokens and fetches usage for each configured instance in parallel.
27
+ * Produces N results with resolved display names and stable instance IDs.
28
+ */
29
+ export async function fetchServiceInstanceUsage(serviceType) {
30
+ const normalized = serviceType.toLowerCase();
31
+ const fetcher = getServiceUsageFetcher(normalized);
32
+ if (!fetcher) {
12
33
  const available = getAvailableServices().join(", ");
34
+ return [
35
+ {
36
+ service: serviceType,
37
+ result: {
38
+ ok: false,
39
+ error: new ApiErrorClass(`Unknown service "${serviceType}". Supported services: ${available}. ` +
40
+ "Run 'axusage --help' for usage."),
41
+ },
42
+ },
43
+ ];
44
+ }
45
+ const instanceConfigs = getServiceInstanceConfigs(normalized);
46
+ const results = await Promise.all(instanceConfigs.map(async (config, index) => {
47
+ const tokenResult = await getInstanceAccessToken(normalized, config);
48
+ if (!tokenResult.token) {
49
+ const label = config.name ?? normalized;
50
+ const isVaultPath = config.source === "vault" ||
51
+ (config.source === "auto" && config.name !== undefined);
52
+ return {
53
+ service: normalized,
54
+ result: {
55
+ ok: false,
56
+ error: new ApiErrorClass(`No credentials found for ${label}. ` +
57
+ (isVaultPath
58
+ ? config.name
59
+ ? `Check that vault is configured and credential "${config.name}" exists.`
60
+ : `Check that vault is configured. Set a credential name in config.`
61
+ : `Run the agent CLI to authenticate.`)),
62
+ },
63
+ };
64
+ }
65
+ const usageResult = await fetcher.fetchUsageWithToken(tokenResult.token);
66
+ if (usageResult.ok) {
67
+ const displayName = resolveInstanceDisplayName(config.displayName, tokenResult.vaultDisplayName, fetcher.name, index, instanceConfigs.length);
68
+ const instanceId = deriveInstanceId(normalized, config.name, index, instanceConfigs.length);
69
+ return {
70
+ service: normalized,
71
+ result: {
72
+ ok: true,
73
+ value: {
74
+ ...usageResult.value,
75
+ service: displayName,
76
+ instanceId,
77
+ },
78
+ },
79
+ };
80
+ }
13
81
  return {
14
- ok: false,
15
- error: new ApiErrorClass(`Unknown service "${serviceName}". Supported services: ${available}. ` +
16
- "Run 'axusage --help' for usage."),
82
+ service: normalized,
83
+ result: usageResult,
17
84
  };
18
- }
19
- return await adapter.fetchUsage();
85
+ }));
86
+ return results;
20
87
  }
@@ -97,12 +97,15 @@ export async function serveCommand(options) {
97
97
  process.exit(1);
98
98
  }, 5000);
99
99
  forceExit.unref();
100
- server.stop().then(() => {
100
+ server
101
+ .stop()
102
+ .finally(() => {
101
103
  clearTimeout(forceExit);
104
+ })
105
+ .then(() => {
102
106
  // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
103
107
  process.exit(0);
104
108
  }, (error) => {
105
- clearTimeout(forceExit);
106
109
  console.error("Error during shutdown:", error);
107
110
  // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
108
111
  process.exit(1);