axusage 3.6.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 (31) 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/usage-command.d.ts +1 -0
  18. package/dist/commands/usage-command.js +4 -5
  19. package/dist/config/credential-sources.d.ts +16 -11
  20. package/dist/config/credential-sources.js +48 -27
  21. package/dist/services/get-instance-access-token.d.ts +20 -0
  22. package/dist/services/{get-service-access-token.js → get-instance-access-token.js} +31 -52
  23. package/dist/services/resolve-service-instances.d.ts +13 -0
  24. package/dist/services/resolve-service-instances.js +24 -0
  25. package/dist/services/service-adapter-registry.d.ts +3 -12
  26. package/dist/services/service-adapter-registry.js +14 -14
  27. package/dist/types/domain.d.ts +9 -3
  28. package/dist/utils/format-prometheus-metrics.js +8 -3
  29. package/dist/utils/format-service-usage.js +4 -1
  30. package/package.json +6 -6
  31. 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
  }
@@ -2,6 +2,7 @@ import type { ServiceResult } from "../types/domain.js";
2
2
  import type { UsageCommandOptions } from "./fetch-service-usage.js";
3
3
  /**
4
4
  * Fetches usage for all requested services in parallel.
5
+ * Each service type may produce multiple results (multi-instance support).
5
6
  */
6
7
  export declare function fetchServicesInParallel(servicesToQuery: string[]): Promise<ServiceResult[]>;
7
8
  /**
@@ -1,16 +1,15 @@
1
1
  import { formatServiceUsageData, formatServiceUsageDataAsJson, formatServiceUsageAsTsv, toJsonObject, } from "../utils/format-service-usage.js";
2
2
  import { formatPrometheusMetrics } from "../utils/format-prometheus-metrics.js";
3
- import { fetchServiceUsage, selectServicesToQuery, } from "./fetch-service-usage.js";
3
+ import { fetchServiceInstanceUsage, selectServicesToQuery, } from "./fetch-service-usage.js";
4
4
  import { isAuthFailure } from "./run-auth-setup.js";
5
5
  import { chalk } from "../utils/color.js";
6
6
  /**
7
7
  * Fetches usage for all requested services in parallel.
8
+ * Each service type may produce multiple results (multi-instance support).
8
9
  */
9
10
  export async function fetchServicesInParallel(servicesToQuery) {
10
- return await Promise.all(servicesToQuery.map(async (serviceName) => {
11
- const result = await fetchServiceUsage(serviceName);
12
- return { service: serviceName, result };
13
- }));
11
+ const nestedResults = await Promise.all(servicesToQuery.map((serviceName) => fetchServiceInstanceUsage(serviceName)));
12
+ return nestedResults.flat();
14
13
  }
15
14
  /**
16
15
  * Executes the usage command
@@ -4,7 +4,9 @@
4
4
  * Supports three modes:
5
5
  * - "local": Use local credentials from axauth (default behavior)
6
6
  * - "vault": Fetch credentials from axvault server
7
- * - "auto": Try vault first if configured and credential name provided, fallback to local
7
+ * - "auto": Without a credential name, uses local credentials. With a named
8
+ * credential, requires vault (no local fallback) to prevent returning the same
9
+ * local token for multiple instances. Vault must be configured for named credentials.
8
10
  */
9
11
  import { z } from "zod";
10
12
  import type { SupportedService } from "../services/supported-service.js";
@@ -15,18 +17,12 @@ declare const CredentialSourceType: z.ZodEnum<{
15
17
  vault: "vault";
16
18
  }>;
17
19
  type CredentialSourceType = z.infer<typeof CredentialSourceType>;
18
- /** Resolved source config with normalized fields */
19
- interface ResolvedSourceConfig {
20
+ /** Resolved instance config with display name */
21
+ interface ResolvedInstanceConfig {
20
22
  source: CredentialSourceType;
21
23
  name: string | undefined;
24
+ displayName: string | undefined;
22
25
  }
23
- /**
24
- * Get the resolved source config for a specific service.
25
- *
26
- * @param service - Service ID (e.g., "claude", "codex", "gemini")
27
- * @returns Resolved config with source type and optional credential name
28
- */
29
- declare function getServiceSourceConfig(service: SupportedService): ResolvedSourceConfig;
30
26
  /**
31
27
  * Get the credential sources config file path.
32
28
  *
@@ -35,4 +31,13 @@ declare function getServiceSourceConfig(service: SupportedService): ResolvedSour
35
31
  * directory during construction).
36
32
  */
37
33
  declare function getCredentialSourcesPath(): string;
38
- export { getServiceSourceConfig, getCredentialSourcesPath };
34
+ /**
35
+ * Get all instance configs for a service, normalizing all config forms to an array.
36
+ *
37
+ * - String shorthand → single instance with that source
38
+ * - Object → single instance
39
+ * - Array → multiple instances
40
+ */
41
+ declare function getServiceInstanceConfigs(service: SupportedService): ResolvedInstanceConfig[];
42
+ export { getServiceInstanceConfigs, getCredentialSourcesPath };
43
+ export type { ResolvedInstanceConfig };
@@ -4,7 +4,9 @@
4
4
  * Supports three modes:
5
5
  * - "local": Use local credentials from axauth (default behavior)
6
6
  * - "vault": Fetch credentials from axvault server
7
- * - "auto": Try vault first if configured and credential name provided, fallback to local
7
+ * - "auto": Without a credential name, uses local credentials. With a named
8
+ * credential, requires vault (no local fallback) to prevent returning the same
9
+ * local token for multiple instances. Vault must be configured for named credentials.
8
10
  */
9
11
  import Conf from "conf";
10
12
  import envPaths from "env-paths";
@@ -12,13 +14,17 @@ import path from "node:path";
12
14
  import { z } from "zod";
13
15
  /** Credential source type */
14
16
  const CredentialSourceType = z.enum(["auto", "local", "vault"]);
15
- /** Service source config - either a string shorthand or object with name */
17
+ /** Instance source config - object form with optional name and displayName */
18
+ const InstanceSourceConfig = z.object({
19
+ source: CredentialSourceType,
20
+ name: z.string().min(1).optional(),
21
+ displayName: z.string().min(1).optional(),
22
+ });
23
+ /** Service source config - string shorthand, object, or array of objects */
16
24
  const ServiceSourceConfig = z.union([
17
25
  CredentialSourceType,
18
- z.object({
19
- source: CredentialSourceType,
20
- name: z.string().optional(),
21
- }),
26
+ InstanceSourceConfig,
27
+ z.array(InstanceSourceConfig).min(1),
22
28
  ]);
23
29
  /** Full sources config - map of service ID to source config */
24
30
  const SourcesConfig = z.record(z.string(), ServiceSourceConfig);
@@ -96,26 +102,6 @@ function getCredentialSourceConfig() {
96
102
  // Priority 3: Empty (defaults apply)
97
103
  return {};
98
104
  }
99
- /**
100
- * Get the resolved source config for a specific service.
101
- *
102
- * @param service - Service ID (e.g., "claude", "codex", "gemini")
103
- * @returns Resolved config with source type and optional credential name
104
- */
105
- function getServiceSourceConfig(service) {
106
- const config = getCredentialSourceConfig();
107
- const serviceConfig = config[service];
108
- // Default: auto mode with no credential name
109
- if (serviceConfig === undefined) {
110
- return { source: "auto", name: undefined };
111
- }
112
- // String shorthand: just the source type
113
- if (typeof serviceConfig === "string") {
114
- return { source: serviceConfig, name: undefined };
115
- }
116
- // Object: source and name
117
- return { source: serviceConfig.source, name: serviceConfig.name };
118
- }
119
105
  /**
120
106
  * Get the credential sources config file path.
121
107
  *
@@ -127,4 +113,39 @@ function getCredentialSourcesPath() {
127
113
  const configDirectory = envPaths("axusage", { suffix: "" }).config;
128
114
  return path.resolve(configDirectory, "config.json");
129
115
  }
130
- export { getServiceSourceConfig, getCredentialSourcesPath };
116
+ /**
117
+ * Get all instance configs for a service, normalizing all config forms to an array.
118
+ *
119
+ * - String shorthand → single instance with that source
120
+ * - Object → single instance
121
+ * - Array → multiple instances
122
+ */
123
+ function getServiceInstanceConfigs(service) {
124
+ const config = getCredentialSourceConfig();
125
+ const serviceConfig = config[service];
126
+ // Default: single auto instance
127
+ if (serviceConfig === undefined) {
128
+ return [{ source: "auto", name: undefined, displayName: undefined }];
129
+ }
130
+ // String shorthand: single instance with that source
131
+ if (typeof serviceConfig === "string") {
132
+ return [{ source: serviceConfig, name: undefined, displayName: undefined }];
133
+ }
134
+ // Array: multiple instances
135
+ if (Array.isArray(serviceConfig)) {
136
+ return serviceConfig.map((instance) => ({
137
+ source: instance.source,
138
+ name: instance.name,
139
+ displayName: instance.displayName,
140
+ }));
141
+ }
142
+ // Object: single instance
143
+ return [
144
+ {
145
+ source: serviceConfig.source,
146
+ name: serviceConfig.name,
147
+ displayName: serviceConfig.displayName,
148
+ },
149
+ ];
150
+ }
151
+ export { getServiceInstanceConfigs, getCredentialSourcesPath };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Instance-aware credential fetcher.
3
+ *
4
+ * Resolves an access token for a specific service instance config,
5
+ * returning vault metadata (displayName) alongside the token.
6
+ */
7
+ import type { ResolvedInstanceConfig } from "../config/credential-sources.js";
8
+ /** Result of resolving an instance token */
9
+ interface InstanceTokenResult {
10
+ token: string | undefined;
11
+ vaultDisplayName: string | undefined;
12
+ }
13
+ /**
14
+ * Get access token for a specific service instance.
15
+ *
16
+ * Returns vault metadata (displayName) alongside the token
17
+ * for multi-instance identification.
18
+ */
19
+ declare function getInstanceAccessToken(service: string, config: ResolvedInstanceConfig): Promise<InstanceTokenResult>;
20
+ export { getInstanceAccessToken };
@@ -1,13 +1,10 @@
1
1
  /**
2
- * Unified credential fetcher for services.
2
+ * Instance-aware credential fetcher.
3
3
  *
4
- * Fetches access tokens based on per-service configuration:
5
- * - "local": From local axauth credential store
6
- * - "vault": From axvault server
7
- * - "auto": Try vault first if configured, fallback to local
4
+ * Resolves an access token for a specific service instance config,
5
+ * returning vault metadata (displayName) alongside the token.
8
6
  */
9
7
  import { fetchVaultCredentials, getAgentAccessToken, isVaultConfigured, } from "axauth";
10
- import { getServiceSourceConfig } from "../config/credential-sources.js";
11
8
  import { getCopilotTokenFromCustomGhPath } from "../utils/copilot-gh-token.js";
12
9
  /**
13
10
  * Extract access token from vault credentials.
@@ -42,41 +39,32 @@ function extractAccessToken(credentials) {
42
39
  }
43
40
  return undefined;
44
41
  }
45
- /**
46
- * Fetch access token from vault.
47
- *
48
- * @returns Access token string or undefined if not available
49
- */
50
- async function fetchFromVault(agentId, credentialName) {
42
+ /** Fetch access token from vault, returning metadata alongside the token */
43
+ async function fetchFromVaultWithMetadata(agentId, credentialName) {
51
44
  try {
52
45
  const result = await fetchVaultCredentials({
53
46
  agentId,
54
47
  name: credentialName,
55
48
  });
56
49
  if (!result.ok) {
57
- // Log warning for debugging, but don't fail hard
58
50
  if (result.reason !== "not-configured" && result.reason !== "not-found") {
59
51
  console.error(`[axusage] Vault fetch failed for ${agentId}/${credentialName}: ${result.reason}`);
60
52
  }
61
- return undefined;
53
+ return { token: undefined, vaultDisplayName: undefined };
62
54
  }
63
55
  const token = extractAccessToken(result.credentials);
64
56
  if (!token) {
65
57
  console.error(`[axusage] Vault credentials for ${agentId}/${credentialName} missing access token. ` +
66
58
  `Credential type: ${result.credentials.type}`);
67
59
  }
68
- return token;
60
+ return { token, vaultDisplayName: result.displayName };
69
61
  }
70
62
  catch (error) {
71
63
  console.error(`[axusage] Vault fetch error for ${agentId}/${credentialName}: ${error instanceof Error ? error.message : String(error)}`);
72
- return undefined;
64
+ return { token: undefined, vaultDisplayName: undefined };
73
65
  }
74
66
  }
75
- /**
76
- * Fetch access token from local credential store.
77
- *
78
- * @returns Access token string or undefined if not available
79
- */
67
+ /** Fetch access token from local credential store */
80
68
  async function fetchFromLocal(agentId) {
81
69
  try {
82
70
  const token = await getAgentAccessToken(agentId);
@@ -92,55 +80,46 @@ async function fetchFromLocal(agentId) {
92
80
  return undefined;
93
81
  }
94
82
  /**
95
- * Get access token for a service.
96
- *
97
- * Uses the configured credential source for the service:
98
- * - "local": Fetch from local axauth credential store
99
- * - "vault": Fetch from axvault server (requires credential name)
100
- * - "auto": Try vault if configured and name provided, fallback to local
83
+ * Get access token for a specific service instance.
101
84
  *
102
- * @param service - Service ID (e.g., "claude", "codex", "gemini")
103
- * @returns Access token string or undefined if not available
104
- *
105
- * @example
106
- * const token = await getServiceAccessToken("claude");
107
- * if (!token) {
108
- * console.error("No credentials found for Claude");
109
- * }
85
+ * Returns vault metadata (displayName) alongside the token
86
+ * for multi-instance identification.
110
87
  */
111
- async function getServiceAccessToken(service) {
112
- const config = getServiceSourceConfig(service);
88
+ async function getInstanceAccessToken(service, config) {
113
89
  const agentId = service;
114
90
  switch (config.source) {
115
91
  case "local": {
116
- return fetchFromLocal(agentId);
92
+ const token = await fetchFromLocal(agentId);
93
+ return { token, vaultDisplayName: undefined };
117
94
  }
118
95
  case "vault": {
119
96
  if (!config.name) {
120
97
  console.error(`[axusage] Vault source requires credential name for ${service}. ` +
121
98
  `Set {"${service}": {"source": "vault", "name": "your-name"}} in config.`);
122
- return undefined;
99
+ return { token: undefined, vaultDisplayName: undefined };
123
100
  }
124
- const token = await fetchFromVault(agentId, config.name);
125
- if (!token) {
126
- // User explicitly selected vault but it failed - provide clear feedback
101
+ const result = await fetchFromVaultWithMetadata(agentId, config.name);
102
+ if (!result.token) {
127
103
  console.error(`[axusage] Vault credential fetch failed for ${service}. ` +
128
104
  `Check that vault is configured (AXVAULT env) and credential "${config.name}" exists.`);
129
105
  }
130
- return token;
106
+ return result;
131
107
  }
132
108
  case "auto": {
133
- // Auto mode: try vault first if configured and name provided
134
- if (config.name && isVaultConfigured()) {
135
- const vaultToken = await fetchFromVault(agentId, config.name);
136
- if (vaultToken) {
137
- return vaultToken;
109
+ if (config.name) {
110
+ // Named credential: vault-only to avoid silently returning
111
+ // the same local token for multiple instances
112
+ if (!isVaultConfigured()) {
113
+ console.error(`[axusage] Named credential "${config.name}" for ${service} requires vault, ` +
114
+ `but vault is not configured. Set AXVAULT env or use source "local" instead.`);
115
+ return { token: undefined, vaultDisplayName: undefined };
138
116
  }
139
- // Fallback to local if vault failed
117
+ return fetchFromVaultWithMetadata(agentId, config.name);
140
118
  }
141
- // No credential name or vault not configured: use local only
142
- return fetchFromLocal(agentId);
119
+ // No credential name: use local
120
+ const token = await fetchFromLocal(agentId);
121
+ return { token, vaultDisplayName: undefined };
143
122
  }
144
123
  }
145
124
  }
146
- export { getServiceAccessToken };
125
+ export { getInstanceAccessToken };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Pure functions for resolving display names for service instances.
3
+ *
4
+ * Priority: config displayName > vault displayName > auto-number (multi) / adapter default (single)
5
+ */
6
+ /**
7
+ * Resolve the display name for a service instance.
8
+ *
9
+ * For single-instance configs: config displayName > vault displayName > adapter default name
10
+ * For multi-instance without displayName: "Claude #1", "Claude #2"
11
+ */
12
+ declare function resolveInstanceDisplayName(configDisplayName: string | undefined, vaultDisplayName: string | undefined, defaultName: string, index: number, total: number): string;
13
+ export { resolveInstanceDisplayName };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure functions for resolving display names for service instances.
3
+ *
4
+ * Priority: config displayName > vault displayName > auto-number (multi) / adapter default (single)
5
+ */
6
+ /**
7
+ * Resolve the display name for a service instance.
8
+ *
9
+ * For single-instance configs: config displayName > vault displayName > adapter default name
10
+ * For multi-instance without displayName: "Claude #1", "Claude #2"
11
+ */
12
+ function resolveInstanceDisplayName(configDisplayName, vaultDisplayName, defaultName, index, total) {
13
+ // Explicit displayName always wins
14
+ if (configDisplayName)
15
+ return configDisplayName;
16
+ if (vaultDisplayName)
17
+ return vaultDisplayName;
18
+ // Single instance: use adapter default
19
+ if (total === 1)
20
+ return defaultName;
21
+ // Multi-instance without displayName: auto-number
22
+ return `${defaultName} #${String(index + 1)}`;
23
+ }
24
+ export { resolveInstanceDisplayName };
@@ -1,17 +1,8 @@
1
- import type { ServiceAdapter } from "../types/domain.js";
1
+ import type { ServiceUsageFetcher } from "../types/domain.js";
2
2
  /**
3
- * Registry of available service adapters
3
+ * Get a token-based usage fetcher by service type
4
4
  */
5
- export declare const SERVICE_ADAPTERS: {
6
- readonly claude: ServiceAdapter;
7
- readonly codex: ServiceAdapter;
8
- readonly copilot: ServiceAdapter;
9
- readonly gemini: ServiceAdapter;
10
- };
11
- /**
12
- * Get a service adapter by name
13
- */
14
- export declare function getServiceAdapter(name: string): ServiceAdapter | undefined;
5
+ export declare function getServiceUsageFetcher(name: string): ServiceUsageFetcher | undefined;
15
6
  /**
16
7
  * Get all available service names
17
8
  */
@@ -1,26 +1,26 @@
1
- import { codexAdapter } from "../adapters/codex.js";
2
- import { claudeAdapter } from "../adapters/claude.js";
3
- import { geminiAdapter } from "../adapters/gemini.js";
4
- import { copilotAdapter } from "../adapters/copilot.js";
1
+ import { codexUsageFetcher } from "../adapters/codex.js";
2
+ import { claudeUsageFetcher } from "../adapters/claude.js";
3
+ import { geminiUsageFetcher } from "../adapters/gemini.js";
4
+ import { copilotUsageFetcher } from "../adapters/copilot.js";
5
5
  /**
6
- * Registry of available service adapters
6
+ * Registry of token-based usage fetchers
7
7
  */
8
- export const SERVICE_ADAPTERS = {
9
- claude: claudeAdapter,
10
- codex: codexAdapter,
11
- copilot: copilotAdapter,
12
- gemini: geminiAdapter,
8
+ const SERVICE_USAGE_FETCHERS = {
9
+ claude: claudeUsageFetcher,
10
+ codex: codexUsageFetcher,
11
+ copilot: copilotUsageFetcher,
12
+ gemini: geminiUsageFetcher,
13
13
  };
14
14
  /**
15
- * Get a service adapter by name
15
+ * Get a token-based usage fetcher by service type
16
16
  */
17
- export function getServiceAdapter(name) {
17
+ export function getServiceUsageFetcher(name) {
18
18
  const key = name.toLowerCase();
19
- return SERVICE_ADAPTERS[key];
19
+ return SERVICE_USAGE_FETCHERS[key];
20
20
  }
21
21
  /**
22
22
  * Get all available service names
23
23
  */
24
24
  export function getAvailableServices() {
25
- return Object.keys(SERVICE_ADAPTERS);
25
+ return Object.keys(SERVICE_USAGE_FETCHERS);
26
26
  }
@@ -14,7 +14,12 @@ export type UsageWindow = {
14
14
  * Complete usage data for a service
15
15
  */
16
16
  export type ServiceUsageData = {
17
+ /** Display name (may be overridden by instance displayName) */
17
18
  readonly service: string;
19
+ /** Stable machine key (e.g., "claude", "codex") for filtering and labeling */
20
+ readonly serviceType: string;
21
+ /** Stable per-instance identifier for metrics (derived from credential name or config key) */
22
+ readonly instanceId?: string;
18
23
  readonly planType?: string;
19
24
  readonly windows: readonly UsageWindow[];
20
25
  readonly metadata?: {
@@ -41,11 +46,12 @@ export declare class ApiError extends Error {
41
46
  constructor(message: string, status?: number, body?: unknown);
42
47
  }
43
48
  /**
44
- * Service adapter interface
49
+ * Token-based usage fetcher for a service.
50
+ * Tokens are resolved externally via credential sources.
45
51
  */
46
- export interface ServiceAdapter {
52
+ export interface ServiceUsageFetcher {
47
53
  readonly name: string;
48
- fetchUsage(): Promise<Result<ServiceUsageData, ApiError>>;
54
+ fetchUsageWithToken(accessToken: string): Promise<Result<ServiceUsageData, ApiError>>;
49
55
  }
50
56
  /**
51
57
  * Result of fetching usage for a single service.
@@ -9,18 +9,23 @@ export async function formatPrometheusMetrics(data, now) {
9
9
  const utilizationGauge = new Gauge({
10
10
  name: "axusage_utilization_percent",
11
11
  help: "Current utilization percentage by service/window",
12
- labelNames: ["service", "window"],
12
+ labelNames: ["service", "service_type", "instance_id", "window"],
13
13
  registers: [registry],
14
14
  });
15
15
  const rateGauge = new Gauge({
16
16
  name: "axusage_usage_rate",
17
17
  help: "Usage rate (utilization / elapsed fraction of period); >1 means over budget",
18
- labelNames: ["service", "window"],
18
+ labelNames: ["service", "service_type", "instance_id", "window"],
19
19
  registers: [registry],
20
20
  });
21
21
  for (const entry of data) {
22
22
  for (const w of entry.windows) {
23
- const labels = { service: entry.service, window: w.name };
23
+ const labels = {
24
+ service: entry.service,
25
+ service_type: entry.serviceType,
26
+ instance_id: entry.instanceId ?? entry.serviceType,
27
+ window: w.name,
28
+ };
24
29
  utilizationGauge.set(labels, w.utilization);
25
30
  const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, now);
26
31
  if (rate !== undefined) {
@@ -71,6 +71,8 @@ export function formatServiceUsageData(data) {
71
71
  export function toJsonObject(data, now) {
72
72
  return {
73
73
  service: data.service,
74
+ serviceType: data.serviceType,
75
+ ...(data.instanceId !== undefined && { instanceId: data.instanceId }),
74
76
  planType: data.planType,
75
77
  windows: data.windows.map((w) => {
76
78
  const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, now);
@@ -92,7 +94,7 @@ export function formatServiceUsageDataAsJson(data) {
92
94
  // eslint-disable-next-line unicorn/no-null -- JSON.stringify requires null for no replacer
93
95
  return JSON.stringify(toJsonObject(data, Date.now()), null, 2);
94
96
  }
95
- const TSV_HEADER = "SERVICE\tPLAN\tWINDOW\tUTILIZATION\tRATE\tRESETS_AT";
97
+ const TSV_HEADER = "SERVICE\tSERVICE_TYPE\tPLAN\tWINDOW\tUTILIZATION\tRATE\tRESETS_AT";
96
98
  /**
97
99
  * Sanitizes a string for TSV output by replacing tabs and newlines with spaces.
98
100
  */
@@ -108,6 +110,7 @@ function formatServiceUsageRowsAsTsv(data) {
108
110
  const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, Date.now());
109
111
  return [
110
112
  sanitizeForTsv(data.service),
113
+ sanitizeForTsv(data.serviceType),
111
114
  sanitizeForTsv(data.planType ?? "-"),
112
115
  sanitizeForTsv(w.name),
113
116
  w.utilization.toFixed(2),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "axusage",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "3.6.0",
5
+ "version": "3.7.0",
6
6
  "description": "Monitor API usage across Claude, ChatGPT, GitHub Copilot, and Gemini from a single CLI",
7
7
  "repository": {
8
8
  "type": "git",
@@ -47,13 +47,13 @@
47
47
  "llm",
48
48
  "monitoring"
49
49
  ],
50
- "packageManager": "pnpm@10.30.1",
50
+ "packageManager": "pnpm@10.30.2",
51
51
  "engines": {
52
52
  "node": ">=22.14.0"
53
53
  },
54
54
  "dependencies": {
55
55
  "@commander-js/extra-typings": "^14.0.0",
56
- "axauth": "^3.1.6",
56
+ "axauth": "^3.2.0",
57
57
  "chalk": "^5.6.2",
58
58
  "commander": "^14.0.3",
59
59
  "conf": "^15.1.0",
@@ -65,10 +65,10 @@
65
65
  "devDependencies": {
66
66
  "@total-typescript/ts-reset": "^0.6.1",
67
67
  "@types/express": "^5.0.6",
68
- "@types/node": "^25.3.0",
68
+ "@types/node": "^25.3.1",
69
69
  "@vitest/coverage-v8": "^4.0.18",
70
- "eslint": "^10.0.1",
71
- "eslint-config-axkit": "^1.2.1",
70
+ "eslint": "^10.0.2",
71
+ "eslint-config-axkit": "^1.3.0",
72
72
  "fta-check": "^1.5.1",
73
73
  "fta-cli": "^3.0.0",
74
74
  "knip": "^5.85.0",
@@ -1,28 +0,0 @@
1
- /**
2
- * Unified credential fetcher for services.
3
- *
4
- * Fetches access tokens based on per-service configuration:
5
- * - "local": From local axauth credential store
6
- * - "vault": From axvault server
7
- * - "auto": Try vault first if configured, fallback to local
8
- */
9
- import type { SupportedService } from "./supported-service.js";
10
- /**
11
- * Get access token for a service.
12
- *
13
- * Uses the configured credential source for the service:
14
- * - "local": Fetch from local axauth credential store
15
- * - "vault": Fetch from axvault server (requires credential name)
16
- * - "auto": Try vault if configured and name provided, fallback to local
17
- *
18
- * @param service - Service ID (e.g., "claude", "codex", "gemini")
19
- * @returns Access token string or undefined if not available
20
- *
21
- * @example
22
- * const token = await getServiceAccessToken("claude");
23
- * if (!token) {
24
- * console.error("No credentials found for Claude");
25
- * }
26
- */
27
- declare function getServiceAccessToken(service: SupportedService): Promise<string | undefined>;
28
- export { getServiceAccessToken };