pi-openai-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/index.ts +32 -0
- package/package.json +43 -0
- package/src/auth.ts +303 -0
- package/src/color.ts +391 -0
- package/src/config.ts +767 -0
- package/src/diagnostics-reporter.ts +202 -0
- package/src/display-width.ts +83 -0
- package/src/format.ts +356 -0
- package/src/interactive-settings-menu.ts +363 -0
- package/src/progress-bar.ts +163 -0
- package/src/status-controller.ts +280 -0
- package/src/usage-client.ts +144 -0
- package/src/usage-command-facade.ts +103 -0
- package/src/usage-refresh-coordinator.ts +193 -0
- package/src/usage-settings.ts +331 -0
- package/src/usage-snapshot.ts +136 -0
- package/src/usage-state.ts +66 -0
- package/src/visibility.ts +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# pi-openai-usage
|
|
2
|
+
|
|
3
|
+
Shows your remaining OpenAI Codex subscription usage in Pi.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Usage: 5h ████████░░ 88% | 7d ███████░░░ 73% | 5h ↺ 42m | 7d ↺ 2d4h
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- [Pi](https://github.com/earendil-works/pi) CLI.
|
|
12
|
+
- An OpenAI subscription.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install git:github.com/studioarray/pi-openai-usage
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Login
|
|
21
|
+
|
|
22
|
+
This extension uses Pi's existing OpenAI Codex login. If you have not logged in yet, run:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/login openai-codex
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Command
|
|
29
|
+
|
|
30
|
+
`/openai-usage-settings` is the command for OpenAI usage and settings.
|
|
31
|
+
|
|
32
|
+
Run it with no arguments in Pi's interactive UI to open the settings menu. In non-UI runs, it shows a read-only fallback with the available utility subcommands.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
/openai-usage-settings
|
|
36
|
+
/openai-usage-settings usage
|
|
37
|
+
/openai-usage-settings refresh
|
|
38
|
+
/openai-usage-settings diagnostics
|
|
39
|
+
/openai-usage-settings help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Utility subcommands
|
|
43
|
+
|
|
44
|
+
Use utility subcommands when you want a direct status check, refresh, diagnostics, or help:
|
|
45
|
+
|
|
46
|
+
| Subcommand | What it does |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `usage` | Show cached usage, refreshing first if the cache is missing or stale. |
|
|
49
|
+
| `refresh` | Force a usage refresh and show the updated usage status. |
|
|
50
|
+
| `diagnostics` | Show setup, auth, config-path, timestamp, and last-error diagnostics without printing tokens. |
|
|
51
|
+
| `help` | Show command help for the one-command workflow. |
|
|
52
|
+
|
|
53
|
+
Example form: `/openai-usage-settings usage`.
|
|
54
|
+
|
|
55
|
+
### Interactive settings menu
|
|
56
|
+
|
|
57
|
+
Running `/openai-usage-settings` with no arguments opens a ten-row menu for common settings:
|
|
58
|
+
|
|
59
|
+
| Row | Visible values |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Display | `On`, `Off`, `Always` |
|
|
62
|
+
| Color scheme | `traffic`, `cyan`, `green`, `mono`, `none` |
|
|
63
|
+
| Bar style | `blocks`, `thin`, `ascii`, `dots`, `squares`, `braille` |
|
|
64
|
+
| Bar width | `4`, `6`, `8`, `10`, `12`, `16`, `20` |
|
|
65
|
+
| 5h display | `hidden`, `percent`, `bar`, `bar + percent` |
|
|
66
|
+
| 7d display | `hidden`, `percent`, `bar`, `bar + percent` |
|
|
67
|
+
| 5h reset display | `hidden`, `countdown`, `clock`, `both` |
|
|
68
|
+
| 7d reset display | `hidden`, `countdown`, `clock`, `both` |
|
|
69
|
+
| Refresh interval | `15s`, `30s`, `1m`, `2m`, `5m`, `10m` |
|
|
70
|
+
| Hide label | `No`, `Yes` |
|
|
71
|
+
|
|
72
|
+
`Display` controls whether the status line is shown normally, hidden, or always shown. Normal display appears for OAuth-backed `openai` and `openai-codex` models; `Always` shows usage regardless of the selected model. `Hide label` controls the `Usage:` prefix. If a JSON config uses a custom color scheme or custom bar style, the menu may show `custom (JSON)` as the current value; selecting a preset replaces it with that preset.
|
|
73
|
+
|
|
74
|
+
## Configuration files
|
|
75
|
+
|
|
76
|
+
Configuration is read from:
|
|
77
|
+
|
|
78
|
+
1. Project config: `.pi/extensions/pi-openai-usage.json`
|
|
79
|
+
2. Global config: `~/.pi/agent/extensions/pi-openai-usage.json`
|
|
80
|
+
|
|
81
|
+
Project config overrides global config.
|
|
82
|
+
|
|
83
|
+
The interactive menu writes to the project config only when `.pi/extensions/pi-openai-usage.json` already exists. Otherwise, menu changes are written to the global config. Create the project config file first if you want menu changes to stay project-local.
|
|
84
|
+
|
|
85
|
+
Use the interactive menu for common display settings. Advanced visual customization is JSON-file-only: edit the project or global config file for custom label text, widget labels, separators, partial bars, custom bar glyphs, custom color stops or targets, and bar-gradient controls. Slash-command setting writes are not supported.
|
|
86
|
+
|
|
87
|
+
## JSON configuration reference
|
|
88
|
+
|
|
89
|
+
### Top-level settings
|
|
90
|
+
|
|
91
|
+
| Key | Type | Default | Accepted values / notes |
|
|
92
|
+
| --- | --- | --- | --- |
|
|
93
|
+
| `enabled` | boolean | `true` | Enables or hides the status-line entry. |
|
|
94
|
+
| `refreshIntervalMs` | number | `60000` | `15000`, `30000`, `60000`, `120000`, `300000`, `600000`. |
|
|
95
|
+
| `display` | object | see below | Status-line label and visibility presentation. |
|
|
96
|
+
| `widgets` | object | see below | Usage and reset widgets. |
|
|
97
|
+
| `bar` | object | see below | Progress-bar style and width. |
|
|
98
|
+
| `colors` | object | see below | Color scheme, target, gradients, and custom stops. |
|
|
99
|
+
|
|
100
|
+
### `display`
|
|
101
|
+
|
|
102
|
+
| Key | Type | Default | Accepted values / notes |
|
|
103
|
+
| --- | --- | --- | --- |
|
|
104
|
+
| `display.showAlways` | boolean | `false` | `true` shows usage even when the selected model is not an OAuth-backed OpenAI/Codex model. |
|
|
105
|
+
| `display.showLabel` | boolean | `true` | Controls the label prefix. |
|
|
106
|
+
| `display.label` | string | `"Usage"` | Non-empty string. |
|
|
107
|
+
| `display.separator` | string | `" | "` | Separator between visible widgets. |
|
|
108
|
+
|
|
109
|
+
### `widgets`
|
|
110
|
+
|
|
111
|
+
| Key | Type | Default | Accepted values / notes |
|
|
112
|
+
| --- | --- | --- | --- |
|
|
113
|
+
| `widgets.fiveHour.enabled` | boolean | `true` | Shows or hides the 5h usage widget. |
|
|
114
|
+
| `widgets.fiveHour.label` | string | `"5h"` | Non-empty string. |
|
|
115
|
+
| `widgets.fiveHour.mode` | string | `"bar-percent"` | `percent`, `bar`, `bar-percent`, `hidden`. |
|
|
116
|
+
| `widgets.sevenDay.enabled` | boolean | `true` | Shows or hides the 7d usage widget. |
|
|
117
|
+
| `widgets.sevenDay.label` | string | `"7d"` | Non-empty string. |
|
|
118
|
+
| `widgets.sevenDay.mode` | string | `"bar-percent"` | `percent`, `bar`, `bar-percent`, `hidden`. |
|
|
119
|
+
| `widgets.fiveHourReset.enabled` | boolean | `true` | Shows or hides the 5h reset widget. |
|
|
120
|
+
| `widgets.fiveHourReset.label` | string | `"5h ↺"` | Non-empty string. |
|
|
121
|
+
| `widgets.fiveHourReset.mode` | string | `"countdown"` | `countdown`, `clock`, `both`, `hidden`. |
|
|
122
|
+
| `widgets.sevenDayReset.enabled` | boolean | `true` | Shows or hides the 7d reset widget. |
|
|
123
|
+
| `widgets.sevenDayReset.label` | string | `"7d ↺"` | Non-empty string. |
|
|
124
|
+
| `widgets.sevenDayReset.mode` | string | `"countdown"` | `countdown`, `clock`, `both`, `hidden`. |
|
|
125
|
+
|
|
126
|
+
### `bar`
|
|
127
|
+
|
|
128
|
+
| Key | Type | Default | Accepted values / notes |
|
|
129
|
+
| --- | --- | --- | --- |
|
|
130
|
+
| `bar.style` | string | `"blocks"` | `blocks`, `thin`, `ascii`, `dots`, `squares`, `braille`, `custom`. |
|
|
131
|
+
| `bar.width` | number | `10` | `4`, `6`, `8`, `10`, `12`, `16`, `20`. |
|
|
132
|
+
| `bar.partials` | boolean | `true` | Uses partial glyphs when the selected style supports them. |
|
|
133
|
+
| `bar.custom.filled` | string | `"▰"` | Filled glyph used when `bar.style` is `custom`. |
|
|
134
|
+
| `bar.custom.empty` | string | `"▱"` | Empty glyph used when `bar.style` is `custom`. |
|
|
135
|
+
| `bar.custom.partials` | string[] | `[]` | Optional partial glyphs used when `bar.style` is `custom` and `bar.partials` is `true`. |
|
|
136
|
+
|
|
137
|
+
### `colors`
|
|
138
|
+
|
|
139
|
+
| Key | Type | Default | Accepted values / notes |
|
|
140
|
+
| --- | --- | --- | --- |
|
|
141
|
+
| `colors.scheme` | string | `"traffic"` | `traffic`, `cyan`, `green`, `mono`, `none`, `custom`. |
|
|
142
|
+
| `colors.target` | string | `"value"` | `value`, `widget`, `bar`, `percent`, `none`. |
|
|
143
|
+
| `colors.barGradient.enabled` | boolean | `false` | Colors filled bar cells across the selected color scale. |
|
|
144
|
+
| `colors.barGradient.direction` | string | `"low-to-high"` | `low-to-high`, `high-to-low`. |
|
|
145
|
+
| `colors.custom.mode` | string | `"step"` | `step`, `gradient`. |
|
|
146
|
+
| `colors.custom.stops` | array | see below | Array of color stops sorted by `percent`. |
|
|
147
|
+
|
|
148
|
+
Default `colors.custom.stops`:
|
|
149
|
+
|
|
150
|
+
| percent | color | label |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `80` | `success` | `success` |
|
|
153
|
+
| `60` | `#65a30d` | `lime/olive` |
|
|
154
|
+
| `40` | `warning` | `warning` |
|
|
155
|
+
| `20` | `#c2410c` | `orange` |
|
|
156
|
+
| `0` | `error` | `error` |
|
|
157
|
+
|
|
158
|
+
Each custom color stop has this shape:
|
|
159
|
+
|
|
160
|
+
| Key | Type | Accepted values / notes |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| `percent` | number | Clamped to `0` through `100`. |
|
|
163
|
+
| `color` | string or number | Pi theme token (`success`, `warning`, `error`, `muted`, `dim`, `text`, `accent`) for `step` mode, `#RRGGBB`, or xterm color number `0` through `255`. Gradient mode supports interpolating `#RRGGBB` and xterm colors. |
|
|
164
|
+
| `label` | string | Optional label for your own reference. |
|
|
165
|
+
|
|
166
|
+
## Display customization
|
|
167
|
+
|
|
168
|
+
The default display uses progress bars and percentages:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
Usage: 5h ████████░░ 88% | 7d ███████░░░ 73% | 5h ↺ 42m | 7d ↺ 2d4h
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
To simplify it in the interactive menu, set:
|
|
175
|
+
|
|
176
|
+
- Hide label: `Yes`
|
|
177
|
+
- 5h display: `percent`
|
|
178
|
+
- 7d display: `percent`
|
|
179
|
+
- 5h reset display: `hidden`
|
|
180
|
+
- 7d reset display: `hidden`
|
|
181
|
+
|
|
182
|
+
Result:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
5h: 88% | 7d: 73%
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
To use the neutral footer text colour instead of usage-coloured values, set Color scheme to `none`.
|
|
189
|
+
|
|
190
|
+
## Privacy and auth
|
|
191
|
+
|
|
192
|
+
The extension uses Pi's existing `openai-codex` OAuth credentials to request Codex usage from ChatGPT's usage endpoint. Diagnostics are designed for troubleshooting and do not print access tokens.
|
|
193
|
+
|
|
194
|
+
## Reference attribution
|
|
195
|
+
|
|
196
|
+
Inspired by [pi-better-openai](https://github.com/mattleong/pi-better-openai/).
|
package/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerOpenAIUsageSettingsCommand } from "./src/usage-settings";
|
|
4
|
+
import {
|
|
5
|
+
createUsageRefreshCoordinator,
|
|
6
|
+
type UsageRefreshCoordinator,
|
|
7
|
+
} from "./src/usage-refresh-coordinator";
|
|
8
|
+
import { createUsageStateStore } from "./src/usage-state";
|
|
9
|
+
import { fetchCodexUsage, type UsageClientPort } from "./src/usage-client";
|
|
10
|
+
import { registerUsageStatusController } from "./src/status-controller";
|
|
11
|
+
|
|
12
|
+
export default function piOpenAIUsage(pi: ExtensionAPI): void {
|
|
13
|
+
const usageClient: UsageClientPort = { fetchUsage: fetchCodexUsage };
|
|
14
|
+
const usageState = createUsageStateStore();
|
|
15
|
+
const usageRefreshCoordinator: UsageRefreshCoordinator = createUsageRefreshCoordinator({
|
|
16
|
+
usageClient,
|
|
17
|
+
usageState,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const statusController = registerUsageStatusController(pi, {
|
|
21
|
+
usageClient,
|
|
22
|
+
usageState,
|
|
23
|
+
usageRefreshCoordinator,
|
|
24
|
+
});
|
|
25
|
+
if (typeof (pi as { registerCommand?: unknown }).registerCommand === "function") {
|
|
26
|
+
registerOpenAIUsageSettingsCommand(pi, {
|
|
27
|
+
usageState,
|
|
28
|
+
usageRefreshCoordinator,
|
|
29
|
+
reapplyStatusLine: (ctx) => statusController.reapply(ctx),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-openai-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Focused Pi extension package for showing OpenAI Codex subscription usage in the status line.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"openai",
|
|
11
|
+
"codex",
|
|
12
|
+
"usage"
|
|
13
|
+
],
|
|
14
|
+
"main": "index.ts",
|
|
15
|
+
"types": "index.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"index.ts",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"check": "npm run typecheck && npm test"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^0.75.4",
|
|
38
|
+
"@earendil-works/pi-tui": "^0.75.4",
|
|
39
|
+
"@types/node": "^24.0.0",
|
|
40
|
+
"typescript": "^5.9.0",
|
|
41
|
+
"vitest": "^4.1.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CODEX_PROVIDER_ID = "openai-codex";
|
|
6
|
+
|
|
7
|
+
export type CodexCredentialSource = "registry" | "auth_file";
|
|
8
|
+
export type CodexCredentialErrorCode = "missing_credentials" | "missing_account_id";
|
|
9
|
+
|
|
10
|
+
export type CodexOAuthCredentials = {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
accountId: string;
|
|
13
|
+
source: CodexCredentialSource;
|
|
14
|
+
toJSON(): RedactedCodexCredentials;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RedactedCodexCredentials = {
|
|
18
|
+
accessToken: "<redacted>";
|
|
19
|
+
accountId: string;
|
|
20
|
+
source: CodexCredentialSource;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CodexCredentialDiagnostics = {
|
|
24
|
+
source: CodexCredentialSource | "none";
|
|
25
|
+
checkedSources: CodexCredentialSource[];
|
|
26
|
+
hasAccessToken: boolean;
|
|
27
|
+
hasAccountId: boolean;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CodexCredentialError = {
|
|
32
|
+
code: CodexCredentialErrorCode;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CodexCredentialResolution =
|
|
37
|
+
| {
|
|
38
|
+
ok: true;
|
|
39
|
+
credentials: CodexOAuthCredentials;
|
|
40
|
+
diagnostics: CodexCredentialDiagnostics;
|
|
41
|
+
toJSON(): {
|
|
42
|
+
ok: true;
|
|
43
|
+
credentials: RedactedCodexCredentials;
|
|
44
|
+
diagnostics: CodexCredentialDiagnostics;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
ok: false;
|
|
49
|
+
error: CodexCredentialError;
|
|
50
|
+
diagnostics: CodexCredentialDiagnostics;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type CodexModelRegistry = {
|
|
54
|
+
getApiKeyForProvider?: (
|
|
55
|
+
provider: string,
|
|
56
|
+
) => string | undefined | Promise<string | undefined>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ResolveCodexOAuthCredentialsOptions = {
|
|
60
|
+
modelRegistry?: CodexModelRegistry;
|
|
61
|
+
authFilePath?: string;
|
|
62
|
+
home?: string;
|
|
63
|
+
env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export async function resolveCodexOAuthCredentials(
|
|
67
|
+
options: ResolveCodexOAuthCredentialsOptions = {},
|
|
68
|
+
): Promise<CodexCredentialResolution> {
|
|
69
|
+
const checkedSources: CodexCredentialSource[] = [];
|
|
70
|
+
let bestAttempt: CredentialAttempt | undefined;
|
|
71
|
+
|
|
72
|
+
const registryAttempt = await readRegistryCredentials(options.modelRegistry);
|
|
73
|
+
checkedSources.push("registry");
|
|
74
|
+
if (registryAttempt?.credentials !== undefined) {
|
|
75
|
+
return credentialSuccess(registryAttempt.credentials, checkedSources);
|
|
76
|
+
}
|
|
77
|
+
bestAttempt = bestCredentialAttempt(bestAttempt, registryAttempt);
|
|
78
|
+
|
|
79
|
+
const authFileAttempt = readAuthFileCredentials(authFilePath(options));
|
|
80
|
+
checkedSources.push("auth_file");
|
|
81
|
+
if (authFileAttempt?.credentials !== undefined) {
|
|
82
|
+
return credentialSuccess(authFileAttempt.credentials, checkedSources);
|
|
83
|
+
}
|
|
84
|
+
bestAttempt = bestCredentialAttempt(bestAttempt, authFileAttempt);
|
|
85
|
+
|
|
86
|
+
return credentialFailure(bestAttempt, checkedSources);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function authFilePath(options: ResolveCodexOAuthCredentialsOptions = {}): string {
|
|
90
|
+
if (options.authFilePath !== undefined) return options.authFilePath;
|
|
91
|
+
|
|
92
|
+
const env = options.env ?? process.env;
|
|
93
|
+
const configuredAgentDir = env.PI_CODING_AGENT_DIR?.trim();
|
|
94
|
+
const agentDir =
|
|
95
|
+
configuredAgentDir !== undefined && configuredAgentDir.length > 0
|
|
96
|
+
? configuredAgentDir
|
|
97
|
+
: join(options.home ?? homedir(), ".pi", "agent");
|
|
98
|
+
return join(agentDir, "auth.json");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type CredentialMaterial = {
|
|
102
|
+
accessToken?: string;
|
|
103
|
+
accountId?: string;
|
|
104
|
+
source: CodexCredentialSource;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type CredentialAttempt = CredentialMaterial & {
|
|
108
|
+
credentials?: CodexOAuthCredentials;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
async function readRegistryCredentials(
|
|
112
|
+
modelRegistry: CodexModelRegistry | undefined,
|
|
113
|
+
): Promise<CredentialAttempt | undefined> {
|
|
114
|
+
if (modelRegistry?.getApiKeyForProvider === undefined) return undefined;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const rawCredential = await modelRegistry.getApiKeyForProvider(CODEX_PROVIDER_ID);
|
|
118
|
+
if (rawCredential === undefined) return undefined;
|
|
119
|
+
|
|
120
|
+
return credentialAttempt(parseRegistryCredential(rawCredential));
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readAuthFileCredentials(path: string): CredentialAttempt | undefined {
|
|
127
|
+
if (!existsSync(path)) return undefined;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
131
|
+
const root = asRecord(parsed);
|
|
132
|
+
const entry = root === undefined ? undefined : asRecord(root[CODEX_PROVIDER_ID]);
|
|
133
|
+
if (entry?.type !== "oauth") return undefined;
|
|
134
|
+
|
|
135
|
+
return credentialAttempt({
|
|
136
|
+
accessToken: trimmedString(entry.access),
|
|
137
|
+
accountId: trimmedString(entry.accountId) ?? trimmedString(entry.account_id),
|
|
138
|
+
source: "auth_file",
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseRegistryCredential(rawCredential: string): CredentialMaterial | undefined {
|
|
146
|
+
const trimmed = rawCredential.trim();
|
|
147
|
+
if (trimmed.length === 0) return undefined;
|
|
148
|
+
|
|
149
|
+
const jsonCredentials = parseRegistryJsonCredential(trimmed);
|
|
150
|
+
if (jsonCredentials !== undefined) return jsonCredentials;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
accessToken: trimmed,
|
|
154
|
+
accountId: accountIdFromJwt(trimmed),
|
|
155
|
+
source: "registry",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseRegistryJsonCredential(rawCredential: string): CredentialMaterial | undefined {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(rawCredential) as unknown;
|
|
162
|
+
const record = asRecord(parsed);
|
|
163
|
+
if (record === undefined) return undefined;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
accessToken: trimmedString(record.access) ?? trimmedString(record.token),
|
|
167
|
+
accountId: trimmedString(record.accountId) ?? trimmedString(record.account_id),
|
|
168
|
+
source: "registry",
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function accountIdFromJwt(token: string): string | undefined {
|
|
176
|
+
const payloadSegment = token.split(".")[1];
|
|
177
|
+
if (payloadSegment === undefined) return undefined;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const payload = JSON.parse(
|
|
181
|
+
Buffer.from(toBase64(payloadSegment), "base64").toString("utf8"),
|
|
182
|
+
) as unknown;
|
|
183
|
+
const payloadRecord = asRecord(payload);
|
|
184
|
+
const authRecord = asRecord(payloadRecord?.["https://api.openai.com/auth"]);
|
|
185
|
+
return trimmedString(authRecord?.chatgpt_account_id);
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toBase64(base64Url: string): string {
|
|
192
|
+
const base64 = base64Url.replaceAll("-", "+").replaceAll("_", "/");
|
|
193
|
+
const paddingLength = (4 - (base64.length % 4)) % 4;
|
|
194
|
+
return `${base64}${"=".repeat(paddingLength)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function credentialAttempt(material: CredentialMaterial | undefined): CredentialAttempt | undefined {
|
|
198
|
+
if (material === undefined) return undefined;
|
|
199
|
+
|
|
200
|
+
const attempt: CredentialAttempt = { ...material };
|
|
201
|
+
const credentials = credentialsFromParts(material);
|
|
202
|
+
if (credentials !== undefined) attempt.credentials = credentials;
|
|
203
|
+
return attempt;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function credentialsFromParts(parts: CredentialMaterial): CodexOAuthCredentials | undefined {
|
|
207
|
+
if (parts.accessToken === undefined || parts.accountId === undefined) return undefined;
|
|
208
|
+
|
|
209
|
+
const accessToken = parts.accessToken;
|
|
210
|
+
const accountId = parts.accountId;
|
|
211
|
+
const source = parts.source;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
accessToken,
|
|
215
|
+
accountId,
|
|
216
|
+
source,
|
|
217
|
+
toJSON() {
|
|
218
|
+
return {
|
|
219
|
+
accessToken: "<redacted>",
|
|
220
|
+
accountId: redactAccountId(accountId),
|
|
221
|
+
source,
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function bestCredentialAttempt(
|
|
228
|
+
current: CredentialAttempt | undefined,
|
|
229
|
+
next: CredentialAttempt | undefined,
|
|
230
|
+
): CredentialAttempt | undefined {
|
|
231
|
+
if (next === undefined) return current;
|
|
232
|
+
if (current === undefined) return next;
|
|
233
|
+
if (next.accessToken !== undefined && current.accessToken === undefined) return next;
|
|
234
|
+
return current;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function credentialFailure(
|
|
238
|
+
bestAttempt: CredentialAttempt | undefined,
|
|
239
|
+
checkedSources: CodexCredentialSource[],
|
|
240
|
+
): CodexCredentialResolution {
|
|
241
|
+
const hasAccessToken = bestAttempt?.accessToken !== undefined;
|
|
242
|
+
const hasAccountId = bestAttempt?.accountId !== undefined;
|
|
243
|
+
const code: CodexCredentialErrorCode =
|
|
244
|
+
hasAccessToken && !hasAccountId ? "missing_account_id" : "missing_credentials";
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: {
|
|
249
|
+
code,
|
|
250
|
+
message:
|
|
251
|
+
code === "missing_account_id"
|
|
252
|
+
? "Missing openai-codex Account ID. Run /login openai-codex."
|
|
253
|
+
: "Missing openai-codex OAuth credentials. Run /login openai-codex.",
|
|
254
|
+
},
|
|
255
|
+
diagnostics: {
|
|
256
|
+
source: bestAttempt?.source ?? "none",
|
|
257
|
+
checkedSources,
|
|
258
|
+
hasAccessToken,
|
|
259
|
+
hasAccountId,
|
|
260
|
+
...(bestAttempt?.accountId === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { accountId: redactAccountId(bestAttempt.accountId) }),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function credentialSuccess(
|
|
268
|
+
credentials: CodexOAuthCredentials,
|
|
269
|
+
checkedSources: CodexCredentialSource[],
|
|
270
|
+
): CodexCredentialResolution {
|
|
271
|
+
const diagnostics: CodexCredentialDiagnostics = {
|
|
272
|
+
source: credentials.source,
|
|
273
|
+
checkedSources,
|
|
274
|
+
hasAccessToken: true,
|
|
275
|
+
hasAccountId: true,
|
|
276
|
+
accountId: redactAccountId(credentials.accountId),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
credentials,
|
|
282
|
+
diagnostics,
|
|
283
|
+
toJSON() {
|
|
284
|
+
return { ok: true, credentials: credentials.toJSON(), diagnostics };
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function redactAccountId(accountId: string): string {
|
|
290
|
+
if (accountId.length <= 4) return "<redacted>";
|
|
291
|
+
return `…${accountId.slice(-4)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function trimmedString(value: unknown): string | undefined {
|
|
295
|
+
if (typeof value !== "string") return undefined;
|
|
296
|
+
const trimmed = value.trim();
|
|
297
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
301
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
302
|
+
return value as Record<string, unknown>;
|
|
303
|
+
}
|