@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 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
+ }