pi-codex-token 1.0.1
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 +143 -0
- package/package.json +50 -0
- package/src/auth.ts +266 -0
- package/src/codex-envelope.ts +69 -0
- package/src/config.ts +105 -0
- package/src/discover-models.ts +113 -0
- package/src/index.ts +45 -0
- package/src/models.ts +26 -0
- package/src/provider.ts +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-codex-token contributors
|
|
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,143 @@
|
|
|
1
|
+
# pi-codex-token
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) provider extension that registers a
|
|
4
|
+
`codex-token` provider so pi can use **`gpt-5.5` on the OpenAI Codex backend**
|
|
5
|
+
(`chatgpt.com/backend-api/codex`), authenticated **non-interactively with a Codex
|
|
6
|
+
personal/enterprise access token (PAT)**.
|
|
7
|
+
|
|
8
|
+
It lets you run Codex with a long-lived access token instead of an interactive
|
|
9
|
+
ChatGPT OAuth login — which is what makes it usable for headless/CI automation.
|
|
10
|
+
|
|
11
|
+
Why a separate provider: pi's built-in `openai-codex` reads the `chatgpt-account-id`
|
|
12
|
+
by JWT-decoding the credential, which an opaque PAT can't satisfy. This provider
|
|
13
|
+
fetches that id out-of-band (the codex `whoami` endpoint) instead, so a plain PAT
|
|
14
|
+
works.
|
|
15
|
+
|
|
16
|
+
> **Codex access tokens are an OpenAI Codex _enterprise_ feature.** A workspace admin
|
|
17
|
+
> mints a long-lived personal/enterprise access token (`at-…`) for non-interactive use;
|
|
18
|
+
> see OpenAI's docs:
|
|
19
|
+
> [Codex enterprise — access tokens](https://developers.openai.com/codex/enterprise/access-tokens).
|
|
20
|
+
> Without an enterprise plan you won't have a PAT — use pi's built-in `openai-codex`
|
|
21
|
+
> provider (interactive OAuth) instead.
|
|
22
|
+
>
|
|
23
|
+
> **This needs a PAT (`at-…`), not an OpenAI API key (`sk-…`).** The Codex backend is a
|
|
24
|
+
> different auth domain — `sk-…` keys are rejected (401). For `sk-…` keys, use pi's plain
|
|
25
|
+
> `openai` provider.
|
|
26
|
+
|
|
27
|
+
## Install / local dev
|
|
28
|
+
|
|
29
|
+
This is a no-build, single-file-style pi extension. Run it straight from a clone:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install # installs dev deps + the pi host packages (peer deps)
|
|
33
|
+
pi -e . --provider codex-token --model gpt-5.5 -p "Reply with exactly: SPIKE_OK"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`pi` resolves the extension's `@earendil-works/pi-*` imports from its own install at
|
|
37
|
+
runtime; `npm install` provides the same packages for local typecheck/test.
|
|
38
|
+
|
|
39
|
+
## Production use
|
|
40
|
+
|
|
41
|
+
`pi -e .` is the dev loop. In production you **install** the extension into the
|
|
42
|
+
environment where pi runs (a worker image, CI runner, server) and configure it via env:
|
|
43
|
+
|
|
44
|
+
1. **Distribute** — publish to npm, or pin a git tag/SHA for an internal build:
|
|
45
|
+
```bash
|
|
46
|
+
pi install pi-codex-token # from npm
|
|
47
|
+
# or, pinned to an immutable ref:
|
|
48
|
+
pi install <git-url>#<tag-or-sha>
|
|
49
|
+
```
|
|
50
|
+
`pi install` records the source in pi's settings, so the extension loads automatically
|
|
51
|
+
on every subsequent pi run (no `-e` needed). In a Docker/image build, run the install
|
|
52
|
+
step at build time so it's baked in.
|
|
53
|
+
|
|
54
|
+
2. **Configure** (env in the runtime):
|
|
55
|
+
```bash
|
|
56
|
+
export CODEX_ACCESS_TOKEN=at-... # the enterprise PAT (see above)
|
|
57
|
+
export CODEX_ACCOUNT_ID=<uuid> # optional but recommended headless — skips the whoami call
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
3. **Select the provider/model** — either per invocation
|
|
61
|
+
(`pi --provider codex-token --model gpt-5.5 …`) or via pi's default provider/model config.
|
|
62
|
+
|
|
63
|
+
At startup pi loads the extension, the async factory discovers the account's models with
|
|
64
|
+
the PAT, and the `codex-token` provider is ready. Pin a tag/SHA (not a moving branch) for a
|
|
65
|
+
reproducible deploy, and gate upgrades on the `npm run smoke` contract test.
|
|
66
|
+
|
|
67
|
+
## Credentials
|
|
68
|
+
|
|
69
|
+
PAT precedence (first non-empty wins):
|
|
70
|
+
|
|
71
|
+
1. the provider `apiKey` (pi resolves `$ENV` / `!command` / `--api-key`)
|
|
72
|
+
2. `CODEX_ACCESS_TOKEN` env, then `CODEX_PAT` (first non-empty wins)
|
|
73
|
+
3. `~/.codex/auth.json` `.personal_access_token` (from `codex login --with-access-token`)
|
|
74
|
+
|
|
75
|
+
`sk-…` API keys are rejected — the codex backend is a different auth domain (use
|
|
76
|
+
pi's plain `openai` provider for those).
|
|
77
|
+
|
|
78
|
+
### Account-id (headless)
|
|
79
|
+
|
|
80
|
+
The `chatgpt-account-id` is a stable workspace UUID resolved in this order:
|
|
81
|
+
|
|
82
|
+
1. `CODEX_ACCOUNT_ID` env override (**recommended for headless/CI** — no network)
|
|
83
|
+
2. in-memory cache (keyed by `SHA-256(PAT)`)
|
|
84
|
+
3. on-disk cache `~/.pi/agent/codex-token-accountid.json` (mode 0600, keyed by `SHA-256(PAT)`)
|
|
85
|
+
4. codex `whoami` (`Authorization: Bearer <PAT>`)
|
|
86
|
+
5. `~/.codex/auth.json` `.tokens.account_id` (local dev only)
|
|
87
|
+
|
|
88
|
+
For headless use, set **both** `CODEX_ACCESS_TOKEN` and `CODEX_ACCOUNT_ID` so resolution
|
|
89
|
+
is fully synchronous with no network round-trip.
|
|
90
|
+
|
|
91
|
+
PATs are **not** auto-refreshable. On a 401 (whoami or backend), the error tells you
|
|
92
|
+
to mint a new PAT.
|
|
93
|
+
|
|
94
|
+
## Models
|
|
95
|
+
|
|
96
|
+
The provider **discovers the account's available models** at registration by calling the
|
|
97
|
+
codex `/models` endpoint with the PAT, and registers the ones the account exposes
|
|
98
|
+
(`visibility: list`, API-supported). No PAT at registration, a `/models` error, or an
|
|
99
|
+
empty result falls back to a static `gpt-5.5` entry.
|
|
100
|
+
|
|
101
|
+
- Set **`CODEX_MODELS`** (comma-separated ids, e.g. `gpt-5.5,gpt-5.4`) to skip discovery
|
|
102
|
+
and pin an explicit list.
|
|
103
|
+
- `contextWindow` comes from `/models`; **`maxTokens` is a default** (not returned by the
|
|
104
|
+
endpoint) and is unverified. The static fallback declares `input: ["text"]` (the proven
|
|
105
|
+
path); discovered entries use the modalities the backend reports.
|
|
106
|
+
|
|
107
|
+
## Config knobs (env)
|
|
108
|
+
|
|
109
|
+
| Env var | Purpose |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `CODEX_ACCESS_TOKEN` / `CODEX_PAT` | PAT source (first non-empty wins) |
|
|
112
|
+
| `CODEX_ACCOUNT_ID` | workspace UUID override (skips whoami) |
|
|
113
|
+
| `CODEX_MODELS` | comma-separated model-id list; skips live model discovery |
|
|
114
|
+
| `CODEX_HOME` | dir for `auth.json` (default `~/.codex`) |
|
|
115
|
+
| `CODEX_BASE_URL` | codex inference base URL override |
|
|
116
|
+
| `CODEX_WHOAMI_URL` / `CODEX_AUTHAPI_BASE_URL` | whoami URL override (testing) |
|
|
117
|
+
| `PI_AGENT_HOME` | dir for the on-disk account-id cache |
|
|
118
|
+
|
|
119
|
+
## Testing
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm test # vitest unit suite + coverage (≥99% on src/**)
|
|
123
|
+
npm run smoke # live request to the real codex endpoint (needs CODEX_ACCESS_TOKEN)
|
|
124
|
+
npm run check-exports
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## How it works
|
|
128
|
+
|
|
129
|
+
The codex backend is an **undocumented** contract. The provider reuses pi-ai's
|
|
130
|
+
exported `streamSimpleOpenAIResponses` for the HTTP/SSE transport + parsing, injects
|
|
131
|
+
the codex auth headers, and reshapes the request body (top-level `instructions`,
|
|
132
|
+
`store:false`) to satisfy the backend's gates. When the contract drifts, the change
|
|
133
|
+
is confined to `src/codex-envelope.ts` + `src/config.ts`, and the smoke test is the
|
|
134
|
+
early-warning. See [`AGENTS.md`](./AGENTS.md) for the full architecture.
|
|
135
|
+
|
|
136
|
+
## Contributing
|
|
137
|
+
|
|
138
|
+
See [`AGENTS.md`](./AGENTS.md) (architecture + conventions) and
|
|
139
|
+
[`CONTRIBUTING.md`](./CONTRIBUTING.md).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-codex-token",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "pi provider plugin: use gpt-5.5 on the OpenAI Codex backend via a personal access token (PAT), non-interactively.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"codex",
|
|
11
|
+
"openai",
|
|
12
|
+
"gpt-5.5",
|
|
13
|
+
"provider",
|
|
14
|
+
"personal-access-token"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clean": "echo 'nothing to clean'",
|
|
23
|
+
"build": "echo 'nothing to build'",
|
|
24
|
+
"check": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run --coverage",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"smoke": "vitest run test/smoke.test.ts",
|
|
28
|
+
"check-exports": "node scripts/check-exports.mjs"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-ai": ">=0.79.0 <0.80.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0 <0.80.0",
|
|
41
|
+
"@types/node": "^20.0.0",
|
|
42
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
43
|
+
"typescript": "^6.0.0",
|
|
44
|
+
"vitest": "^4.1.8"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@earendil-works/pi-ai": ">=0.79.0 <0.80.0",
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0 <0.80.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential + account-id lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Pure and provider-agnostic. The PAT is opaque (`at-…`, not a JWT) so the
|
|
5
|
+
* `chatgpt-account-id` cannot be decoded from it — it is resolved out-of-band via
|
|
6
|
+
* the codex whoami endpoint and cached, keyed by SHA-256(PAT) so PAT rotation
|
|
7
|
+
* auto-invalidates the cache and the raw PAT is never written to disk.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
ENV_ACCOUNT_ID,
|
|
16
|
+
ENV_CODEX_HOME,
|
|
17
|
+
ENV_PAT_PRIMARY,
|
|
18
|
+
ENV_PI_AGENT_HOME,
|
|
19
|
+
PAT_ENV_VARS,
|
|
20
|
+
PAT_PREFIX,
|
|
21
|
+
httpTimeoutMs,
|
|
22
|
+
whoamiUrl,
|
|
23
|
+
} from "./config.js";
|
|
24
|
+
|
|
25
|
+
/** A fetch-compatible function. The DI seam for whoami (overridable in tests). */
|
|
26
|
+
export type FetchImpl = typeof fetch;
|
|
27
|
+
|
|
28
|
+
export type CredentialSource = "pi-config" | "env" | "codex-auth-json";
|
|
29
|
+
|
|
30
|
+
export interface ResolvedCredentials {
|
|
31
|
+
pat: string;
|
|
32
|
+
source: CredentialSource;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Raised when a PAT is rejected (401/403) by whoami or the codex backend.
|
|
37
|
+
* PATs are NOT auto-refreshable (unlike OAuth) — the only recovery is minting a
|
|
38
|
+
* new one, so the message is actionable.
|
|
39
|
+
*/
|
|
40
|
+
export class PatAuthError extends Error {
|
|
41
|
+
constructor(public readonly httpStatus?: number) {
|
|
42
|
+
super(
|
|
43
|
+
`Codex PAT rejected${httpStatus ? ` (HTTP ${httpStatus})` : ""}. The personal access ` +
|
|
44
|
+
`token is expired, revoked, or invalid. PATs are NOT auto-refreshable — mint a new one ` +
|
|
45
|
+
`in the ChatGPT admin console (Settings → Personal access tokens) and update ` +
|
|
46
|
+
`${ENV_PAT_PRIMARY} (or the provider apiKey / ~/.codex/auth.json). If you switched workspaces, ` +
|
|
47
|
+
`also clear the cached account-id at ~/.pi/agent/codex-token-accountid.json.`,
|
|
48
|
+
);
|
|
49
|
+
this.name = "PatAuthError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True for HTTP 401/403, whether the value is a PatAuthError, an SDK error, or a 401 message. */
|
|
54
|
+
export function is401(e: unknown): boolean {
|
|
55
|
+
if (e instanceof PatAuthError) return e.httpStatus === undefined || e.httpStatus === 401 || e.httpStatus === 403;
|
|
56
|
+
const status = (e as { status?: unknown })?.status;
|
|
57
|
+
if (status === 401 || status === 403) return true;
|
|
58
|
+
// Message fallback: only the parenthesized status the inner provider emits
|
|
59
|
+
// ("OpenAI API error (401): …"). Matching a bare 401/403 anywhere would misread
|
|
60
|
+
// an id fragment or count in a 400/500 message as an auth failure.
|
|
61
|
+
const msg = e instanceof Error ? e.message : typeof e === "string" ? e : "";
|
|
62
|
+
return /\((?:401|403)\)/.test(msg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- PAT sourcing ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function authJsonPath(env: NodeJS.ProcessEnv): string {
|
|
68
|
+
const home = env[ENV_CODEX_HOME];
|
|
69
|
+
return home ? join(home, "auth.json") : join(homedir(), ".codex", "auth.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validate(pat: string, source: CredentialSource): ResolvedCredentials {
|
|
73
|
+
if (pat.startsWith("sk-")) {
|
|
74
|
+
// sk- keys are 401 against the codex backend (wrong auth domain).
|
|
75
|
+
throw new Error(
|
|
76
|
+
"Got an OpenAI API key (sk-…), but the Codex backend requires a personal access token " +
|
|
77
|
+
"(at-…). Use the plain `openai` provider for sk- keys.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (!pat.startsWith(PAT_PREFIX)) {
|
|
81
|
+
// Don't hard-fail (prefix could drift) but warn — the token is opaque, not a JWT.
|
|
82
|
+
console.warn(`[codex-token] PAT does not start with "${PAT_PREFIX}"; proceeding (opaque token).`);
|
|
83
|
+
}
|
|
84
|
+
return { pat, source };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** First non-empty value among the accepted PAT env vars (precedence order). */
|
|
88
|
+
export function patFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
89
|
+
for (const name of PAT_ENV_VARS) {
|
|
90
|
+
const value = env[name]?.trim();
|
|
91
|
+
if (value) return value;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the PAT. Precedence:
|
|
98
|
+
* 1. pi-resolved ProviderConfig.apiKey (runtime --api-key / $ENV / !command)
|
|
99
|
+
* 2. PAT env vars: CODEX_ACCESS_TOKEN, then CODEX_PAT
|
|
100
|
+
* 3. ~/.codex/auth.json .personal_access_token (local `codex login`)
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveCredentials(
|
|
103
|
+
optionsApiKey?: string,
|
|
104
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
105
|
+
): Promise<ResolvedCredentials> {
|
|
106
|
+
const fromConfig = optionsApiKey?.trim();
|
|
107
|
+
if (fromConfig) return validate(fromConfig, "pi-config");
|
|
108
|
+
|
|
109
|
+
const fromEnv = patFromEnv(env);
|
|
110
|
+
if (fromEnv) return validate(fromEnv, "env");
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const raw = await readFile(authJsonPath(env), "utf8");
|
|
114
|
+
const pat = (JSON.parse(raw) as { personal_access_token?: string }).personal_access_token?.trim();
|
|
115
|
+
if (pat) return validate(pat, "codex-auth-json");
|
|
116
|
+
} catch {
|
|
117
|
+
/* no file / unreadable -> fall through */
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error(
|
|
121
|
+
`No Codex PAT found. Set ${ENV_PAT_PRIMARY}, configure the provider's apiKey ` +
|
|
122
|
+
`(e.g. "$${ENV_PAT_PRIMARY}"), or run \`codex login --with-access-token\`.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- account-id resolution (headless) ----------------------------------------
|
|
127
|
+
|
|
128
|
+
interface WhoamiMetadata {
|
|
129
|
+
chatgpt_account_id?: string;
|
|
130
|
+
account_id?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const memCache = new Map<string, string>();
|
|
134
|
+
|
|
135
|
+
/** Test-only: reset the in-memory account-id cache. */
|
|
136
|
+
export function clearMemCache(): void {
|
|
137
|
+
memCache.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function patKey(pat: string): string {
|
|
141
|
+
return createHash("sha256").update(pat).digest("hex").slice(0, 16);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function diskCachePath(env: NodeJS.ProcessEnv): string {
|
|
145
|
+
const base = env[ENV_PI_AGENT_HOME] ?? join(homedir(), ".pi", "agent");
|
|
146
|
+
return join(base, "codex-token-accountid.json");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readDiskCache(key: string, env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
150
|
+
try {
|
|
151
|
+
const raw = await readFile(diskCachePath(env), "utf8");
|
|
152
|
+
return (JSON.parse(raw) as Record<string, string>)[key];
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeDiskCache(key: string, id: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
159
|
+
const path = diskCachePath(env);
|
|
160
|
+
let current: Record<string, string> = {};
|
|
161
|
+
try {
|
|
162
|
+
current = JSON.parse(await readFile(path, "utf8")) as Record<string, string>;
|
|
163
|
+
} catch {
|
|
164
|
+
/* fresh file */
|
|
165
|
+
}
|
|
166
|
+
current[key] = id;
|
|
167
|
+
await mkdir(dirname(path), { recursive: true });
|
|
168
|
+
// Write to a unique temp file then atomically rename, so a concurrent reader (or
|
|
169
|
+
// another process sharing this cache) never observes a torn/invalid JSON file.
|
|
170
|
+
// (A last-writer-wins merge can still drop a key under cross-process races, but
|
|
171
|
+
// that is self-healing: the next readDiskCache miss simply re-resolves via whoami.)
|
|
172
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
await writeFile(tmp, JSON.stringify(current, null, 2), { mode: 0o600 });
|
|
175
|
+
await rename(tmp, path);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
await unlink(tmp).catch(() => {}); // best-effort: don't leave an orphan .tmp behind
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function accountIdFromWhoami(
|
|
183
|
+
pat: string,
|
|
184
|
+
fetchImpl: FetchImpl,
|
|
185
|
+
env: NodeJS.ProcessEnv,
|
|
186
|
+
signal?: AbortSignal,
|
|
187
|
+
): Promise<string> {
|
|
188
|
+
// Always bound by a timeout; also honor the caller's abort signal if given, so a
|
|
189
|
+
// cancelled request doesn't leave whoami running to completion.
|
|
190
|
+
const timeout = AbortSignal.timeout(httpTimeoutMs(env));
|
|
191
|
+
const res = await fetchImpl(whoamiUrl(env), {
|
|
192
|
+
headers: { Authorization: `Bearer ${pat}` },
|
|
193
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
194
|
+
});
|
|
195
|
+
if (res.status === 401 || res.status === 403) throw new PatAuthError(res.status);
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Codex whoami failed with HTTP ${res.status}. The endpoint may have changed; mirror the ` +
|
|
199
|
+
`official codex CLI (login/src/auth/personal_access_token.rs).`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const meta = (await res.json()) as WhoamiMetadata;
|
|
203
|
+
const id = meta.chatgpt_account_id ?? meta.account_id;
|
|
204
|
+
if (!id) throw new Error("Codex whoami returned no chatgpt_account_id (response shape drift).");
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Dev-only convenience: account-id from a local `codex login` (OAuth-mode) auth.json. */
|
|
209
|
+
async function accountIdFromAuthJson(env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
210
|
+
try {
|
|
211
|
+
const raw = await readFile(authJsonPath(env), "utf8");
|
|
212
|
+
return (JSON.parse(raw) as { tokens?: { account_id?: string } })?.tokens?.account_id;
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the chatgpt-account-id for a PAT. Order:
|
|
220
|
+
* 1. CODEX_ACCOUNT_ID env override (recommended for headless use — synchronous, no network)
|
|
221
|
+
* 2. in-memory cache (keyed by SHA-256(PAT))
|
|
222
|
+
* 3. on-disk cache (~/.pi/agent/codex-token-accountid.json, mode 0600, keyed by SHA-256(PAT))
|
|
223
|
+
* 4. whoami(PAT)
|
|
224
|
+
* 5. ~/.codex/auth.json .tokens.account_id (dev convenience only)
|
|
225
|
+
*
|
|
226
|
+
* A 401/403 from whoami throws PatAuthError. Other whoami failures fall back to the
|
|
227
|
+
* dev auth.json before giving up.
|
|
228
|
+
*/
|
|
229
|
+
export async function resolveAccountId(
|
|
230
|
+
pat: string,
|
|
231
|
+
fetchImpl: FetchImpl = globalThis.fetch,
|
|
232
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
const override = env[ENV_ACCOUNT_ID]?.trim();
|
|
236
|
+
if (override) return override;
|
|
237
|
+
|
|
238
|
+
const key = patKey(pat);
|
|
239
|
+
|
|
240
|
+
const mem = memCache.get(key);
|
|
241
|
+
if (mem) return mem;
|
|
242
|
+
|
|
243
|
+
const fromDisk = await readDiskCache(key, env);
|
|
244
|
+
if (fromDisk) {
|
|
245
|
+
memCache.set(key, fromDisk);
|
|
246
|
+
return fromDisk;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let id: string;
|
|
250
|
+
try {
|
|
251
|
+
id = await accountIdFromWhoami(pat, fetchImpl, env, signal);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (e instanceof PatAuthError) throw e;
|
|
254
|
+
// Best-effort dev fallback for a transient whoami failure (timeout/5xx). Do NOT
|
|
255
|
+
// cache it: the local OAuth auth.json account-id may belong to a different
|
|
256
|
+
// workspace than the PAT, and caching it would send a mismatched
|
|
257
|
+
// chatgpt-account-id on every later request even after whoami recovers.
|
|
258
|
+
const dev = await accountIdFromAuthJson(env);
|
|
259
|
+
if (dev) return dev;
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
memCache.set(key, id);
|
|
264
|
+
await writeDiskCache(key, id, env);
|
|
265
|
+
return id;
|
|
266
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE volatile bit, isolated. When the OpenAI codex backend drifts, you edit
|
|
3
|
+
* ONLY this file (plus config.ts) and the smoke-test fixture.
|
|
4
|
+
*
|
|
5
|
+
* Proven-200 request envelope (captured from the working spike, secrets masked):
|
|
6
|
+
*
|
|
7
|
+
* POST https://chatgpt.com/backend-api/codex/responses
|
|
8
|
+
* Authorization: Bearer at-***
|
|
9
|
+
* chatgpt-account-id: ***UUID***
|
|
10
|
+
* OpenAI-Beta: responses=experimental
|
|
11
|
+
* originator: pi
|
|
12
|
+
* Content-Type: application/json
|
|
13
|
+
* Accept: text/event-stream
|
|
14
|
+
*
|
|
15
|
+
* { "model":"gpt-5.5", "input":[{user…}], "stream":true, "store":false,
|
|
16
|
+
* "reasoning":{"effort":…}, "instructions":"…" }
|
|
17
|
+
*
|
|
18
|
+
* The body-delta vs what pi's generic openai-responses provider emits: codex
|
|
19
|
+
* requires a TOP-LEVEL `instructions` string. `convertResponsesMessages` instead
|
|
20
|
+
* inlines the system prompt as a `developer` turn inside `input`, so the backend
|
|
21
|
+
* returns 400 {"detail":"Instructions are required"}. `makeOnPayload` reproduces
|
|
22
|
+
* the proven shape post-hoc.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { DEFAULT_INSTRUCTIONS, OPENAI_BETA, ORIGINATOR } from "./config.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Body transform for the `onPayload` hook. `onPayload` only receives
|
|
29
|
+
* `(payload, model)` — not `context` — so the system prompt is captured here in a
|
|
30
|
+
* closure. Carried verbatim from the proven spike.
|
|
31
|
+
*/
|
|
32
|
+
export function makeOnPayload(systemPrompt: string | undefined) {
|
|
33
|
+
return (payload: unknown): unknown => {
|
|
34
|
+
const body = payload as Record<string, unknown> & { input?: unknown[] };
|
|
35
|
+
// 1. Hoist the system prompt to a top-level `instructions` (codex gate).
|
|
36
|
+
body.instructions =
|
|
37
|
+
systemPrompt && systemPrompt.length > 0 ? systemPrompt : DEFAULT_INSTRUCTIONS;
|
|
38
|
+
// 2. Drop the leading developer/system turn convertResponsesMessages injected
|
|
39
|
+
// (it would otherwise duplicate the instructions inside `input`).
|
|
40
|
+
if (Array.isArray(body.input)) {
|
|
41
|
+
body.input = body.input.filter((m) => {
|
|
42
|
+
const role = (m as { role?: string })?.role;
|
|
43
|
+
return role !== "system" && role !== "developer";
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// 3. Enforce codex gates (buildParams already sets these; belt-and-suspenders).
|
|
47
|
+
body.store = false;
|
|
48
|
+
body.stream = true;
|
|
49
|
+
return body;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The codex wire headers. `streamSimpleOpenAIResponses` merges these as the SDK's
|
|
55
|
+
* `defaultHeaders` without clobbering, so our values win.
|
|
56
|
+
*/
|
|
57
|
+
export function buildHeaders(
|
|
58
|
+
pat: string,
|
|
59
|
+
accountId: string,
|
|
60
|
+
extra: Record<string, string> = {},
|
|
61
|
+
): Record<string, string> {
|
|
62
|
+
return {
|
|
63
|
+
...extra,
|
|
64
|
+
Authorization: `Bearer ${pat}`,
|
|
65
|
+
"chatgpt-account-id": accountId,
|
|
66
|
+
"OpenAI-Beta": OPENAI_BETA,
|
|
67
|
+
originator: ORIGINATOR,
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All constants and env-var names for the codex-token provider live here.
|
|
3
|
+
*
|
|
4
|
+
* The codex backend is an UNDOCUMENTED contract: the betas, headers, and base
|
|
5
|
+
* URLs below can drift without notice. Keeping them in one file means a contract
|
|
6
|
+
* change is a one-line edit here (or in codex-envelope.ts), not a hunt across the
|
|
7
|
+
* package. The values were verified against a live codex request (see AGENTS.md).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Provider id registered with pi. */
|
|
11
|
+
export const PROVIDER_NAME = "codex-token";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Custom api id. Required when `streamSimple` is given, and chosen so it never
|
|
15
|
+
* collides with pi's built-in `openai` / `openai-codex` / `openai-responses`.
|
|
16
|
+
* Single source of truth for the api id across the package.
|
|
17
|
+
*/
|
|
18
|
+
export const API_ID = "codex-token-responses";
|
|
19
|
+
|
|
20
|
+
/** Default codex inference backend. The OpenAI SDK appends `/responses`. */
|
|
21
|
+
export const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex";
|
|
22
|
+
|
|
23
|
+
/** Default codex auth/whoami host (distinct from the inference host). */
|
|
24
|
+
export const DEFAULT_WHOAMI_URL =
|
|
25
|
+
"https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami";
|
|
26
|
+
|
|
27
|
+
/** Dated SSE beta the codex backend accepts today. */
|
|
28
|
+
export const OPENAI_BETA = "responses=experimental";
|
|
29
|
+
|
|
30
|
+
/** Sent as the `originator` header; matches the proven-200 request. */
|
|
31
|
+
export const ORIGINATOR = "pi";
|
|
32
|
+
|
|
33
|
+
/** Fallback when there is no system prompt (codex requires top-level instructions). */
|
|
34
|
+
export const DEFAULT_INSTRUCTIONS = "You are a helpful assistant.";
|
|
35
|
+
|
|
36
|
+
/** Opaque PATs start with this; sk- keys are rejected (wrong auth domain). */
|
|
37
|
+
export const PAT_PREFIX = "at-";
|
|
38
|
+
|
|
39
|
+
/** maxTokens is not returned by the /models endpoint; sensible default (unverified). */
|
|
40
|
+
export const DEFAULT_MAX_TOKENS = 128000;
|
|
41
|
+
/** contextWindow default when /models omits it. */
|
|
42
|
+
export const DEFAULT_CONTEXT_WINDOW = 272000;
|
|
43
|
+
/** `client_version` query param the /models endpoint requires. */
|
|
44
|
+
export const DEFAULT_CODEX_CLIENT_VERSION = "0.139.0";
|
|
45
|
+
/** Response timeout (ms) for the whoami / models fetches, so they can't hang forever. */
|
|
46
|
+
export const DEFAULT_HTTP_TIMEOUT_MS = 10000;
|
|
47
|
+
|
|
48
|
+
// --- env var names (no magic strings elsewhere) ------------------------------
|
|
49
|
+
/** Primary PAT env var — matches the OpenAI codex CLI convention (CODEX_ACCESS_TOKEN). */
|
|
50
|
+
export const ENV_PAT_PRIMARY = "CODEX_ACCESS_TOKEN";
|
|
51
|
+
/** PAT env var precedence (first non-empty wins). Primary first. */
|
|
52
|
+
export const PAT_ENV_VARS = [ENV_PAT_PRIMARY, "CODEX_PAT"] as const;
|
|
53
|
+
/** Static workspace UUID override — skips whoami entirely. */
|
|
54
|
+
export const ENV_ACCOUNT_ID = "CODEX_ACCOUNT_ID";
|
|
55
|
+
/** Comma-separated model-id override; skips live model discovery. */
|
|
56
|
+
export const ENV_MODELS = "CODEX_MODELS";
|
|
57
|
+
/** Override for the /models `client_version` query param. */
|
|
58
|
+
export const ENV_CLIENT_VERSION = "CODEX_CLIENT_VERSION";
|
|
59
|
+
/** Override (ms) for the whoami / models fetch timeout. */
|
|
60
|
+
export const ENV_HTTP_TIMEOUT_MS = "CODEX_HTTP_TIMEOUT_MS";
|
|
61
|
+
/** Mirrors codex: dir holding auth.json (local dev). */
|
|
62
|
+
export const ENV_CODEX_HOME = "CODEX_HOME";
|
|
63
|
+
/** Full whoami URL override (testing / mock). */
|
|
64
|
+
export const ENV_WHOAMI_URL = "CODEX_WHOAMI_URL";
|
|
65
|
+
/** Base-URL override for the auth host (mirrors codex personal_access_token.rs). */
|
|
66
|
+
export const ENV_AUTHAPI_BASE_URL = "CODEX_AUTHAPI_BASE_URL";
|
|
67
|
+
/** codex inference base-URL override (testing). */
|
|
68
|
+
export const ENV_CODEX_BASE_URL = "CODEX_BASE_URL";
|
|
69
|
+
/** Dir for the on-disk account-id cache. */
|
|
70
|
+
export const ENV_PI_AGENT_HOME = "PI_AGENT_HOME";
|
|
71
|
+
|
|
72
|
+
// --- env-derived values (functions so tests can override process.env) --------
|
|
73
|
+
|
|
74
|
+
/** The codex inference base URL, honoring CODEX_BASE_URL. */
|
|
75
|
+
export function codexBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
76
|
+
return env[ENV_CODEX_BASE_URL]?.trim() || DEFAULT_CODEX_BASE_URL;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The codex model-listing endpoint (`{codexBaseUrl}/models`), for discovery. */
|
|
80
|
+
export function modelsUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
81
|
+
return `${codexBaseUrl(env)}/models`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The `client_version` query value the /models endpoint requires, honoring the override. */
|
|
85
|
+
export function codexClientVersion(env: NodeJS.ProcessEnv = process.env): string {
|
|
86
|
+
return env[ENV_CLIENT_VERSION]?.trim() || DEFAULT_CODEX_CLIENT_VERSION;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Fetch timeout (ms), honoring CODEX_HTTP_TIMEOUT_MS; falls back to the default for blank/invalid values. */
|
|
90
|
+
export function httpTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
91
|
+
const raw = env[ENV_HTTP_TIMEOUT_MS]?.trim();
|
|
92
|
+
const parsed = raw ? Number(raw) : Number.NaN;
|
|
93
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HTTP_TIMEOUT_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The whoami URL. Precedence: CODEX_WHOAMI_URL (full) → CODEX_AUTHAPI_BASE_URL
|
|
98
|
+
* (base, with the whoami path appended) → default. Mirrors codex's override.
|
|
99
|
+
*/
|
|
100
|
+
export function whoamiUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
101
|
+
const full = env[ENV_WHOAMI_URL]?.trim();
|
|
102
|
+
if (full) return full;
|
|
103
|
+
const base = env[ENV_AUTHAPI_BASE_URL]?.trim().replace(/\/+$/, "");
|
|
104
|
+
return base ? `${base}/v1/user-auth-credential/whoami` : DEFAULT_WHOAMI_URL;
|
|
105
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live model discovery against the codex `/models` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* The account's available models are discovered at registration so the provider isn't
|
|
5
|
+
* pinned to a hardcoded list. The endpoint needs only `Authorization: Bearer <PAT>` and
|
|
6
|
+
* a `client_version` query param (no account-id / beta). Its per-model shape carries
|
|
7
|
+
* `slug`, `display_name`, `context_window`, `input_modalities`,
|
|
8
|
+
* `supported_reasoning_levels`, `visibility`, and `supported_in_api` — everything we
|
|
9
|
+
* need except `maxTokens` (defaulted). Verified against the live endpoint; see AGENTS.md.
|
|
10
|
+
*
|
|
11
|
+
* Precedence:
|
|
12
|
+
* 1. CODEX_MODELS env override (comma-separated ids) — no network
|
|
13
|
+
* 2. live GET {codexBaseUrl}/models?client_version=… → visible, api-supported models
|
|
14
|
+
* 3. FALLBACK_MODELS ([gpt-5.5]) on any failure / empty result
|
|
15
|
+
*
|
|
16
|
+
* Discovery never throws — it always degrades to FALLBACK_MODELS so registration can't break.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { FetchImpl } from "./auth.js";
|
|
21
|
+
import {
|
|
22
|
+
API_ID,
|
|
23
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
24
|
+
DEFAULT_MAX_TOKENS,
|
|
25
|
+
ENV_MODELS,
|
|
26
|
+
codexBaseUrl,
|
|
27
|
+
codexClientVersion,
|
|
28
|
+
httpTimeoutMs,
|
|
29
|
+
modelsUrl,
|
|
30
|
+
} from "./config.js";
|
|
31
|
+
import { FALLBACK_MODELS } from "./models.js";
|
|
32
|
+
|
|
33
|
+
/** The subset of the codex `/models` per-entry shape we consume. */
|
|
34
|
+
interface RawCodexModel {
|
|
35
|
+
slug?: string;
|
|
36
|
+
display_name?: string;
|
|
37
|
+
context_window?: number;
|
|
38
|
+
input_modalities?: string[];
|
|
39
|
+
supported_reasoning_levels?: unknown[];
|
|
40
|
+
visibility?: string;
|
|
41
|
+
supported_in_api?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function baseConfig(id: string, env: NodeJS.ProcessEnv): ProviderModelConfig {
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
name: id,
|
|
48
|
+
api: API_ID,
|
|
49
|
+
baseUrl: codexBaseUrl(env),
|
|
50
|
+
reasoning: true,
|
|
51
|
+
input: ["text"],
|
|
52
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
53
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
54
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toConfig(raw: RawCodexModel, env: NodeJS.ProcessEnv): ProviderModelConfig | undefined {
|
|
59
|
+
// Type-guard every field: the /models payload is untrusted wire data, so a single
|
|
60
|
+
// malformed entry (e.g. a non-string slug) must be skipped, not throw inside .map()
|
|
61
|
+
// and degrade the whole batch to FALLBACK_MODELS.
|
|
62
|
+
const id = typeof raw.slug === "string" ? raw.slug.trim() : "";
|
|
63
|
+
if (!id) return undefined;
|
|
64
|
+
const modalities = Array.isArray(raw.input_modalities)
|
|
65
|
+
? raw.input_modalities.filter((m): m is "text" | "image" => m === "text" || m === "image")
|
|
66
|
+
: [];
|
|
67
|
+
const name = typeof raw.display_name === "string" && raw.display_name.trim() ? raw.display_name.trim() : id;
|
|
68
|
+
return {
|
|
69
|
+
...baseConfig(id, env),
|
|
70
|
+
name,
|
|
71
|
+
reasoning: Array.isArray(raw.supported_reasoning_levels) && raw.supported_reasoning_levels.length > 0,
|
|
72
|
+
input: modalities.length ? modalities : ["text"],
|
|
73
|
+
contextWindow: typeof raw.context_window === "number" ? raw.context_window : DEFAULT_CONTEXT_WINDOW,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build configs for an explicit CODEX_MODELS override (generic defaults per id). */
|
|
78
|
+
function fromOverride(value: string, env: NodeJS.ProcessEnv): ProviderModelConfig[] {
|
|
79
|
+
return value
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((id) => baseConfig(id, env));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function discoverModels(
|
|
87
|
+
pat: string,
|
|
88
|
+
fetchImpl: FetchImpl = globalThis.fetch,
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
+
): Promise<ProviderModelConfig[]> {
|
|
91
|
+
const override = env[ENV_MODELS]?.trim();
|
|
92
|
+
if (override) {
|
|
93
|
+
const models = fromOverride(override, env);
|
|
94
|
+
return models.length ? models : FALLBACK_MODELS;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const url = `${modelsUrl(env)}?client_version=${encodeURIComponent(codexClientVersion(env))}`;
|
|
99
|
+
const res = await fetchImpl(url, {
|
|
100
|
+
headers: { Authorization: `Bearer ${pat}` },
|
|
101
|
+
signal: AbortSignal.timeout(httpTimeoutMs(env)),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) return FALLBACK_MODELS;
|
|
104
|
+
const data = (await res.json()) as { models?: RawCodexModel[] };
|
|
105
|
+
const models = (data.models ?? [])
|
|
106
|
+
.filter((m) => m.visibility === "list" && m.supported_in_api !== false)
|
|
107
|
+
.map((m) => toConfig(m, env))
|
|
108
|
+
.filter((m): m is ProviderModelConfig => m !== undefined);
|
|
109
|
+
return models.length ? models : FALLBACK_MODELS;
|
|
110
|
+
} catch {
|
|
111
|
+
return FALLBACK_MODELS;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension: register the `codex-token` provider so pi can use OpenAI Codex models
|
|
3
|
+
* (e.g. gpt-5.5) authenticated with an opaque personal access token (PAT),
|
|
4
|
+
* non-interactively.
|
|
5
|
+
*
|
|
6
|
+
* Thin wiring only — all logic lives in the src/ modules (see AGENTS.md):
|
|
7
|
+
* config.ts constants + env-var names
|
|
8
|
+
* models.ts the static FALLBACK_MODELS
|
|
9
|
+
* discover-models.ts live model discovery (/models endpoint) + CODEX_MODELS override
|
|
10
|
+
* auth.ts resolveCredentials / resolveAccountId / caching / PatAuthError
|
|
11
|
+
* codex-envelope.ts makeOnPayload + buildHeaders (the volatile contract)
|
|
12
|
+
* provider.ts streamCodexPat (own-stream + async IIFE)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { resolveCredentials } from "./auth.js";
|
|
17
|
+
import { API_ID, DEFAULT_CODEX_BASE_URL, ENV_PAT_PRIMARY, PROVIDER_NAME } from "./config.js";
|
|
18
|
+
import { discoverModels } from "./discover-models.js";
|
|
19
|
+
import { FALLBACK_MODELS } from "./models.js";
|
|
20
|
+
import { streamCodexPat } from "./provider.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Best-effort model list at registration: if a PAT is available in the environment
|
|
24
|
+
* (env or ~/.codex/auth.json), discover the account's models; otherwise use the static
|
|
25
|
+
* fallback. Never throws — registration must not break on a discovery failure.
|
|
26
|
+
*/
|
|
27
|
+
export async function registrationModels(): Promise<ProviderModelConfig[]> {
|
|
28
|
+
let pat: string;
|
|
29
|
+
try {
|
|
30
|
+
pat = (await resolveCredentials()).pat;
|
|
31
|
+
} catch {
|
|
32
|
+
return FALLBACK_MODELS; // no PAT at registration time
|
|
33
|
+
}
|
|
34
|
+
return discoverModels(pat);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
38
|
+
pi.registerProvider(PROVIDER_NAME, {
|
|
39
|
+
baseUrl: DEFAULT_CODEX_BASE_URL,
|
|
40
|
+
apiKey: `$${ENV_PAT_PRIMARY}`,
|
|
41
|
+
api: API_ID,
|
|
42
|
+
streamSimple: (model, context, options) => streamCodexPat(model, context, options),
|
|
43
|
+
models: await registrationModels(),
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { API_ID, DEFAULT_CODEX_BASE_URL, DEFAULT_MAX_TOKENS } from "./config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static fallback model list. Used when live discovery (see `discover-models.ts`) is
|
|
6
|
+
* unavailable — no PAT at registration, the `/models` endpoint errors, or it returns
|
|
7
|
+
* nothing. The account's real model set is normally discovered dynamically.
|
|
8
|
+
*
|
|
9
|
+
* `gpt-5.5` is the proven model: it returned HTTP 200 on a ChatGPT account, and the
|
|
10
|
+
* request shape is verified by the smoke test. `input: ["text"]` only — the proven run
|
|
11
|
+
* was text-only; image is unverified against our SSE transport even though the backend
|
|
12
|
+
* advertises it. (Discovered entries use the modalities the backend reports.)
|
|
13
|
+
*/
|
|
14
|
+
export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
|
15
|
+
{
|
|
16
|
+
id: "gpt-5.5",
|
|
17
|
+
name: "GPT-5.5 (Codex PAT)",
|
|
18
|
+
api: API_ID,
|
|
19
|
+
baseUrl: DEFAULT_CODEX_BASE_URL,
|
|
20
|
+
reasoning: true,
|
|
21
|
+
input: ["text"],
|
|
22
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
23
|
+
contextWindow: 272000,
|
|
24
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
25
|
+
},
|
|
26
|
+
];
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider stream function. Thin composition of auth + codex-envelope +
|
|
3
|
+
* the reused `streamSimpleOpenAIResponses`.
|
|
4
|
+
*
|
|
5
|
+
* `streamSimple` must RETURN the stream object synchronously, but the async body
|
|
6
|
+
* feeding it may await. So we create our own AssistantMessageEventStream, run the
|
|
7
|
+
* work (credential + account-id resolution, which may hit whoami) in an async
|
|
8
|
+
* IIFE, pipe the inner provider's events into our stream, and return ours
|
|
9
|
+
* synchronously.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type Api,
|
|
14
|
+
type AssistantMessage,
|
|
15
|
+
type AssistantMessageEvent,
|
|
16
|
+
type AssistantMessageEventStream,
|
|
17
|
+
type Context,
|
|
18
|
+
type Model,
|
|
19
|
+
type SimpleStreamOptions,
|
|
20
|
+
createAssistantMessageEventStream,
|
|
21
|
+
streamSimpleOpenAIResponses,
|
|
22
|
+
} from "@earendil-works/pi-ai";
|
|
23
|
+
import { type FetchImpl, PatAuthError, is401, resolveAccountId, resolveCredentials } from "./auth.js";
|
|
24
|
+
import { buildHeaders, makeOnPayload } from "./codex-envelope.js";
|
|
25
|
+
import { codexBaseUrl } from "./config.js";
|
|
26
|
+
|
|
27
|
+
/** Injectable seams for unit testing. Defaults are the real implementations. */
|
|
28
|
+
export interface StreamDeps {
|
|
29
|
+
streamImpl?: typeof streamSimpleOpenAIResponses;
|
|
30
|
+
createStream?: typeof createAssistantMessageEventStream;
|
|
31
|
+
resolveCredentialsImpl?: typeof resolveCredentials;
|
|
32
|
+
resolveAccountIdImpl?: typeof resolveAccountId;
|
|
33
|
+
fetchImpl?: FetchImpl;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeErrorMessage(
|
|
37
|
+
model: Model<Api>,
|
|
38
|
+
message: string,
|
|
39
|
+
stopReason: "error" | "aborted",
|
|
40
|
+
): AssistantMessage {
|
|
41
|
+
return {
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: [],
|
|
44
|
+
api: model.api,
|
|
45
|
+
provider: model.provider,
|
|
46
|
+
model: model.id,
|
|
47
|
+
usage: {
|
|
48
|
+
input: 0,
|
|
49
|
+
output: 0,
|
|
50
|
+
cacheRead: 0,
|
|
51
|
+
cacheWrite: 0,
|
|
52
|
+
totalTokens: 0,
|
|
53
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
54
|
+
},
|
|
55
|
+
stopReason,
|
|
56
|
+
errorMessage: message,
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function streamCodexPat(
|
|
62
|
+
model: Model<Api>,
|
|
63
|
+
context: Context,
|
|
64
|
+
options?: SimpleStreamOptions,
|
|
65
|
+
deps: StreamDeps = {},
|
|
66
|
+
): AssistantMessageEventStream {
|
|
67
|
+
const streamImpl = deps.streamImpl ?? streamSimpleOpenAIResponses;
|
|
68
|
+
const createStream = deps.createStream ?? createAssistantMessageEventStream;
|
|
69
|
+
const resolveCredentialsImpl = deps.resolveCredentialsImpl ?? resolveCredentials;
|
|
70
|
+
const resolveAccountIdImpl = deps.resolveAccountIdImpl ?? resolveAccountId;
|
|
71
|
+
|
|
72
|
+
const stream = createStream();
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
// Honor an already-cancelled request before doing any credential/whoami work;
|
|
77
|
+
// the signal is also threaded into resolveAccountId so an in-flight whoami aborts.
|
|
78
|
+
options?.signal?.throwIfAborted();
|
|
79
|
+
const { pat } = await resolveCredentialsImpl(options?.apiKey);
|
|
80
|
+
const accountId = await resolveAccountIdImpl(
|
|
81
|
+
pat,
|
|
82
|
+
deps.fetchImpl ?? globalThis.fetch,
|
|
83
|
+
process.env,
|
|
84
|
+
options?.signal,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const headers = buildHeaders(pat, accountId, options?.headers ?? {});
|
|
88
|
+
// The inner code only reads model.id/baseUrl/reasoning/compat, not the api
|
|
89
|
+
// string, for body-building — so re-tagging to "openai-responses" is safe.
|
|
90
|
+
const codexModel = { ...model, baseUrl: codexBaseUrl() } as Model<"openai-responses">;
|
|
91
|
+
|
|
92
|
+
const inner = streamImpl(codexModel, context, {
|
|
93
|
+
...options,
|
|
94
|
+
headers,
|
|
95
|
+
onPayload: makeOnPayload(context.systemPrompt),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
for await (const ev of inner as AsyncIterable<AssistantMessageEvent>) {
|
|
99
|
+
// The backend 401 arrives as an `error` event (the inner provider catches
|
|
100
|
+
// SDK errors internally rather than throwing) — remap its message so the
|
|
101
|
+
// user gets the same actionable "mint a new PAT" text as a whoami 401.
|
|
102
|
+
if (ev.type === "error" && is401(ev.error.errorMessage)) {
|
|
103
|
+
ev.error.errorMessage = new PatAuthError(401).message;
|
|
104
|
+
}
|
|
105
|
+
stream.push(ev);
|
|
106
|
+
}
|
|
107
|
+
stream.end();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// Thrown before/around the inner stream: missing/invalid PAT, sk- key, a
|
|
110
|
+
// whoami 401/403 (PatAuthError), or a cancellation. Funnel 401s through the
|
|
111
|
+
// actionable message, and report a caller cancellation as `aborted` (pi-ai's
|
|
112
|
+
// convention) rather than a generic error so callers can branch on it.
|
|
113
|
+
// (A timeout surfaces as TimeoutError → stays `error`, since the backend hung.)
|
|
114
|
+
const reason: "error" | "aborted" =
|
|
115
|
+
(e as { name?: string })?.name === "AbortError" ? "aborted" : "error";
|
|
116
|
+
const message = is401(e)
|
|
117
|
+
? new PatAuthError(e instanceof PatAuthError ? e.httpStatus : 401).message
|
|
118
|
+
: e instanceof Error
|
|
119
|
+
? e.message
|
|
120
|
+
: String(e);
|
|
121
|
+
stream.push({ type: "error", reason, error: makeErrorMessage(model, message, reason) });
|
|
122
|
+
stream.end();
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
|
|
126
|
+
return stream;
|
|
127
|
+
}
|