@howaboua/pi-codex-conversion 1.5.9 → 1.5.10-dev.41.7e2d8d2
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/CHANGELOG.md +5 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/adapter/compaction.ts +79 -17
- package/src/adapter/provider-request.ts +3 -1
- package/src/adapter/state.ts +11 -0
- package/src/codex-settings/command.ts +33 -2
- package/src/codex-settings/ui.ts +99 -8
- package/src/codex-settings/usage.ts +151 -0
- package/src/index.ts +1 -0
- package/vendor/apply-patch/win32-arm64/apply_patch.exe +0 -0
- package/vendor/apply-patch/win32-x64/apply_patch.exe +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.10
|
|
4
|
+
|
|
5
|
+
- Added `/codex usage` and a Usage tab for OpenAI Codex subscription limits, with automatic refresh and aligned 5-hour/weekly usage columns.
|
|
6
|
+
- Moved settings links into a dedicated About tab.
|
|
7
|
+
|
|
3
8
|
## 1.5.9
|
|
4
9
|
|
|
5
10
|
- Fixed native Responses compaction replay when provider payloads include in-flight tail items that are not yet persisted in the session branch.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ GPT/Codex models are strongest when the tool surface looks like the Codex CLI th
|
|
|
9
9
|
|
|
10
10
|
The point is to give the model tools it already knows how to use well: shell-first inspection, resumable command sessions, and large one-shot patch edits instead of piecemeal read/edit/write steps.
|
|
11
11
|
|
|
12
|
-
You can also opt into using the adapter on every provider/model. YMMV: Codex-tuned models are still the best fit, but the shell/patch workflow can help elsewhere too. The extension also has a small `/codex` settings UI for toggling adapter behavior, web search, image generation, fast mode, and verbosity. See [Settings](#settings).
|
|
12
|
+
You can also opt into using the adapter on every provider/model. YMMV: Codex-tuned models are still the best fit, but the shell/patch workflow can help elsewhere too. The extension also has a small `/codex` settings UI for toggling adapter behavior, web search, image generation, fast mode, native Responses compaction, and verbosity. See [Settings](#settings).
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -46,14 +46,17 @@ Use `/codex` to change adapter settings.
|
|
|
46
46
|
- `/codex fast` — toggle priority service tier for the OpenAI Codex provider
|
|
47
47
|
- `/codex search` — toggle native Codex web search
|
|
48
48
|
- `/codex image` — toggle native Codex image generation
|
|
49
|
+
- `/codex usage` — show Codex subscription usage windows for the active OpenAI Codex model
|
|
49
50
|
- `/codex low`, `/codex medium`, `/codex high` — set Responses API verbosity
|
|
50
51
|
|
|
51
52
|
Settings are saved globally in `~/.pi/agent/pi-codex-conversion.json`.
|
|
52
53
|
|
|
53
|
-
The settings UI also has
|
|
54
|
+
The settings UI also has **Usage**, **Overrides**, and **About** tabs. **Usage** refreshes automatically when opened and can be refreshed manually with `r`. Override options intentionally do not have `/codex ...` command shortcuts:
|
|
54
55
|
|
|
55
56
|
- add only the Pi `apply_patch` tool for GPT/Codex models while keeping Pi's default toolkit, prompt, provider behavior, and compaction flow
|
|
56
57
|
|
|
58
|
+
The **Compaction** tab can enable native OpenAI Responses compaction and choose the compaction model/reasoning. If native compaction fails, the extension falls back to Pi's normal compaction flow; when an older native compacted window exists, it is included in that Pi fallback summarization request so OpenAI can still use the prior opaque context server-side.
|
|
59
|
+
|
|
57
60
|
When `all` is on, non-Codex providers get the shell, patch, skill, and prompt-adapter behavior, but keep their normal Pi provider path. Native web search, native image generation, and priority service tier stay limited to the OpenAI Codex provider. Verbosity is applied to Responses API providers.
|
|
58
61
|
|
|
59
62
|
The footer shows the active state, for example:
|
package/package.json
CHANGED
|
@@ -2,9 +2,9 @@ import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "
|
|
|
2
2
|
import { clampThinkingLevel, type ModelThinkingLevel, type Tool } from "@earendil-works/pi-ai";
|
|
3
3
|
import { executeNativeCompaction } from "./compact-client.ts";
|
|
4
4
|
import { extractCompactionSummaryText, hasCompactionOutputItem, sanitizeCompactedWindow, summarizeCompactionOutputForDiagnostics } from "./compaction-output.ts";
|
|
5
|
-
import { resolveLatestNativeCompactionEntry } from "./details-store.ts";
|
|
5
|
+
import { findLatestNativeCompactionEntry, findLatestNativeCompactionEntryIndex, resolveLatestNativeCompactionEntry } from "./details-store.ts";
|
|
6
6
|
import { rewriteResponsesPayloadWithNativeReplay, serializeLiveTailToResponsesInput } from "./payload-rewrite.ts";
|
|
7
|
-
import { resolveNativeCompactionEnvironment } from "./compaction-runtime.ts";
|
|
7
|
+
import { isResponsesCompatiblePayload, resolveNativeCompactionEnvironment, type ResponsesCompatibleRequestPayload } from "./compaction-runtime.ts";
|
|
8
8
|
import { convertResponsesTools } from "../providers/openai-responses-shared.ts";
|
|
9
9
|
import {
|
|
10
10
|
serializeCompactionPreparationToRequest,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type NativeCompactionRequestOptions,
|
|
13
13
|
type ResponsesInputItem,
|
|
14
14
|
} from "./serializer.ts";
|
|
15
|
-
import { createNativeCompactionDetails, createNativeCompactionShimResult, isNativeCompactionDetails, NATIVE_COMPACTION_SHIM_SUMMARY } from "./types.ts";
|
|
15
|
+
import { createNativeCompactionDetails, createNativeCompactionShimResult, isNativeCompactionDetails, NATIVE_COMPACTION_SHIM_SUMMARY, type NativeCompactionEntry } from "./types.ts";
|
|
16
16
|
import { isOpenAICodexContext, isResponsesContext } from "./codex-model.ts";
|
|
17
17
|
import { shouldUseCodexAdapter } from "./activation.ts";
|
|
18
18
|
import type { AdapterState } from "./state.ts";
|
|
@@ -23,6 +23,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
23
23
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function stashLatestNativeWindowForPiCompactionFallback(
|
|
27
|
+
ctx: ExtensionContext,
|
|
28
|
+
branchEntries: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>,
|
|
29
|
+
runtime: { provider: string; api: string; baseUrl: string },
|
|
30
|
+
state: AdapterState,
|
|
31
|
+
): boolean {
|
|
32
|
+
state.pendingPiCompactionNativeWindow = undefined;
|
|
33
|
+
const nativeEntry = findLatestNativeCompactionEntry(branchEntries, {
|
|
34
|
+
provider: runtime.provider,
|
|
35
|
+
api: runtime.api,
|
|
36
|
+
baseUrl: runtime.baseUrl,
|
|
37
|
+
});
|
|
38
|
+
const compactedWindow = cloneCompactedWindow(nativeEntry?.details?.compactedWindow ?? []);
|
|
39
|
+
if (!compactedWindow || compactedWindow.length === 0) return false;
|
|
40
|
+
state.pendingPiCompactionNativeWindow = {
|
|
41
|
+
window: compactedWindow,
|
|
42
|
+
provider: runtime.provider,
|
|
43
|
+
api: runtime.api,
|
|
44
|
+
baseUrl: runtime.baseUrl,
|
|
45
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
46
|
+
sourceCompactionEntryId: nativeEntry?.id,
|
|
47
|
+
};
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
function cloneCompactedWindow(window: readonly unknown[]): ResponsesInputItem[] | undefined {
|
|
27
52
|
if (!window.every(isRecord)) return undefined;
|
|
28
53
|
return window.map((item) => structuredClone(item));
|
|
@@ -94,7 +119,7 @@ function formatCompactFailureMessage(compactResult: Awaited<ReturnType<typeof ex
|
|
|
94
119
|
const status = compactResult.status ? ` HTTP ${compactResult.status}` : "";
|
|
95
120
|
const response = compactResult.responseText?.trim();
|
|
96
121
|
const detail = response ? `: ${response.slice(0, 500)}` : compactResult.errorMessage ? `: ${compactResult.errorMessage}` : "";
|
|
97
|
-
return `OpenAI native compaction failed (${compactResult.reason}${status})${detail}
|
|
122
|
+
return `OpenAI native compaction failed (${compactResult.reason}${status})${detail}`;
|
|
98
123
|
}
|
|
99
124
|
|
|
100
125
|
function formatCompactRequestDiagnostics(request: NativeCompactionRequestBody): string {
|
|
@@ -104,6 +129,11 @@ function formatCompactRequestDiagnostics(request: NativeCompactionRequestBody):
|
|
|
104
129
|
return `model=${request.model}, input=${request.input.length}, tools=${tools}, reasoning=${reasoning}, service_tier=${serviceTier}`;
|
|
105
130
|
}
|
|
106
131
|
|
|
132
|
+
function notifyNativeCompactionFallback(ctx: ExtensionContext, state: AdapterState, branchEntries: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, runtime: { provider: string; api: string; baseUrl: string }, message: string): void {
|
|
133
|
+
const stashed = stashLatestNativeWindowForPiCompactionFallback(ctx, branchEntries, runtime, state);
|
|
134
|
+
ctx.ui.notify(`${message}; Pi compaction will run.${stashed ? " Previous native compacted window will be included in Pi compaction fallback." : ""}`, "error");
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
export async function handleCodexSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext, state: AdapterState, pi: ExtensionAPI) {
|
|
108
138
|
if (!state.config.responsesCompaction || !shouldUseCodexAdapter(ctx, state.config)) {
|
|
109
139
|
return undefined;
|
|
@@ -203,23 +233,23 @@ async function handleCodexSessionBeforeCompactInner(event: SessionBeforeCompactE
|
|
|
203
233
|
const compactResult = await executeNativeCompaction({ runtime, request, signal: event.signal });
|
|
204
234
|
if (!compactResult.ok) {
|
|
205
235
|
if (compactResult.reason !== "aborted") {
|
|
206
|
-
ctx
|
|
236
|
+
notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, formatCompactFailureMessage(compactResult));
|
|
207
237
|
}
|
|
208
|
-
return { cancel: true };
|
|
238
|
+
return compactResult.reason === "aborted" ? { cancel: true } : undefined;
|
|
209
239
|
}
|
|
210
240
|
const compactedWindow = sanitizeCompactedWindow(compactResult.compactedWindow);
|
|
211
241
|
if (compactedWindow.length === 0) {
|
|
212
|
-
ctx
|
|
213
|
-
return
|
|
242
|
+
notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction returned no installable compacted context. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
|
|
243
|
+
return undefined;
|
|
214
244
|
}
|
|
215
245
|
if (!hasCompactionOutputItem(compactedWindow)) {
|
|
216
|
-
ctx
|
|
217
|
-
return
|
|
246
|
+
notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction did not return a compaction item. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
|
|
247
|
+
return undefined;
|
|
218
248
|
}
|
|
219
249
|
const encryptedSummary = extractCompactionSummaryText(compactedWindow);
|
|
220
250
|
if (!encryptedSummary) {
|
|
221
|
-
ctx
|
|
222
|
-
return
|
|
251
|
+
notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction returned compacted context without a displayable summary. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
|
|
252
|
+
return undefined;
|
|
223
253
|
}
|
|
224
254
|
try {
|
|
225
255
|
const details = createNativeCompactionDetails({
|
|
@@ -234,8 +264,8 @@ async function handleCodexSessionBeforeCompactInner(event: SessionBeforeCompactE
|
|
|
234
264
|
});
|
|
235
265
|
return { compaction: createNativeCompactionShimResult({ summary: NATIVE_COMPACTION_SHIM_SUMMARY, firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details }) };
|
|
236
266
|
} catch {
|
|
237
|
-
ctx
|
|
238
|
-
return
|
|
267
|
+
notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, "OpenAI native compaction produced details Pi could not store");
|
|
268
|
+
return undefined;
|
|
239
269
|
}
|
|
240
270
|
}
|
|
241
271
|
|
|
@@ -245,17 +275,49 @@ export async function rewriteCodexCompactedProviderRequest(payload: unknown, ctx
|
|
|
245
275
|
if (!resolution.ok) return undefined;
|
|
246
276
|
const runtime = resolution.runtime;
|
|
247
277
|
const branchEntries = ctx.sessionManager.getBranch();
|
|
248
|
-
const
|
|
278
|
+
const latestNativeCompactionIndex = findLatestNativeCompactionEntryIndex(branchEntries, {
|
|
249
279
|
provider: runtime.provider,
|
|
250
280
|
api: runtime.api,
|
|
251
281
|
baseUrl: runtime.baseUrl,
|
|
252
282
|
});
|
|
253
|
-
if (
|
|
283
|
+
if (latestNativeCompactionIndex === undefined) return undefined;
|
|
254
284
|
if (!runtime.payload) return undefined;
|
|
255
|
-
const rewrite = rewriteResponsesPayloadWithNativeReplay({ model: runtime.currentModel, payload: runtime.payload, branchEntries, compactionEntry:
|
|
285
|
+
const rewrite = rewriteResponsesPayloadWithNativeReplay({ model: runtime.currentModel, payload: runtime.payload, branchEntries, compactionEntry: branchEntries[latestNativeCompactionIndex] as NativeCompactionEntry });
|
|
256
286
|
if (rewrite.ok) return rewrite.rewrittenPayload;
|
|
257
287
|
const detail = rewrite.parity?.mismatches.slice(0, 3).join("; ");
|
|
258
288
|
const message = `OpenAI native compaction replay failed (${rewrite.reason})${detail ? `: ${detail}` : ""}; request was not sent with placeholder compaction context.`;
|
|
259
289
|
ctx.ui.notify(message, "error");
|
|
260
290
|
throw new Error(message);
|
|
261
291
|
}
|
|
292
|
+
|
|
293
|
+
export async function injectPendingNativeWindowIntoPiCompactionRequest(payload: unknown, ctx: ExtensionContext, state: AdapterState): Promise<unknown | undefined> {
|
|
294
|
+
const pending = state.pendingPiCompactionNativeWindow;
|
|
295
|
+
if (!pending || pending.window.length === 0) return undefined;
|
|
296
|
+
if (!isResponsesCompatiblePayload(payload)) return undefined;
|
|
297
|
+
if (pending.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
298
|
+
state.pendingPiCompactionNativeWindow = undefined;
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const resolution = await resolveNativeCompactionEnvironment(ctx, { enabled: true }, payload);
|
|
303
|
+
if (!resolution.ok) return undefined;
|
|
304
|
+
const runtime = resolution.runtime;
|
|
305
|
+
if (pending.provider !== runtime.provider || pending.api !== runtime.api || pending.baseUrl !== runtime.baseUrl) return undefined;
|
|
306
|
+
|
|
307
|
+
const input = [...payload.input];
|
|
308
|
+
let insertAt = 0;
|
|
309
|
+
while (insertAt < input.length) {
|
|
310
|
+
const item = input[insertAt];
|
|
311
|
+
if (!isRecord(item) || (item.role !== "system" && item.role !== "developer")) break;
|
|
312
|
+
insertAt++;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
...payload,
|
|
317
|
+
input: [
|
|
318
|
+
...input.slice(0, insertAt),
|
|
319
|
+
...pending.window.map((item) => structuredClone(item)),
|
|
320
|
+
...input.slice(insertAt),
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { AdapterState } from "./state.ts";
|
|
|
5
5
|
import { rewriteNativeImageGenerationTool } from "../tools/image-generation-tool.ts";
|
|
6
6
|
import { rewriteNativeWebSearchTool } from "../tools/web-search-tool.ts";
|
|
7
7
|
import { shouldUseCodexAdapter } from "./activation.ts";
|
|
8
|
-
import { rewriteCodexCompactedProviderRequest } from "./compaction.ts";
|
|
8
|
+
import { injectPendingNativeWindowIntoPiCompactionRequest, rewriteCodexCompactedProviderRequest } from "./compaction.ts";
|
|
9
9
|
|
|
10
10
|
export async function rewriteCodexProviderRequest(payload: unknown, ctx: ExtensionContext, state: AdapterState): Promise<unknown | undefined> {
|
|
11
11
|
if (!shouldUseCodexAdapter(ctx, state.config) || (!isOpenAICodexContext(ctx) && !isResponsesContext(ctx))) {
|
|
@@ -21,5 +21,7 @@ export async function rewriteCodexProviderRequest(payload: unknown, ctx: Extensi
|
|
|
21
21
|
serviceTier: isOpenAICodex,
|
|
22
22
|
verbosity: true,
|
|
23
23
|
});
|
|
24
|
+
const piCompactionPayload = await injectPendingNativeWindowIntoPiCompactionRequest(configuredPayload, ctx, state);
|
|
25
|
+
if (piCompactionPayload !== undefined) return piCompactionPayload;
|
|
24
26
|
return (await rewriteCodexCompactedProviderRequest(configuredPayload, ctx, state)) ?? configuredPayload;
|
|
25
27
|
}
|
package/src/adapter/state.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { PromptSkill } from "../prompt/build-system-prompt.ts";
|
|
2
2
|
import type { CodexConversionConfig } from "./config.ts";
|
|
3
|
+
import type { ResponsesInputItem } from "./serializer.ts";
|
|
4
|
+
|
|
5
|
+
export interface PendingPiCompactionNativeWindow {
|
|
6
|
+
window: ResponsesInputItem[];
|
|
7
|
+
provider: string;
|
|
8
|
+
api: string;
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
sourceCompactionEntryId?: string;
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export interface AdapterState {
|
|
5
15
|
enabled: boolean;
|
|
@@ -8,4 +18,5 @@ export interface AdapterState {
|
|
|
8
18
|
previousToolNames?: string[];
|
|
9
19
|
promptSkills: PromptSkill[];
|
|
10
20
|
config: CodexConversionConfig;
|
|
21
|
+
pendingPiCompactionNativeWindow?: PendingPiCompactionNativeWindow;
|
|
11
22
|
}
|
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
import { syncAdapter } from "../adapter/activation.ts";
|
|
9
9
|
import type { AdapterState } from "../adapter/state.ts";
|
|
10
10
|
import { openCodexSettingsScreen } from "./ui.ts";
|
|
11
|
+
import { fetchCodexUsage, formatCodexUsage } from "./usage.ts";
|
|
11
12
|
|
|
12
|
-
const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "low", "medium", "high"] as const;
|
|
13
|
-
const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex low|medium|high";
|
|
13
|
+
const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "usage", "low", "medium", "high"] as const;
|
|
14
|
+
const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex usage, /codex low|medium|high";
|
|
14
15
|
|
|
15
16
|
export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onConfigApplied?: (config: CodexConversionConfig) => void): void {
|
|
16
17
|
function saveAndApply(ctx: ExtensionContext, nextConfig: CodexConversionConfig): boolean {
|
|
@@ -32,6 +33,36 @@ export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onCo
|
|
|
32
33
|
handler: async (args, ctx) => {
|
|
33
34
|
state.config = readCodexConversionConfig();
|
|
34
35
|
const arg = args.trim().toLowerCase();
|
|
36
|
+
if (arg === "usage") {
|
|
37
|
+
let usage;
|
|
38
|
+
try {
|
|
39
|
+
usage = await fetchCodexUsage(ctx);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
if (!ctx.hasUI) {
|
|
43
|
+
ctx.ui.notify(message, "error");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await openCodexSettingsScreen(ctx, {
|
|
47
|
+
initialConfig: state.config,
|
|
48
|
+
initialTab: "usage",
|
|
49
|
+
initialUsage: { error: message },
|
|
50
|
+
onChange: (config) => saveAndApply(ctx, config),
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!ctx.hasUI) {
|
|
55
|
+
ctx.ui.notify(formatCodexUsage(usage), "info");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await openCodexSettingsScreen(ctx, {
|
|
59
|
+
initialConfig: state.config,
|
|
60
|
+
initialTab: "usage",
|
|
61
|
+
initialUsage: usage,
|
|
62
|
+
onChange: (config) => saveAndApply(ctx, config),
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
35
66
|
if (arg === "compact") {
|
|
36
67
|
if (!ctx.hasUI) {
|
|
37
68
|
ctx.ui.notify(formatCodexSettings(state.config), "info");
|
package/src/codex-settings/ui.ts
CHANGED
|
@@ -10,25 +10,41 @@ import {
|
|
|
10
10
|
type CodexConversionConfig,
|
|
11
11
|
} from "../adapter/config.ts";
|
|
12
12
|
import { CHANGELOG_URL, DISCORD_URL, GITHUB_URL, ISSUE_URL, openExternalUrl } from "./links.ts";
|
|
13
|
+
import { fetchCodexUsage, formatCodexUsage, type CodexUsageSnapshot } from "./usage.ts";
|
|
13
14
|
|
|
14
15
|
export interface CodexSettingsScreenOptions {
|
|
15
16
|
initialConfig: CodexConversionConfig;
|
|
16
17
|
onChange: (nextConfig: CodexConversionConfig) => boolean;
|
|
17
18
|
initialTab?: SettingsTab;
|
|
19
|
+
initialUsage?: CodexUsageSnapshot | { error: string };
|
|
20
|
+
onRefreshUsage?: () => Promise<CodexUsageSnapshot>;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
type SettingsTab = "general" | "compaction" | "overrides";
|
|
23
|
+
type SettingsTab = "general" | "compaction" | "usage" | "overrides" | "about";
|
|
21
24
|
|
|
22
|
-
const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "overrides"];
|
|
25
|
+
const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "usage", "overrides", "about"];
|
|
23
26
|
|
|
24
27
|
export async function openCodexSettingsScreen(ctx: ExtensionContext, options: CodexSettingsScreenOptions): Promise<void> {
|
|
25
28
|
let draft = { ...options.initialConfig };
|
|
26
29
|
let activeTab: SettingsTab = options.initialTab ?? "general";
|
|
30
|
+
let usageState: CodexUsageSnapshot | { error: string } | undefined = options.initialUsage;
|
|
31
|
+
let usageLoading = false;
|
|
32
|
+
|
|
33
|
+
const loadUsage = (requestRender: () => void) => {
|
|
34
|
+
if (usageLoading) return;
|
|
35
|
+
usageLoading = true;
|
|
36
|
+
requestRender();
|
|
37
|
+
(options.onRefreshUsage ?? (() => fetchCodexUsage(ctx)))()
|
|
38
|
+
.then((usage) => { usageState = usage; })
|
|
39
|
+
.catch((error) => { usageState = { error: error instanceof Error ? error.message : String(error) }; })
|
|
40
|
+
.finally(() => { usageLoading = false; requestRender(); });
|
|
41
|
+
};
|
|
27
42
|
|
|
28
43
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
29
44
|
let settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
|
|
30
45
|
draft = nextDraft;
|
|
31
46
|
}, done, () => tui.requestRender());
|
|
47
|
+
if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
|
|
32
48
|
|
|
33
49
|
const switchTab = () => {
|
|
34
50
|
const currentIndex = TAB_ORDER.indexOf(activeTab);
|
|
@@ -36,6 +52,7 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
|
|
|
36
52
|
settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
|
|
37
53
|
draft = nextDraft;
|
|
38
54
|
}, done, () => tui.requestRender());
|
|
55
|
+
if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
|
|
39
56
|
tui.requestRender();
|
|
40
57
|
};
|
|
41
58
|
|
|
@@ -47,12 +64,12 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
|
|
|
47
64
|
rule(width, theme, "borderMuted"),
|
|
48
65
|
...(activeTab === "compaction" ? formatCompactionNotes(theme) : []),
|
|
49
66
|
...(activeTab === "overrides" ? formatOverridesNotes(theme) : []),
|
|
67
|
+
...(activeTab === "usage" ? formatUsageLines(theme, usageState, usageLoading) : []),
|
|
68
|
+
...(activeTab === "about" ? formatLinks(theme) : []),
|
|
50
69
|
"",
|
|
51
|
-
...settingsList.render(width),
|
|
52
|
-
rule(width, theme, "borderMuted"),
|
|
53
|
-
...formatLinks(theme),
|
|
70
|
+
...(activeTab === "usage" || activeTab === "about" ? [] : settingsList.render(width)),
|
|
54
71
|
rule(width, theme, "accent"),
|
|
55
|
-
theme.fg("dim",
|
|
72
|
+
theme.fg("dim", formatFooter(activeTab)),
|
|
56
73
|
].map((line) => truncateToWidth(line, width, "")),
|
|
57
74
|
invalidate: () => settingsList.invalidate(),
|
|
58
75
|
handleInput: (data: string) => {
|
|
@@ -60,7 +77,11 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
|
|
|
60
77
|
switchTab();
|
|
61
78
|
return;
|
|
62
79
|
}
|
|
63
|
-
if (handleLinkKey(data, ctx)) return;
|
|
80
|
+
if (activeTab === "about" && handleLinkKey(data, ctx)) return;
|
|
81
|
+
if (activeTab === "usage" && data.toLowerCase() === "r") {
|
|
82
|
+
loadUsage(() => tui.requestRender());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
64
85
|
settingsList.handleInput?.(data);
|
|
65
86
|
tui.requestRender();
|
|
66
87
|
},
|
|
@@ -109,6 +130,8 @@ function createSettingsList(
|
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
function buildItems(tab: SettingsTab, draft: CodexConversionConfig): SettingItem[] {
|
|
133
|
+
if (tab === "usage" || tab === "about") return [];
|
|
134
|
+
|
|
112
135
|
if (tab === "compaction") {
|
|
113
136
|
return [
|
|
114
137
|
{ id: "responsesCompaction", label: "Responses compaction", currentValue: (draft.responsesCompaction ?? false) ? "on" : "off", values: ["off", "on"] },
|
|
@@ -150,7 +173,75 @@ function applySettingChange(id: string, value: string, draft: CodexConversionCon
|
|
|
150
173
|
|
|
151
174
|
function formatTabs(activeTab: SettingsTab, theme: Theme): string {
|
|
152
175
|
const renderTab = (tab: SettingsTab, label: string) => activeTab === tab ? theme.bold(label) : theme.fg("dim", label);
|
|
153
|
-
return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")}`;
|
|
176
|
+
return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("usage", "Usage")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")} ${theme.fg("dim", "/")} ${renderTab("about", "About")}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatFooter(activeTab: SettingsTab): string {
|
|
180
|
+
if (activeTab === "usage") return " Tab to switch sections · r refresh";
|
|
181
|
+
if (activeTab === "about") return " Tab to switch sections · g/c/d/i open links";
|
|
182
|
+
return " Tab to switch sections";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatUsageLines(theme: Theme, usageState: CodexUsageSnapshot | { error: string } | undefined, loading: boolean): string[] {
|
|
186
|
+
if (loading && !usageState) return [theme.fg("dim", " Loading Codex usage…")];
|
|
187
|
+
if (!usageState) return [theme.fg("dim", " Loading Codex usage…")];
|
|
188
|
+
if ("error" in usageState) return [theme.fg("error", ` ${usageState.error}`), theme.fg("dim", " Press r to retry.")];
|
|
189
|
+
|
|
190
|
+
const rows = usageState.limits.map((limit) => {
|
|
191
|
+
const primary = usageColumns(limit.primary);
|
|
192
|
+
const secondary = usageColumns(limit.secondary);
|
|
193
|
+
return [limit.limitName ?? limit.limitId, primary.bar, primary.percent, primary.reset, secondary.bar, secondary.percent, secondary.reset];
|
|
194
|
+
});
|
|
195
|
+
const headers = ["Limit", "5h", "", "Reset", "Weekly", "", "Reset"];
|
|
196
|
+
const widths = columnWidths([headers, ...rows]);
|
|
197
|
+
return [
|
|
198
|
+
` ${theme.bold(`Codex usage${usageState.planType ? ` · ${usageState.planType}` : ""}`)}${loading ? theme.fg("dim", " refreshing…") : ""}`,
|
|
199
|
+
"",
|
|
200
|
+
formatUsageRow(headers.map((header) => theme.fg("dim", header)), widths),
|
|
201
|
+
theme.fg("borderMuted", ` ${"─".repeat(widths.reduce((sum, width) => sum + width, 0) + (2 * (widths.length - 1)))}`),
|
|
202
|
+
...rows.map((row) => formatUsageRow(row, widths)),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function columnWidths(rows: string[][]): number[] {
|
|
207
|
+
const columnCount = Math.max(...rows.map((row) => row.length));
|
|
208
|
+
return Array.from({ length: columnCount }, (_, index) => Math.max(...rows.map((row) => stripAnsi(row[index] ?? "").length)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stripAnsi(value: string): string {
|
|
212
|
+
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function padCell(value: string, width: number): string {
|
|
216
|
+
return value + " ".repeat(Math.max(0, width - stripAnsi(value).length));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatUsageRow(row: string[], widths: number[]): string {
|
|
220
|
+
return ` ${row.map((cell, index) => padCell(cell, widths[index] ?? 0)).join(" ")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function usageColumns(window: { usedPercent?: number; windowMinutes?: number; resetsAt?: number } | undefined): { bar: string; percent: string; reset: string } {
|
|
224
|
+
if (!window) return { bar: "—", percent: "", reset: "" };
|
|
225
|
+
const percent = window.usedPercent === undefined ? undefined : Math.max(0, Math.min(100, window.usedPercent));
|
|
226
|
+
return {
|
|
227
|
+
bar: bar(percent),
|
|
228
|
+
percent: percent === undefined ? "?%" : `${Math.round(percent)}%`,
|
|
229
|
+
reset: formatResetShort(window.resetsAt),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function bar(percent: number | undefined): string {
|
|
234
|
+
if (percent === undefined) return "░░░░░░░░░░";
|
|
235
|
+
const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
|
|
236
|
+
return "█".repeat(filled) + "░".repeat(10 - filled);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatResetShort(timestampSeconds: number | undefined): string {
|
|
240
|
+
if (!timestampSeconds) return "reset ?";
|
|
241
|
+
const minutes = Math.max(0, Math.round((timestampSeconds * 1000 - Date.now()) / 60000));
|
|
242
|
+
if (minutes < 90) return `~${minutes}m`;
|
|
243
|
+
if (minutes < 60 * 48) return `~${Math.round(minutes / 60)}h`;
|
|
244
|
+
return `~${Math.round(minutes / 1440)}d`;
|
|
154
245
|
}
|
|
155
246
|
|
|
156
247
|
function formatLinks(theme: Theme): string[] {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
5
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
6
|
+
|
|
7
|
+
type RuntimeModel = Model<Api>;
|
|
8
|
+
|
|
9
|
+
export interface CodexUsageWindow {
|
|
10
|
+
usedPercent?: number;
|
|
11
|
+
windowMinutes?: number;
|
|
12
|
+
resetsAt?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CodexUsageLimit {
|
|
16
|
+
limitId: string;
|
|
17
|
+
limitName?: string;
|
|
18
|
+
primary?: CodexUsageWindow;
|
|
19
|
+
secondary?: CodexUsageWindow;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CodexUsageSnapshot {
|
|
23
|
+
planType?: string;
|
|
24
|
+
limits: CodexUsageLimit[];
|
|
25
|
+
raw: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function numberValue(value: unknown): number | undefined {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringValue(value: unknown): string | undefined {
|
|
37
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCodexUsageUrl(): string {
|
|
41
|
+
return `${DEFAULT_CODEX_BASE_URL}/wham/usage`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractBearerToken(headers: Headers): string | undefined {
|
|
45
|
+
const authorization = headers.get("authorization")?.trim();
|
|
46
|
+
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
|
47
|
+
return match?.[1]?.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractAccountId(token: string): string | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const parts = token.split(".");
|
|
53
|
+
if (parts.length !== 3) return undefined;
|
|
54
|
+
const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64").toString("utf8")) as unknown;
|
|
55
|
+
const authClaims = isRecord(payload) ? payload[JWT_CLAIM_PATH] : undefined;
|
|
56
|
+
const accountId = isRecord(authClaims) ? authClaims.chatgpt_account_id : undefined;
|
|
57
|
+
return stringValue(accountId);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildCodexUsageHeaders(ctx: ExtensionContext, model: RuntimeModel): Promise<Headers> {
|
|
64
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
65
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
66
|
+
const headers = new Headers(model.headers);
|
|
67
|
+
for (const [key, value] of Object.entries(auth.headers ?? {})) headers.set(key, value);
|
|
68
|
+
if (auth.apiKey) headers.set("authorization", `Bearer ${auth.apiKey}`);
|
|
69
|
+
const token = auth.apiKey ?? extractBearerToken(headers);
|
|
70
|
+
const accountId = token ? extractAccountId(token) : undefined;
|
|
71
|
+
if (accountId) headers.set("chatgpt-account-id", accountId);
|
|
72
|
+
headers.set("accept", "application/json");
|
|
73
|
+
headers.set("originator", "pi");
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseWindow(value: unknown): CodexUsageWindow | undefined {
|
|
78
|
+
if (!isRecord(value)) return undefined;
|
|
79
|
+
const usedPercent = numberValue(value.used_percent);
|
|
80
|
+
const limitWindowSeconds = numberValue(value.limit_window_seconds);
|
|
81
|
+
const windowMinutes = numberValue(value.window_minutes) ?? (limitWindowSeconds === undefined ? undefined : Math.ceil(limitWindowSeconds / 60));
|
|
82
|
+
const resetsAt = numberValue(value.resets_at) ?? numberValue(value.reset_at);
|
|
83
|
+
return usedPercent === undefined && windowMinutes === undefined && resetsAt === undefined ? undefined : { usedPercent, windowMinutes, resetsAt };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseRateLimit(value: unknown): { primary?: CodexUsageWindow; secondary?: CodexUsageWindow } {
|
|
87
|
+
if (!isRecord(value)) return {};
|
|
88
|
+
return {
|
|
89
|
+
primary: parseWindow(value.primary_window) ?? parseWindow(value.primary),
|
|
90
|
+
secondary: parseWindow(value.secondary_window) ?? parseWindow(value.secondary),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCodexUsagePayload(payload: unknown): CodexUsageSnapshot {
|
|
95
|
+
const root = isRecord(payload) ? payload : {};
|
|
96
|
+
const limits: CodexUsageLimit[] = [];
|
|
97
|
+
const addLimit = (limitId: string, limitName: string | undefined, source: unknown) => {
|
|
98
|
+
const rateLimit = isRecord(source) && "rate_limit" in source ? source.rate_limit : source;
|
|
99
|
+
const parsed = parseRateLimit(rateLimit);
|
|
100
|
+
limits.push({
|
|
101
|
+
limitId,
|
|
102
|
+
...(limitName ? { limitName } : {}),
|
|
103
|
+
...(parsed.primary ? { primary: parsed.primary } : {}),
|
|
104
|
+
...(parsed.secondary ? { secondary: parsed.secondary } : {}),
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
addLimit("codex", undefined, root.rate_limit);
|
|
108
|
+
if (Array.isArray(root.additional_rate_limits)) {
|
|
109
|
+
for (const item of root.additional_rate_limits) {
|
|
110
|
+
if (!isRecord(item)) continue;
|
|
111
|
+
addLimit(stringValue(item.metered_feature) ?? "additional", stringValue(item.limit_name), item);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { planType: stringValue(root.plan_type), limits, raw: payload };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function fetchCodexUsage(ctx: ExtensionContext): Promise<CodexUsageSnapshot> {
|
|
118
|
+
const model = ctx.model;
|
|
119
|
+
if (!model) throw new Error("No active model selected.");
|
|
120
|
+
if (model.provider !== "openai-codex") {
|
|
121
|
+
throw new Error("Codex usage is only available for OpenAI Codex subscription models.");
|
|
122
|
+
}
|
|
123
|
+
const response = await fetch(buildCodexUsageUrl(), { method: "GET", headers: await buildCodexUsageHeaders(ctx, model), signal: ctx.signal });
|
|
124
|
+
const text = await response.text();
|
|
125
|
+
if (!response.ok) throw new Error(`Usage request failed (${response.status}): ${text || response.statusText}`);
|
|
126
|
+
return parseCodexUsagePayload(JSON.parse(text));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatReset(timestampSeconds: number | undefined): string {
|
|
130
|
+
if (!timestampSeconds) return "reset unknown";
|
|
131
|
+
const ms = timestampSeconds * 1000;
|
|
132
|
+
const minutes = Math.max(0, Math.round((ms - Date.now()) / 60000));
|
|
133
|
+
return minutes < 90 ? `resets in ~${minutes}m` : `resets ${new Date(ms).toLocaleString()}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatWindow(label: string, window: CodexUsageWindow | undefined): string | undefined {
|
|
137
|
+
if (!window) return undefined;
|
|
138
|
+
const percent = window.usedPercent === undefined ? "?" : `${Math.round(window.usedPercent)}%`;
|
|
139
|
+
const span = window.windowMinutes ? `${Math.round(window.windowMinutes)}m` : "window";
|
|
140
|
+
return `${label}: ${percent} used (${span}, ${formatReset(window.resetsAt)})`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatCodexUsage(snapshot: CodexUsageSnapshot): string {
|
|
144
|
+
const lines = [`Codex usage${snapshot.planType ? ` (${snapshot.planType})` : ""}:`];
|
|
145
|
+
for (const limit of snapshot.limits) {
|
|
146
|
+
const title = limit.limitName ?? limit.limitId;
|
|
147
|
+
const parts = [formatWindow("5h", limit.primary), formatWindow("weekly", limit.secondary)].filter(Boolean);
|
|
148
|
+
lines.push(`- ${title}: ${parts.length ? parts.join("; ") : "no usage data"}`);
|
|
149
|
+
}
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -163,6 +163,7 @@ export default function codexConversion(pi: ExtensionAPI) {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
pi.on("session_compact", async (event) => {
|
|
166
|
+
state.pendingPiCompactionNativeWindow = undefined;
|
|
166
167
|
if (!event.fromExtension || !isNativeCompactionDetails(event.compactionEntry.details)) return;
|
|
167
168
|
pi.sendMessage(
|
|
168
169
|
{
|
|
Binary file
|
|
Binary file
|