codex-usage-analyzer 0.1.0 → 0.2.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.
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +125 -185
- package/SECURITY.md +44 -0
- package/bin/codex-usage-analyzer.js +4 -2
- package/docs/account-usage-contract.md +85 -0
- package/docs/account-usage.schema.json +99 -0
- package/docs/downstream-integration.md +195 -0
- package/package.json +20 -8
- package/src/account-usage.js +115 -0
- package/src/app-server-client.js +229 -0
- package/src/cli.js +64 -51
- package/src/errors.js +46 -0
- package/src/format-account-usage.js +81 -0
- package/src/index.d.ts +63 -33
- package/src/index.js +10 -14
- package/src/analyze.js +0 -239
- package/src/fixtures/sample-v2-snapshot.js +0 -103
- package/src/parser/activity-aggregate.js +0 -264
- package/src/parser/asset-aggregate.js +0 -363
- package/src/parser/codex-home.js +0 -37
- package/src/parser/model-aggregate.js +0 -204
- package/src/parser/session-jsonl.js +0 -231
- package/src/parser/skill-plugin-aggregate.js +0 -228
- package/src/parser/token-aggregate.js +0 -229
- package/src/snapshot/index.js +0 -6
- package/src/snapshot/v2-schema.js +0 -519
- package/src/snapshot/v2-types.d.ts +0 -109
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Codex Account Usage Contract",
|
|
4
|
+
"description": "Identity-free account usage output produced by codex-usage-analyzer.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"contractVersion",
|
|
9
|
+
"capturedAt",
|
|
10
|
+
"summary",
|
|
11
|
+
"dailyUsageBuckets"
|
|
12
|
+
],
|
|
13
|
+
"properties": {
|
|
14
|
+
"contractVersion": {
|
|
15
|
+
"const": 1
|
|
16
|
+
},
|
|
17
|
+
"capturedAt": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"format": "date-time"
|
|
20
|
+
},
|
|
21
|
+
"summary": {
|
|
22
|
+
"$ref": "#/definitions/summary"
|
|
23
|
+
},
|
|
24
|
+
"dailyUsageBuckets": {
|
|
25
|
+
"oneOf": [
|
|
26
|
+
{
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {
|
|
29
|
+
"$ref": "#/definitions/dailyUsageBucket"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"type": "null"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"definitions": {
|
|
39
|
+
"nullableNonNegativeInteger": {
|
|
40
|
+
"oneOf": [
|
|
41
|
+
{
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"maximum": 9007199254740991
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"type": "null"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"summary": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"required": [
|
|
55
|
+
"lifetimeTokens",
|
|
56
|
+
"peakDailyTokens",
|
|
57
|
+
"longestRunningTurnSec",
|
|
58
|
+
"currentStreakDays",
|
|
59
|
+
"longestStreakDays"
|
|
60
|
+
],
|
|
61
|
+
"properties": {
|
|
62
|
+
"lifetimeTokens": {
|
|
63
|
+
"$ref": "#/definitions/nullableNonNegativeInteger"
|
|
64
|
+
},
|
|
65
|
+
"peakDailyTokens": {
|
|
66
|
+
"$ref": "#/definitions/nullableNonNegativeInteger"
|
|
67
|
+
},
|
|
68
|
+
"longestRunningTurnSec": {
|
|
69
|
+
"$ref": "#/definitions/nullableNonNegativeInteger"
|
|
70
|
+
},
|
|
71
|
+
"currentStreakDays": {
|
|
72
|
+
"$ref": "#/definitions/nullableNonNegativeInteger"
|
|
73
|
+
},
|
|
74
|
+
"longestStreakDays": {
|
|
75
|
+
"$ref": "#/definitions/nullableNonNegativeInteger"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"dailyUsageBucket": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"required": [
|
|
83
|
+
"startDate",
|
|
84
|
+
"tokens"
|
|
85
|
+
],
|
|
86
|
+
"properties": {
|
|
87
|
+
"startDate": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
|
90
|
+
},
|
|
91
|
+
"tokens": {
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 0,
|
|
94
|
+
"maximum": 9007199254740991
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Downstream Integration Guide
|
|
2
|
+
|
|
3
|
+
This guide defines the boundary between `codex-usage-analyzer` and a service that stores account usage or renders a profile card.
|
|
4
|
+
|
|
5
|
+
The CLI owns one artifact: the identity-free [Account Usage Contract](account-usage-contract.md). A downstream service owns identity, authorization, submission, persistence, rendering, cache behavior, privacy controls, and deletion.
|
|
6
|
+
|
|
7
|
+
## Recommended architecture
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Codex app-server
|
|
11
|
+
|
|
|
12
|
+
v
|
|
13
|
+
codex-usage-analyzer --json
|
|
14
|
+
|
|
|
15
|
+
| identity-free usage document
|
|
16
|
+
v
|
|
17
|
+
downstream submit API ---- GitHub account binding
|
|
18
|
+
|
|
|
19
|
+
+---- validated usage storage
|
|
20
|
+
+---- sanitized identity projection
|
|
21
|
+
+---- stable card/image endpoint
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Do not extend the CLI document with downstream fields. Keep the usage contract reusable and combine identity only after the downstream has authenticated the submitter.
|
|
25
|
+
|
|
26
|
+
## Responsibility boundary
|
|
27
|
+
|
|
28
|
+
| Concern | CLI | Downstream |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Read account usage | Call the official app-server method | Never request Codex credentials |
|
|
31
|
+
| Normalize usage | Emit contract-versioned allowlisted fields | Validate the complete contract before accepting it |
|
|
32
|
+
| GitHub identity | None | Authenticate and bind a stable GitHub user id |
|
|
33
|
+
| submit token | None | Issue, hash, rotate, revoke, and rate-limit its own token |
|
|
34
|
+
| Storage | None | Store only the minimum usage and identity projection needed |
|
|
35
|
+
| Card rendering | None | Format values, localize labels, and render the image |
|
|
36
|
+
| HTTP cache | None | Define freshness, validators, and revision behavior |
|
|
37
|
+
| Privacy and delete controls | None | Explain visibility and retention; provide revoke and delete paths |
|
|
38
|
+
|
|
39
|
+
## GitHub identity
|
|
40
|
+
|
|
41
|
+
Use the downstream's GitHub OAuth or GitHub App session as the account ownership proof. Bind records to GitHub's stable numeric user id, not to a mutable login or display name.
|
|
42
|
+
|
|
43
|
+
Recommended normalized fields inside the downstream only:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"provider": "github",
|
|
48
|
+
"providerUserId": "12345678",
|
|
49
|
+
"username": "octocat",
|
|
50
|
+
"displayName": "The Octocat",
|
|
51
|
+
"avatarUrl": "https://avatars.githubusercontent.com/u/12345678?v=4"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The values are illustrative. Map GitHub `login` to `username`, use `name` for `displayName` when present, and fall back to `login` when it is absent. Map `avatar_url` to `avatarUrl` only after validating the response from GitHub.
|
|
56
|
+
|
|
57
|
+
Never trust identity fields supplied beside a CLI upload. A user who can submit usage must not be able to overwrite another account's name, username, avatar, ownership id, or public URL.
|
|
58
|
+
|
|
59
|
+
## Submit authentication
|
|
60
|
+
|
|
61
|
+
Issue a narrow downstream submit token after GitHub account binding. The token should authorize only usage updates for one bound downstream account.
|
|
62
|
+
|
|
63
|
+
- Do not accept an OpenAI, Codex, GitHub, npm, or operating-system credential as a submit token.
|
|
64
|
+
- Show the token only at creation, store only a slow or cryptographic hash as appropriate, and support rotation and immediate revocation.
|
|
65
|
+
- Keep the token out of command arguments, URLs, image URLs, logs, analytics, and error bodies.
|
|
66
|
+
- Prefer stdin, an environment variable, or an operating-system secret store in the downstream client.
|
|
67
|
+
- Apply per-account and per-token rate limits and record security events without recording payload values.
|
|
68
|
+
|
|
69
|
+
The base CLI does not define a submit command or token field. A downstream-specific wrapper should own that interface.
|
|
70
|
+
|
|
71
|
+
## Suggested submit API
|
|
72
|
+
|
|
73
|
+
The following protocol is non-normative. It illustrates a boundary that does not mix identity with usage.
|
|
74
|
+
|
|
75
|
+
```http
|
|
76
|
+
POST /v1/account-usage
|
|
77
|
+
Authorization: Bearer <downstream-issued-submit-token>
|
|
78
|
+
Content-Type: application/json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The request body is exactly one Account Usage Contract document:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"contractVersion": 1,
|
|
86
|
+
"capturedAt": "2026-07-11T00:00:00.000Z",
|
|
87
|
+
"summary": {
|
|
88
|
+
"lifetimeTokens": 1234567890,
|
|
89
|
+
"peakDailyTokens": 45600000,
|
|
90
|
+
"longestRunningTurnSec": 754,
|
|
91
|
+
"currentStreakDays": 3,
|
|
92
|
+
"longestStreakDays": 21
|
|
93
|
+
},
|
|
94
|
+
"dailyUsageBuckets": []
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
All values are synthetic. A successful response can return opaque downstream metadata:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"status": "accepted",
|
|
103
|
+
"revision": "opaque-revision",
|
|
104
|
+
"imageUrl": "https://example.invalid/u/octocat/codex-usage.png"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Do not copy `revision` or `imageUrl` into the account usage document.
|
|
109
|
+
|
|
110
|
+
## Validation and storage
|
|
111
|
+
|
|
112
|
+
Before storing a submission:
|
|
113
|
+
|
|
114
|
+
1. Authenticate and rate-limit the downstream submit token.
|
|
115
|
+
2. Enforce request content type and a conservative body-size limit.
|
|
116
|
+
3. Validate the JSON Schema and reject unknown `contractVersion` values.
|
|
117
|
+
4. Reject extra fields, unsafe integers, invalid dates, and future `capturedAt` values beyond a small clock-skew allowance.
|
|
118
|
+
5. Reject stale or replayed updates according to an explicit policy.
|
|
119
|
+
6. Bind the write to the token's account, never to a body-supplied username.
|
|
120
|
+
|
|
121
|
+
Store only fields needed to render the product. Separate usage data from credential records, encrypt sensitive stores at rest, limit operator access, and define a retention period. Logs should contain request ids, result codes, timings, contract version, and coarse byte counts, not account usage values or identity payloads.
|
|
122
|
+
|
|
123
|
+
Preserve null semantics:
|
|
124
|
+
|
|
125
|
+
- `null` means unavailable and must not be displayed as zero.
|
|
126
|
+
- `[]` means daily data was available but contained no rows.
|
|
127
|
+
- Daily `startDate` values are source dates and must not be timezone-rebucketed.
|
|
128
|
+
|
|
129
|
+
## Rendering and cache behavior
|
|
130
|
+
|
|
131
|
+
The downstream owns all presentation decisions, including number formatting, localization, missing-value labels, heatmap color thresholds, and avatar treatment.
|
|
132
|
+
|
|
133
|
+
For a README image endpoint:
|
|
134
|
+
|
|
135
|
+
- Keep one stable HTTPS image URL per public profile.
|
|
136
|
+
- Return an actual image content type and deterministic bytes for a stored revision.
|
|
137
|
+
- Send `ETag` and `Last-Modified` validators and a deliberate `Cache-Control` policy that permits revalidation.
|
|
138
|
+
- Update the validator when a new submission changes rendered content.
|
|
139
|
+
- Keep immutable, revision-addressed assets internally so rendering can be retried and rolled back.
|
|
140
|
+
- Expect GitHub's image proxy to introduce cache delay; do not promise immediate refresh after submit.
|
|
141
|
+
- Do not include submit tokens, account ids, private revisions, or sensitive query parameters in the public image URL.
|
|
142
|
+
|
|
143
|
+
Fetch remote avatars server-side only with strict HTTPS host policy, redirect limits, response-size limits, image decoding, and content-type verification. Re-encode to a safe image format before storage or rendering. This prevents browser tracking and reduces server-side request forgery and image parser risk.
|
|
144
|
+
|
|
145
|
+
## Privacy, revocation, and deletion
|
|
146
|
+
|
|
147
|
+
Before a profile becomes public, explain exactly which identity and usage fields will be visible. Default new profiles to private unless the product intentionally and clearly asks for public visibility.
|
|
148
|
+
|
|
149
|
+
Provide controls to:
|
|
150
|
+
|
|
151
|
+
- revoke every submit token
|
|
152
|
+
- disable the public card without deleting the account
|
|
153
|
+
- delete stored usage, rendered assets, and the downstream identity binding
|
|
154
|
+
- export or inspect the stored fields when required by the product's policy
|
|
155
|
+
|
|
156
|
+
Deletion should invalidate caches where possible and make the stable image URL return a non-sensitive tombstone or `404`. Document backup-retention limits and avoid retaining deleted data indefinitely.
|
|
157
|
+
|
|
158
|
+
## Experimental profile identity
|
|
159
|
+
|
|
160
|
+
A future adapter for private or undocumented profile endpoints such as `/wham/profiles/me` must remain separate from the default CLI and its contract. It is not implemented by `codex-usage-analyzer`.
|
|
161
|
+
|
|
162
|
+
If a downstream chooses to experiment with such an adapter:
|
|
163
|
+
|
|
164
|
+
- require explicit opt-in for every invocation
|
|
165
|
+
- label the source unstable and unsupported
|
|
166
|
+
- never silently fall back to it
|
|
167
|
+
- never use its identity as authentication, authorization, or account ownership proof
|
|
168
|
+
- allowlist only cosmetic fields and discard usage metrics from that response
|
|
169
|
+
- never persist or log raw responses, credentials, account identifiers, or private URLs
|
|
170
|
+
- sanitize and re-host an avatar instead of exposing a raw remote URL to viewers
|
|
171
|
+
- make failure non-fatal to the official account usage path
|
|
172
|
+
|
|
173
|
+
A separate downstream-owned envelope may use these field names:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"experimentalProfile": {
|
|
178
|
+
"source": "codex-private-profile",
|
|
179
|
+
"displayName": "Example Name",
|
|
180
|
+
"username": "example",
|
|
181
|
+
"avatarUrl": "https://example.invalid/avatar-source",
|
|
182
|
+
"observedAt": "2026-07-11T00:00:00.000Z"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
This envelope is not part of the Account Usage Contract. GitHub identity should remain the default display source and the only account binding unless a later, separately reviewed design establishes another verifiable identity source.
|
|
188
|
+
|
|
189
|
+
Keep the experimental envelope authenticated and non-public. Treat `avatarUrl` as untrusted adapter input and replace it with a sanitized downstream asset before rendering or storage.
|
|
190
|
+
|
|
191
|
+
## Versioning
|
|
192
|
+
|
|
193
|
+
Accept only contract versions the downstream explicitly supports. A new root field, changed field meaning, changed nullability, or changed date semantics requires a new contract version and coordinated rollout.
|
|
194
|
+
|
|
195
|
+
Deploy downstream read support before emitting a new version. Keep old readers and stored records isolated by `contractVersion`, and do not reinterpret old data under new semantics.
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-usage-analyzer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Read Codex account usage through the official app-server protocol.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"codex",
|
|
7
|
+
"openai",
|
|
8
|
+
"usage",
|
|
9
|
+
"cli",
|
|
10
|
+
"app-server"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
5
13
|
"type": "module",
|
|
6
14
|
"homepage": "https://github.com/postmelee/codex-usage-analyzer#readme",
|
|
7
15
|
"repository": {
|
|
@@ -23,17 +31,21 @@
|
|
|
23
31
|
},
|
|
24
32
|
"files": [
|
|
25
33
|
"bin",
|
|
26
|
-
"
|
|
34
|
+
"docs",
|
|
35
|
+
"src/account-usage.js",
|
|
36
|
+
"src/app-server-client.js",
|
|
27
37
|
"src/cli.js",
|
|
28
|
-
"src/
|
|
38
|
+
"src/errors.js",
|
|
39
|
+
"src/format-account-usage.js",
|
|
29
40
|
"src/index.d.ts",
|
|
30
41
|
"src/index.js",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
42
|
+
"README.md",
|
|
43
|
+
"CONTRIBUTING.md",
|
|
44
|
+
"SECURITY.md"
|
|
34
45
|
],
|
|
35
46
|
"scripts": {
|
|
36
|
-
"test": "node --test"
|
|
47
|
+
"test": "node --test",
|
|
48
|
+
"release:preflight": "node scripts/release-preflight.js"
|
|
37
49
|
},
|
|
38
50
|
"engines": {
|
|
39
51
|
"node": ">=20"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { requestAccountUsageFromAppServer } from "./app-server-client.js";
|
|
2
|
+
import { CODEX_USAGE_ERROR_CODES, CodexUsageError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export const ACCOUNT_USAGE_CONTRACT_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const ACCOUNT_USAGE_SUMMARY_FIELDS = Object.freeze([
|
|
7
|
+
"lifetimeTokens",
|
|
8
|
+
"peakDailyTokens",
|
|
9
|
+
"longestRunningTurnSec",
|
|
10
|
+
"currentStreakDays",
|
|
11
|
+
"longestStreakDays"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export async function readAccountUsage(options = {}) {
|
|
15
|
+
const result = await requestAccountUsageFromAppServer({
|
|
16
|
+
timeoutMs: options.timeoutMs,
|
|
17
|
+
spawnProcess: options.spawnProcess
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return normalizeAccountUsageResult(result);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeAccountUsageResult(result, options = {}) {
|
|
24
|
+
if (!isRecord(result) || !isRecord(result.summary)) {
|
|
25
|
+
throw invalidResponse();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const summary = {};
|
|
29
|
+
for (const field of ACCOUNT_USAGE_SUMMARY_FIELDS) {
|
|
30
|
+
summary[field] = normalizeNullableInteger(result.summary[field]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
contractVersion: ACCOUNT_USAGE_CONTRACT_VERSION,
|
|
35
|
+
capturedAt: normalizeCapturedAt(options.capturedAt),
|
|
36
|
+
summary,
|
|
37
|
+
dailyUsageBuckets: normalizeDailyUsageBuckets(result.dailyUsageBuckets)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeDailyUsageBuckets(value) {
|
|
42
|
+
if (value === undefined || value === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!Array.isArray(value)) {
|
|
47
|
+
throw invalidResponse();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value.map((bucket) => {
|
|
51
|
+
if (!isRecord(bucket) || !isDateOnly(bucket.startDate)) {
|
|
52
|
+
throw invalidResponse();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
startDate: bucket.startDate,
|
|
57
|
+
tokens: normalizeRequiredInteger(bucket.tokens)
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeNullableInteger(value) {
|
|
63
|
+
if (value === undefined || value === null) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return normalizeRequiredInteger(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeRequiredInteger(value) {
|
|
71
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
72
|
+
throw invalidResponse();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeCapturedAt(value) {
|
|
79
|
+
if (value === undefined) {
|
|
80
|
+
return new Date().toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
84
|
+
if (Number.isNaN(date.getTime())) {
|
|
85
|
+
throw invalidResponse();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return date.toISOString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isDateOnly(value) {
|
|
92
|
+
if (typeof value !== "string") return false;
|
|
93
|
+
|
|
94
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value);
|
|
95
|
+
if (match === null) return false;
|
|
96
|
+
|
|
97
|
+
const year = Number(match[1]);
|
|
98
|
+
const month = Number(match[2]);
|
|
99
|
+
const day = Number(match[3]);
|
|
100
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
101
|
+
|
|
102
|
+
return date.getUTCFullYear() === year
|
|
103
|
+
&& date.getUTCMonth() === month - 1
|
|
104
|
+
&& date.getUTCDate() === day;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function invalidResponse() {
|
|
108
|
+
return new CodexUsageError(
|
|
109
|
+
CODEX_USAGE_ERROR_CODES.INVALID_ACCOUNT_USAGE_RESPONSE
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isRecord(value) {
|
|
114
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
115
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
|
|
4
|
+
import { CODEX_USAGE_ERROR_CODES, CodexUsageError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_APP_SERVER_TIMEOUT_MS = 15_000;
|
|
7
|
+
export const MAX_APP_SERVER_TIMEOUT_MS = 120_000;
|
|
8
|
+
|
|
9
|
+
const INITIALIZE_REQUEST_ID = 0;
|
|
10
|
+
const ACCOUNT_USAGE_REQUEST_ID = 1;
|
|
11
|
+
|
|
12
|
+
export async function requestAccountUsageFromAppServer(options = {}) {
|
|
13
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
14
|
+
const spawnProcess = options.spawnProcess ?? spawn;
|
|
15
|
+
let child;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
child = spawnProcess("codex", ["app-server"], {
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
windowsHide: true
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return Promise.reject(createSpawnError(error));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!child?.stdin || !child?.stdout || !child?.stderr) {
|
|
27
|
+
stopChild(child);
|
|
28
|
+
return Promise.reject(new CodexUsageError(
|
|
29
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_START_FAILED
|
|
30
|
+
));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
child.stderr.resume();
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const lines = createInterface({
|
|
37
|
+
input: child.stdout,
|
|
38
|
+
crlfDelay: Infinity
|
|
39
|
+
});
|
|
40
|
+
let phase = "initializing";
|
|
41
|
+
let settled = false;
|
|
42
|
+
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
fail(new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_TIMEOUT));
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
|
|
47
|
+
function cleanup() {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
lines.removeAllListeners();
|
|
50
|
+
lines.close();
|
|
51
|
+
|
|
52
|
+
if (!child.stdin.destroyed) {
|
|
53
|
+
child.stdin.end();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stopChild(child);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function succeed(result) {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(result);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(error) {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
cleanup();
|
|
70
|
+
reject(error);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function send(message) {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
78
|
+
if (error) {
|
|
79
|
+
fail(new CodexUsageError(
|
|
80
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
fail(new CodexUsageError(
|
|
86
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
87
|
+
));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onError(error) {
|
|
92
|
+
fail(createSpawnError(error));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onExit() {
|
|
96
|
+
fail(new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_EXITED));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function onLine(line) {
|
|
100
|
+
let message;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
message = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
fail(new CodexUsageError(
|
|
106
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
107
|
+
));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!isRecord(message)) {
|
|
112
|
+
fail(new CodexUsageError(
|
|
113
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
114
|
+
));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (message.id === INITIALIZE_REQUEST_ID) {
|
|
119
|
+
if (phase !== "initializing") {
|
|
120
|
+
fail(new CodexUsageError(
|
|
121
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
122
|
+
));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (hasRpcError(message)) {
|
|
127
|
+
fail(createRpcError(message.error));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!Object.hasOwn(message, "result")) {
|
|
132
|
+
fail(new CodexUsageError(
|
|
133
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
134
|
+
));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
phase = "reading_usage";
|
|
139
|
+
send({ method: "initialized", params: {} });
|
|
140
|
+
send({ method: "account/usage/read", id: ACCOUNT_USAGE_REQUEST_ID });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (message.id === ACCOUNT_USAGE_REQUEST_ID) {
|
|
145
|
+
if (phase !== "reading_usage") {
|
|
146
|
+
fail(new CodexUsageError(
|
|
147
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
148
|
+
));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (hasRpcError(message)) {
|
|
153
|
+
fail(createRpcError(message.error));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!Object.hasOwn(message, "result")) {
|
|
158
|
+
fail(new CodexUsageError(
|
|
159
|
+
CODEX_USAGE_ERROR_CODES.APP_SERVER_PROTOCOL_ERROR
|
|
160
|
+
));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
succeed(message.result);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
child.once("error", onError);
|
|
169
|
+
child.once("exit", onExit);
|
|
170
|
+
lines.on("line", onLine);
|
|
171
|
+
|
|
172
|
+
send({
|
|
173
|
+
method: "initialize",
|
|
174
|
+
id: INITIALIZE_REQUEST_ID,
|
|
175
|
+
params: {
|
|
176
|
+
clientInfo: {
|
|
177
|
+
name: "codex_usage_analyzer",
|
|
178
|
+
title: "Codex Usage Analyzer",
|
|
179
|
+
version: "0.2.0"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeTimeoutMs(value) {
|
|
187
|
+
if (value === undefined) {
|
|
188
|
+
return DEFAULT_APP_SERVER_TIMEOUT_MS;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_APP_SERVER_TIMEOUT_MS) {
|
|
192
|
+
throw new CodexUsageError(CODEX_USAGE_ERROR_CODES.INVALID_TIMEOUT);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createSpawnError(error) {
|
|
199
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
200
|
+
return new CodexUsageError(
|
|
201
|
+
code === "ENOENT"
|
|
202
|
+
? CODEX_USAGE_ERROR_CODES.CODEX_NOT_FOUND
|
|
203
|
+
: CODEX_USAGE_ERROR_CODES.APP_SERVER_START_FAILED
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createRpcError(error) {
|
|
208
|
+
return new CodexUsageError(CODEX_USAGE_ERROR_CODES.APP_SERVER_RPC_ERROR, {
|
|
209
|
+
rpcCode: Number.isInteger(error?.code) ? error.code : undefined
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hasRpcError(message) {
|
|
214
|
+
return Object.hasOwn(message, "error");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stopChild(child) {
|
|
218
|
+
if (!child || child.exitCode !== null || child.killed) return;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
child.kill();
|
|
222
|
+
} catch {
|
|
223
|
+
// Cleanup failure does not replace the safe protocol result.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isRecord(value) {
|
|
228
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
229
|
+
}
|