@99percentpeople/pi-codex-api 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 +207 -0
- package/client.ts +237 -0
- package/config.ts +79 -0
- package/image.ts +343 -0
- package/index.ts +103 -0
- package/package.json +64 -0
- package/render.ts +23 -0
- package/search-display.ts +259 -0
- package/search.ts +316 -0
- package/settings.ts +122 -0
- package/skills/gpt-image-prompts/SKILL.md +282 -0
- package/usage.ts +628 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pi Extensions
|
|
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,207 @@
|
|
|
1
|
+
# @99percentpeople/pi-codex-api
|
|
2
|
+
|
|
3
|
+
Expose first-party Codex subscription APIs as native Pi tools and commands.
|
|
4
|
+
The extension reuses Pi's existing `openai-codex` OAuth login and does not need
|
|
5
|
+
an OpenAI API key, MCP server, or separate search provider.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Pi authenticated with `/login` for the `openai-codex` provider
|
|
10
|
+
- An active `openai-codex` model, or **Other providers** enabled in `/99settings`
|
|
11
|
+
- A ChatGPT workspace and plan entitled to the requested Codex feature
|
|
12
|
+
|
|
13
|
+
The extension still loads when Codex has not been configured. It does not
|
|
14
|
+
prompt or attempt an OAuth request during installation. A tool call instead
|
|
15
|
+
shows an actionable error directing you to `/login`; an expired login is
|
|
16
|
+
reported the same way after Pi's refresh attempt fails.
|
|
17
|
+
|
|
18
|
+
API-key authentication is intentionally rejected because these tools target the
|
|
19
|
+
ChatGPT subscription backend rather than metered Platform API usage.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:@99percentpeople/pi-codex-api
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
During development:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install ./extensions/codex-api
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
### `codex_image`
|
|
36
|
+
|
|
37
|
+
Generates or edits images through the Codex subscription image API using
|
|
38
|
+
`gpt-image-2`.
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
codex_image prompt="A quiet neon-lit ramen shop at night"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For edits, provide up to five image paths inside the current workspace:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
codex_image \
|
|
48
|
+
prompt="Change only the sky to a warm sunset" \
|
|
49
|
+
referenced_image_paths=["assets/photo.png"] \
|
|
50
|
+
output_path="assets/photo-sunset.png"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If an edit target was attached or generated in the conversation and has no
|
|
54
|
+
usable local path, the model can instead include the smallest necessary window
|
|
55
|
+
of one to five recent conversation images with `num_last_images_to_include`.
|
|
56
|
+
The two reference mechanisms are mutually exclusive.
|
|
57
|
+
|
|
58
|
+
The model may request a validated GPT Image 2 `WIDTHxHEIGHT` size when exact
|
|
59
|
+
dimensions are part of the task. It normally omits `quality`, inheriting the
|
|
60
|
+
user's **Image quality** preference, and only overrides it when the user
|
|
61
|
+
explicitly requests a draft or a quality level.
|
|
62
|
+
|
|
63
|
+
New images default to `output/codex-images/<tool-call-id>.png`. Reference and
|
|
64
|
+
output paths must stay inside the current workspace, and existing files are
|
|
65
|
+
never overwritten. The PNG is both saved locally and returned as image
|
|
66
|
+
content so Codex can inspect or revise it in later turns. If a network request
|
|
67
|
+
fails before an HTTP response, the error identifies the endpoint path and any
|
|
68
|
+
safe transport code (for example `ECONNRESET`). The extension itself never
|
|
69
|
+
retries image generation automatically, because the server may have accepted
|
|
70
|
+
the first request; decide whether to retry after reviewing the error.
|
|
71
|
+
|
|
72
|
+
### `codex_search`
|
|
73
|
+
|
|
74
|
+
Uses the first-party Codex standalone search API. Supported command families:
|
|
75
|
+
|
|
76
|
+
- web and image queries;
|
|
77
|
+
- open, click, and find operations using returned reference IDs;
|
|
78
|
+
- PDF page screenshots;
|
|
79
|
+
- finance, weather, sports, and time lookups.
|
|
80
|
+
|
|
81
|
+
Search mode is configurable as Cached, Indexed, or Live. External content is
|
|
82
|
+
untrusted and should never be treated as instructions.
|
|
83
|
+
|
|
84
|
+
## Parameter ownership
|
|
85
|
+
|
|
86
|
+
The extension follows the current official Codex split between task intent,
|
|
87
|
+
user policy, and internal protocol fields:
|
|
88
|
+
|
|
89
|
+
| Owner | Search | Image |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| AI per call | queries, recency, task-specific domains, navigation/lookup commands, response length | prompt, local or recent-conversation references, task-specific size, explicit quality override, destination path |
|
|
92
|
+
| User in `/99settings` | Cached/Indexed/Live mode and search context size | default image quality |
|
|
93
|
+
| Extension/backend | session/model routing, caller policy and token ceiling | fixed `gpt-image-2`, automatic background, one PNG result, reference and dimension validation |
|
|
94
|
+
|
|
95
|
+
Model selection, batch count, output format, input fidelity, masks, moderation,
|
|
96
|
+
and transparent background are intentionally not advertised as AI arguments on
|
|
97
|
+
this subscription path. In particular, GPT Image 2 does not support native
|
|
98
|
+
transparent-background output; requesting transparency through an unsupported
|
|
99
|
+
field would be misleading.
|
|
100
|
+
|
|
101
|
+
## Bundled skill
|
|
102
|
+
|
|
103
|
+
The package also ships the Agent Skills-standard **`gpt-image-prompts`** skill
|
|
104
|
+
under `skills/`. Pi discovers it with the package and can load it on demand to
|
|
105
|
+
write or refine production-ready GPT Image 2 prompts for new images,
|
|
106
|
+
reference-guided work, and precise edits. Invoke it explicitly with:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
/skill:gpt-image-prompts
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The skill contains visual prompt-writing guidance only. It does not select or
|
|
113
|
+
document tools, construct API requests, manage credentials, or prescribe file
|
|
114
|
+
and execution workflows.
|
|
115
|
+
|
|
116
|
+
## Tool display
|
|
117
|
+
|
|
118
|
+
Web and image queries render normalized source cards instead of the backend's
|
|
119
|
+
raw citation dump. The collapsed view shows three title/domain/snippet cards;
|
|
120
|
+
Pi's configured tool-output expansion shortcut (`Ctrl+O` by default) expands
|
|
121
|
+
every source with its full URL. Internal reference markers,
|
|
122
|
+
word-limit metadata, and separators are hidden. Open/click/find/PDF operations
|
|
123
|
+
use a cleaned document view, while weather, finance, sports, and time lookups
|
|
124
|
+
use a compact data view. This changes only the TUI display copy: the model
|
|
125
|
+
still receives the complete original search output.
|
|
126
|
+
|
|
127
|
+
Search and image parameters stream into the call row while the model constructs
|
|
128
|
+
them; input parameters are never repeated in the result area. Both tools also
|
|
129
|
+
stream real execution stages into the active result row while they run
|
|
130
|
+
(authentication, request, reference loading, generation, and saving as
|
|
131
|
+
applicable). Successful `codex_search` and `codex_image` calls also request a
|
|
132
|
+
rate-limited background usage refresh. The Codex search and image endpoints
|
|
133
|
+
return one final response, so result bodies appear atomically rather than as
|
|
134
|
+
fabricated content chunks.
|
|
135
|
+
|
|
136
|
+
## Commands
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
/codex-usage
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Fast mode is controlled only through **Codex API → Fast mode** in
|
|
143
|
+
`/99settings`. It sends `service_tier: "priority"` on Codex Responses requests
|
|
144
|
+
and can reduce latency while consuming included limits faster. The extension fetches
|
|
145
|
+
subscription usage directly from Codex's official ChatGPT WHAM endpoint when a
|
|
146
|
+
session starts or a Codex model is selected; `/codex-usage` forces a fresh
|
|
147
|
+
read, so no model request is required first. Automatic post-response refreshes
|
|
148
|
+
are limited to once per minute.
|
|
149
|
+
|
|
150
|
+
Pi does not currently publish an OAuth-account-change event or expose its
|
|
151
|
+
credential store to extensions. As a temporary compatibility layer, this
|
|
152
|
+
extension watches Pi's agent-directory `auth.json`. A short debounce reloads
|
|
153
|
+
the public model registry and compares the resolved Codex Account ID. After
|
|
154
|
+
`/login` replaces the account, the old snapshot is removed, the status changes
|
|
155
|
+
to `Codex syncing…`, and a forced account-scoped refresh starts. `/logout`
|
|
156
|
+
clears the status. In-flight requests are revision-guarded, so an old account
|
|
157
|
+
cannot overwrite the newly active account. The watcher is closed on session
|
|
158
|
+
teardown and never accesses Pi's private authentication runtime.
|
|
159
|
+
|
|
160
|
+
`/codex-usage` groups every metered limit under a simple `Codex usage` heading.
|
|
161
|
+
Each server-provided window (for example 5h, daily, or weekly) uses a fixed
|
|
162
|
+
20-cell bar whose filled portion represents remaining capacity, followed only
|
|
163
|
+
by `% left` and its reset time. Additional credit availability stays under the
|
|
164
|
+
same limit group. Inactive zero-value placeholder windows are hidden. Response
|
|
165
|
+
headers remain supported as a fallback.
|
|
166
|
+
|
|
167
|
+
## Settings
|
|
168
|
+
|
|
169
|
+
Use the shared menu:
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
/99settings
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The **Codex API** section controls:
|
|
176
|
+
|
|
177
|
+
- **Other providers** — off by default. When enabled, a non-Codex model may
|
|
178
|
+
call `codex_image` and `codex_search`; the extension resolves only the
|
|
179
|
+
separately logged-in `openai-codex` OAuth account. It never sends the active
|
|
180
|
+
model provider's credentials to ChatGPT.
|
|
181
|
+
- **Fast mode** — enables or disables the priority service tier;
|
|
182
|
+
- Cached, Indexed, or Live search;
|
|
183
|
+
- search context size;
|
|
184
|
+
- default GPT Image 2 quality (`Auto`, `Low`, `Medium`, or `High`);
|
|
185
|
+
- subscription usage status visibility.
|
|
186
|
+
|
|
187
|
+
Configuration is stored under the `codex-api` namespace in:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
~/.pi/agent/99extensions.json
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Authentication and privacy
|
|
194
|
+
|
|
195
|
+
The extension asks Pi's model registry for a refreshed OAuth token at tool-call
|
|
196
|
+
time. It sends the same bearer token and `ChatGPT-Account-ID` used by Pi's
|
|
197
|
+
`openai-codex` adapter. With **Other providers** enabled, it resolves that
|
|
198
|
+
Codex model specifically rather than using the active model's credentials.
|
|
199
|
+
Tokens are never copied into extension settings or tool results. The extracted
|
|
200
|
+
ChatGPT account ID is used only as an in-memory Usage-state key and is never
|
|
201
|
+
persisted or displayed. The watcher reacts only to the `auth.json` filename;
|
|
202
|
+
credential parsing and resolution remain inside Pi's public model registry.
|
|
203
|
+
|
|
204
|
+
Image prompts, reference images, search commands, and search context are sent
|
|
205
|
+
to OpenAI's Codex backend and are subject to the active ChatGPT workspace's
|
|
206
|
+
policies. Feature availability and request formats may change as Codex rolls
|
|
207
|
+
out backend updates.
|
package/client.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
4
|
+
const CODEX_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
5
|
+
|
|
6
|
+
export type CodexFetch = typeof fetch;
|
|
7
|
+
|
|
8
|
+
export interface CodexApiClientOptions {
|
|
9
|
+
accessToken: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
modelId?: string;
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
fetch?: CodexFetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CodexApiError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly body: unknown;
|
|
20
|
+
|
|
21
|
+
constructor(status: number, message: string, body?: unknown) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "CodexApiError";
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.body = body;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerValue(headers: Record<string, string> | undefined, name: string): string | undefined {
|
|
30
|
+
const normalized = name.toLowerCase();
|
|
31
|
+
return Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === normalized)?.[1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractCodexAccountId(accessToken: string): string {
|
|
35
|
+
try {
|
|
36
|
+
const parts = accessToken.split(".");
|
|
37
|
+
if (parts.length !== 3) throw new Error("not a JWT");
|
|
38
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as {
|
|
39
|
+
[CODEX_AUTH_CLAIM]?: { chatgpt_account_id?: unknown };
|
|
40
|
+
};
|
|
41
|
+
const accountId = payload[CODEX_AUTH_CLAIM]?.chatgpt_account_id;
|
|
42
|
+
if (typeof accountId !== "string" || accountId.length === 0) throw new Error("missing claim");
|
|
43
|
+
return accountId;
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("Failed to extract ChatGPT account ID from Codex OAuth token");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveCodexApiRoot(baseUrl = DEFAULT_CODEX_BASE_URL): string {
|
|
50
|
+
const normalized = baseUrl.trim().replace(/\/+$/, "");
|
|
51
|
+
if (normalized.endsWith("/codex/responses")) return normalized.slice(0, -"/responses".length);
|
|
52
|
+
if (normalized.endsWith("/codex")) return normalized;
|
|
53
|
+
return `${normalized}/codex`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorMessage(status: number, statusText: string, body: unknown): string {
|
|
57
|
+
if (body && typeof body === "object") {
|
|
58
|
+
const error = (body as { error?: unknown }).error;
|
|
59
|
+
if (typeof error === "string" && error.trim()) return error;
|
|
60
|
+
if (error && typeof error === "object") {
|
|
61
|
+
const message = (error as { message?: unknown }).message;
|
|
62
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
63
|
+
}
|
|
64
|
+
const message = (body as { message?: unknown }).message;
|
|
65
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
66
|
+
}
|
|
67
|
+
if (typeof body === "string" && body.trim()) return body.trim();
|
|
68
|
+
return `Codex API request failed with HTTP ${status}${statusText ? ` ${statusText}` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function responseBody(response: Response): Promise<unknown> {
|
|
72
|
+
const text = await response.text();
|
|
73
|
+
if (!text) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function transportErrorCode(error: unknown): string | undefined {
|
|
82
|
+
const values = [
|
|
83
|
+
error,
|
|
84
|
+
error && typeof error === "object" ? (error as { cause?: unknown }).cause : undefined,
|
|
85
|
+
];
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
if (!value || typeof value !== "object") continue;
|
|
88
|
+
const code = (value as { code?: unknown }).code;
|
|
89
|
+
if (typeof code === "string" && /^[A-Z0-9_-]+$/.test(code)) return code;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function transportError(method: string, endpoint: string, error: unknown): CodexApiError {
|
|
95
|
+
const path = new URL(endpoint).pathname;
|
|
96
|
+
const code = transportErrorCode(error);
|
|
97
|
+
return new CodexApiError(
|
|
98
|
+
0,
|
|
99
|
+
`Codex network request failed before a response: ${method} ${path}${code ? ` (${code})` : ""}. `
|
|
100
|
+
+ "No HTTP status was received, so a generation may or may not have reached ChatGPT. "
|
|
101
|
+
+ "Automatic retry was not attempted.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class CodexApiClient {
|
|
106
|
+
readonly rootUrl: string;
|
|
107
|
+
readonly modelId?: string;
|
|
108
|
+
/** ChatGPT account identity used only for in-memory account-scoped state. */
|
|
109
|
+
readonly accountId: string;
|
|
110
|
+
private readonly accessToken: string;
|
|
111
|
+
private readonly headers: Record<string, string>;
|
|
112
|
+
private readonly fetchImpl: CodexFetch;
|
|
113
|
+
|
|
114
|
+
constructor(options: CodexApiClientOptions) {
|
|
115
|
+
this.rootUrl = resolveCodexApiRoot(options.baseUrl);
|
|
116
|
+
this.modelId = options.modelId;
|
|
117
|
+
this.accessToken = options.accessToken;
|
|
118
|
+
this.accountId = options.accountId;
|
|
119
|
+
this.headers = options.headers ?? {};
|
|
120
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private endpoint(path: string): string {
|
|
124
|
+
const root = new URL(`${this.rootUrl}/`);
|
|
125
|
+
const endpoint = new URL(path.replace(/^\/+/, ""), root);
|
|
126
|
+
if (endpoint.protocol !== "https:" || endpoint.hostname !== "chatgpt.com" || endpoint.origin !== root.origin) {
|
|
127
|
+
throw new Error(`Refusing to send Codex OAuth credentials to non-ChatGPT endpoint: ${endpoint.origin}`);
|
|
128
|
+
}
|
|
129
|
+
return endpoint.toString();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async request<T>(
|
|
133
|
+
method: "GET" | "POST",
|
|
134
|
+
path: string,
|
|
135
|
+
body: unknown,
|
|
136
|
+
signal?: AbortSignal,
|
|
137
|
+
): Promise<T> {
|
|
138
|
+
const headers = new Headers(this.headers);
|
|
139
|
+
headers.set("authorization", `Bearer ${this.accessToken}`);
|
|
140
|
+
headers.set("chatgpt-account-id", this.accountId);
|
|
141
|
+
headers.set("originator", "pi");
|
|
142
|
+
headers.set("accept", "application/json");
|
|
143
|
+
if (body !== undefined) headers.set("content-type", "application/json");
|
|
144
|
+
|
|
145
|
+
const endpoint = this.endpoint(path);
|
|
146
|
+
let response: Response;
|
|
147
|
+
try {
|
|
148
|
+
response = await this.fetchImpl(endpoint, {
|
|
149
|
+
method,
|
|
150
|
+
headers,
|
|
151
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
152
|
+
signal,
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw transportError(method, endpoint, error);
|
|
156
|
+
}
|
|
157
|
+
const parsed = await responseBody(response);
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new CodexApiError(
|
|
160
|
+
response.status,
|
|
161
|
+
errorMessage(response.status, response.statusText, parsed),
|
|
162
|
+
parsed,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return parsed as T;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async get<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
169
|
+
return this.request<T>("GET", path, undefined, signal);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
|
173
|
+
return this.request<T>("POST", path, body, signal);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface CodexApiClientContextOptions {
|
|
178
|
+
/** Allow a non-Codex active model to use separately stored Codex OAuth. */
|
|
179
|
+
allowOtherProviders?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function codexOAuthUnavailable(message?: string): Error {
|
|
183
|
+
return new Error(
|
|
184
|
+
`Codex subscription OAuth is unavailable${message ? `: ${message}` : ""}. `
|
|
185
|
+
+ "Run /login and sign in to openai-codex, then retry.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function resolveCodexAuthModel(
|
|
190
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
191
|
+
allowOtherProviders: boolean,
|
|
192
|
+
) {
|
|
193
|
+
if (ctx.model?.provider === "openai-codex") return ctx.model;
|
|
194
|
+
if (!allowOtherProviders) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
"Codex API tools require an active openai-codex model. "
|
|
197
|
+
+ "Enable Other providers in /99settings to use them from another model.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const model = ctx.modelRegistry.getAll().find((candidate) =>
|
|
201
|
+
candidate.provider === "openai-codex" && ctx.modelRegistry.isUsingOAuth(candidate)
|
|
202
|
+
);
|
|
203
|
+
if (!model) throw codexOAuthUnavailable();
|
|
204
|
+
return model;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function createCodexApiClient(
|
|
208
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
209
|
+
optionsOrFetch: CodexApiClientContextOptions | CodexFetch = {},
|
|
210
|
+
fetchImpl?: CodexFetch,
|
|
211
|
+
): Promise<CodexApiClient> {
|
|
212
|
+
// Keep the former (ctx, fetch) call shape usable for extension consumers.
|
|
213
|
+
const options = typeof optionsOrFetch === "function" ? {} : optionsOrFetch;
|
|
214
|
+
const effectiveFetch = typeof optionsOrFetch === "function" ? optionsOrFetch : fetchImpl;
|
|
215
|
+
const model = resolveCodexAuthModel(ctx, options.allowOtherProviders === true);
|
|
216
|
+
if (!ctx.modelRegistry.isUsingOAuth(model)) {
|
|
217
|
+
throw codexOAuthUnavailable("API-key authentication is not supported");
|
|
218
|
+
}
|
|
219
|
+
const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
220
|
+
if (!resolved.ok) throw codexOAuthUnavailable(resolved.error);
|
|
221
|
+
if (!resolved.apiKey) throw codexOAuthUnavailable();
|
|
222
|
+
const accountId = headerValue(resolved.headers, "chatgpt-account-id")
|
|
223
|
+
?? extractCodexAccountId(resolved.apiKey);
|
|
224
|
+
const baseUrl = model.baseUrl ?? DEFAULT_CODEX_BASE_URL;
|
|
225
|
+
const endpoint = new URL(resolveCodexApiRoot(baseUrl));
|
|
226
|
+
if (endpoint.protocol !== "https:" || endpoint.hostname !== "chatgpt.com") {
|
|
227
|
+
throw new Error(`Refusing to send Codex OAuth credentials to non-ChatGPT endpoint: ${endpoint.origin}`);
|
|
228
|
+
}
|
|
229
|
+
return new CodexApiClient({
|
|
230
|
+
accessToken: resolved.apiKey,
|
|
231
|
+
accountId,
|
|
232
|
+
modelId: model.id,
|
|
233
|
+
baseUrl,
|
|
234
|
+
headers: resolved.headers,
|
|
235
|
+
fetch: effectiveFetch,
|
|
236
|
+
});
|
|
237
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSharedSettingsPath,
|
|
3
|
+
readSettingsNamespace,
|
|
4
|
+
writeSettingsNamespace,
|
|
5
|
+
} from "@99percentpeople/pi-shared-settings";
|
|
6
|
+
|
|
7
|
+
export type CodexSearchMode = "cached" | "indexed" | "live";
|
|
8
|
+
export type CodexSearchContextSize = "low" | "medium" | "high";
|
|
9
|
+
export type CodexImageQuality = "auto" | "low" | "medium" | "high";
|
|
10
|
+
|
|
11
|
+
export interface CodexApiConfig {
|
|
12
|
+
fastMode: boolean;
|
|
13
|
+
allowOtherProviders: boolean;
|
|
14
|
+
searchMode: CodexSearchMode;
|
|
15
|
+
searchContextSize: CodexSearchContextSize;
|
|
16
|
+
imageQuality: CodexImageQuality;
|
|
17
|
+
usageStatus: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CODEX_API_SETTINGS_NAMESPACE = "codex-api";
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_CODEX_API_CONFIG: CodexApiConfig = {
|
|
23
|
+
fastMode: false,
|
|
24
|
+
allowOtherProviders: false,
|
|
25
|
+
searchMode: "cached",
|
|
26
|
+
searchContextSize: "medium",
|
|
27
|
+
imageQuality: "auto",
|
|
28
|
+
usageStatus: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function oneOf<T extends string>(value: unknown, values: readonly T[], fallback: T): T {
|
|
32
|
+
return typeof value === "string" && values.includes(value as T) ? value as T : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeCodexApiConfig(value: unknown): CodexApiConfig {
|
|
36
|
+
if (!value || typeof value !== "object") return { ...DEFAULT_CODEX_API_CONFIG };
|
|
37
|
+
const input = value as Record<string, unknown>;
|
|
38
|
+
return {
|
|
39
|
+
fastMode: typeof input.fastMode === "boolean"
|
|
40
|
+
? input.fastMode
|
|
41
|
+
: DEFAULT_CODEX_API_CONFIG.fastMode,
|
|
42
|
+
allowOtherProviders: typeof input.allowOtherProviders === "boolean"
|
|
43
|
+
? input.allowOtherProviders
|
|
44
|
+
: DEFAULT_CODEX_API_CONFIG.allowOtherProviders,
|
|
45
|
+
searchMode: oneOf(
|
|
46
|
+
input.searchMode,
|
|
47
|
+
["cached", "indexed", "live"],
|
|
48
|
+
DEFAULT_CODEX_API_CONFIG.searchMode,
|
|
49
|
+
),
|
|
50
|
+
searchContextSize: oneOf(
|
|
51
|
+
input.searchContextSize,
|
|
52
|
+
["low", "medium", "high"],
|
|
53
|
+
DEFAULT_CODEX_API_CONFIG.searchContextSize,
|
|
54
|
+
),
|
|
55
|
+
imageQuality: oneOf(
|
|
56
|
+
input.imageQuality,
|
|
57
|
+
["auto", "low", "medium", "high"],
|
|
58
|
+
DEFAULT_CODEX_API_CONFIG.imageQuality,
|
|
59
|
+
),
|
|
60
|
+
usageStatus: typeof input.usageStatus === "boolean"
|
|
61
|
+
? input.usageStatus
|
|
62
|
+
: DEFAULT_CODEX_API_CONFIG.usageStatus,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getCodexApiConfigPath(): string {
|
|
67
|
+
return getSharedSettingsPath();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadCodexApiConfig(path = getCodexApiConfigPath()): CodexApiConfig {
|
|
71
|
+
return readSettingsNamespace(CODEX_API_SETTINGS_NAMESPACE, normalizeCodexApiConfig, path);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function saveCodexApiConfig(
|
|
75
|
+
config: CodexApiConfig,
|
|
76
|
+
path = getCodexApiConfigPath(),
|
|
77
|
+
): void {
|
|
78
|
+
writeSettingsNamespace(CODEX_API_SETTINGS_NAMESPACE, normalizeCodexApiConfig(config), path);
|
|
79
|
+
}
|