pi-codex-compaction 0.1.0 → 0.1.2
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 +26 -0
- package/README.md +47 -5
- package/SECURITY.md +12 -2
- package/extensions/index.ts +35 -5
- package/package.json +5 -5
- package/src/codex-wire.ts +387 -85
- package/src/install-telemetry.ts +8 -7
- package/src/remote-compaction.ts +224 -40
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.2] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Preserve pi-codex-tools grammar metadata and custom-tool history during remote compaction.
|
|
14
|
+
- Honor pi-fast on direct compaction requests through a public event-bus contract.
|
|
15
|
+
- Include cache writes and standard Pi compaction usage in session totals.
|
|
16
|
+
- Restrict endpoint paths and ports; merge beta features and honor null auth headers.
|
|
17
|
+
- Avoid I/O after pre-cancellation, bound total request time, and close completed SSE streams promptly.
|
|
18
|
+
|
|
19
|
+
## [0.1.1] - 2026-08-06
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
|
|
24
|
+
- Keep the normal Codex Responses request envelope while excluding Pi's retained user input from compaction history.
|
|
25
|
+
- Avoid reusing opaque checkpoints across model, endpoint, account, or authentication-mode changes.
|
|
26
|
+
- Avoid reusing opaque checkpoints when the authentication mode cannot be verified.
|
|
27
|
+
- Reject version 1 opaque checkpoints from existing sessions and use readable fallback context until a new checkpoint is created.
|
|
28
|
+
- Fall back to Pi's standard compactor when custom compaction instructions are supplied.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Incremental bounded SSE parsing, transient request retries, response idle timeouts, provider usage capture, and file-operation fallback metadata.
|
|
33
|
+
- Trusted-origin and redirect protections for direct Codex compaction requests.
|
|
34
|
+
|
|
9
35
|
## [0.1.0] - 2026-08-02
|
|
10
36
|
|
|
11
37
|
### Added
|
package/README.md
CHANGED
|
@@ -6,11 +6,14 @@ Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summ
|
|
|
6
6
|
|
|
7
7
|
- Uses the current `openai-codex` model for each compaction; it never silently changes the session model.
|
|
8
8
|
- Sends only the history Pi is discarding, plus the previous Codex checkpoint, so the incoming/kept user message is not duplicated.
|
|
9
|
+
- Retains the normal Codex Responses request envelope, including system instructions, active tool schemas, reasoning settings, prompt-cache fields, and routing fields.
|
|
9
10
|
- Persists Codex's opaque encrypted checkpoint and rehydrates it only for supported Codex requests.
|
|
10
|
-
-
|
|
11
|
+
- Reuses checkpoints only for the same model, trusted endpoint, Codex account, and authentication mode.
|
|
12
|
+
- Bounds input with a UTF-8-aware token estimate, trims tool output when necessary, retries transient failures, and honors cancellation.
|
|
13
|
+
- Falls back to standard Pi compaction on failure or when custom compaction instructions are requested.
|
|
11
14
|
- Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
|
|
12
15
|
|
|
13
|
-
The current Codex catalog includes
|
|
16
|
+
The current Codex catalog includes `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`. Capability detection follows the provider/API contract (`openai-codex` + `openai-codex-responses`) rather than a brittle model-name list. Use Pi 0.85.1 or later.
|
|
14
17
|
|
|
15
18
|
## Installation
|
|
16
19
|
|
|
@@ -32,14 +35,53 @@ pi -e /path/to/pi-mono/packages/pi-codex-compaction
|
|
|
32
35
|
|
|
33
36
|
## Behavior
|
|
34
37
|
|
|
35
|
-
When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request
|
|
38
|
+
When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request whose `input` contains only discardable history, any compatible prior checkpoint, and a `compaction_trigger` item. The normal request envelope is retained because Codex's compaction path is parity-tested against ordinary Responses requests; this includes the effective system prompt, active tool definitions, reasoning level, prompt-cache fields, and routing fields. The request uses the `remote_compaction_v2` beta feature, and the returned opaque checkpoint and bounded provider usage are stored in the Pi compaction entry. Later requests rehydrate the raw checkpoint only when the model, endpoint, account, and authentication mode match; other providers/models receive the bounded textual fallback instead.
|
|
36
39
|
|
|
37
|
-
Compaction uses the model active when Pi triggers it. If a session switches from a larger to a smaller model, the remote request is bounded against the new model's context window and tool outputs are reduced before sending. If
|
|
40
|
+
Compaction uses the model active when Pi triggers it. If a session switches from a larger to a smaller model, the remote request is bounded against the new model's context window and tool outputs are reduced before sending. A previous opaque checkpoint is treated as incompatible after a model, endpoint, account, or authentication-mode switch; Pi's readable previous summary is sent instead. If the full request still cannot fit, the extension leaves compaction to Pi's normal implementation.
|
|
38
41
|
|
|
39
|
-
If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. No configuration is required.
|
|
42
|
+
If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. Custom compaction instructions also use Pi's standard path because RemoteCompactionV2 has no documented custom-instructions field. The direct checkpoint request is restricted to `https://chatgpt.com`, rejects redirects, limits request/response size, and never decodes or logs `encrypted_content`. No configuration is required.
|
|
40
43
|
|
|
41
44
|
## Development
|
|
42
45
|
|
|
46
|
+
### Other Codex extensions
|
|
47
|
+
|
|
48
|
+
With `pi-fast` installed, direct compaction requests use the current Fast toggle.
|
|
49
|
+
With `pi-codex-tools` installed, `apply_patch` keeps its raw grammar definition,
|
|
50
|
+
custom-tool calls, and custom-tool results during compaction. Neither package is
|
|
51
|
+
required. No package reads a private Pi tool registry.
|
|
52
|
+
With `pi-openai-reasoning` installed, verified Astra requests keep the original
|
|
53
|
+
request effort and receive the current effort as a configuration update.
|
|
54
|
+
Failed compaction does not change saved reasoning state.
|
|
55
|
+
|
|
56
|
+
Pi 0.85.1 does not expose grammar metadata in `getAllTools()`. Two synchronous,
|
|
57
|
+
versioned `pi.events` contracts let cooperating extensions supply it:
|
|
58
|
+
|
|
59
|
+
- `pi-codex-compaction:tools:v1`: `{ model, tools }`, before provider serialization.
|
|
60
|
+
A tool owner can attach its own `constrainedSampling` metadata.
|
|
61
|
+
- `pi-codex-compaction:request:v1`: `{ ctx, messages, payload }`, after input
|
|
62
|
+
assembly and before size checks. A listener can replace `payload`. This event
|
|
63
|
+
is not the general `before_provider_request` chain and does not carry auth.
|
|
64
|
+
|
|
65
|
+
Other extensions' private request changes are not applied automatically.
|
|
66
|
+
Unknown third-party grammar metadata needs cooperation through the tools event.
|
|
67
|
+
The checkpoint entry includes standard Pi `usage`, including cache reads and
|
|
68
|
+
cache writes, as well as the original bounded token counters in `details`.
|
|
69
|
+
Costs follow Pi's catalog estimates. They are not a ChatGPT subscription bill.
|
|
70
|
+
|
|
71
|
+
### Reference and smoke test
|
|
72
|
+
|
|
73
|
+
Behavior was checked against `openai/codex` commit
|
|
74
|
+
`654b0a77d0d2f81aa21f61caf7af4be88fe550bb` (2026-09-11), notably
|
|
75
|
+
`core/src/compact_remote_v2{,_attempt}.rs`. No Codex code was copied.
|
|
76
|
+
RemoteCompactionV2 is a changing Codex protocol, not the public `/responses/compact`
|
|
77
|
+
API. Async tools and mid-turn steering require upstream Pi support.
|
|
78
|
+
|
|
79
|
+
For a small live test, load this package and select `openai-codex/gpt-6-astra`.
|
|
80
|
+
Send two short messages, run `/compact`, then ask about the first message.
|
|
81
|
+
Repeat with `/fast on` and `pi-codex-tools` loaded. Check that compaction succeeds,
|
|
82
|
+
the continuation retains context, and session usage includes compaction tokens.
|
|
83
|
+
Use only a temporary file if you test `apply_patch`. Do not generate images.
|
|
84
|
+
|
|
43
85
|
```bash
|
|
44
86
|
npm install
|
|
45
87
|
npm run -w packages/pi-codex-compaction check
|
package/SECURITY.md
CHANGED
|
@@ -19,10 +19,20 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
`pi-codex-compaction` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
|
|
21
21
|
|
|
22
|
-
For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so
|
|
22
|
+
For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. The request also includes the normal Codex Responses envelope: the effective system prompt, active tool schemas, reasoning settings, and provider-generated cache/routing fields. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so compatible Codex requests can reuse it. The checkpoint is not decoded, printed, or logged.
|
|
23
23
|
|
|
24
|
-
The extension bounds compaction input and response size, validates the
|
|
24
|
+
The extension bounds request, compaction input, and response size, validates the HTTPS endpoint against the official `chatgpt.com` origin, rejects redirects, validates the account claim shape, binds checkpoints to a hashed account identity/model/endpoint/authentication mode, honors Pi cancellation, and retries only transient transport failures. It falls back to standard Pi compaction on authentication, transport, response, or context-limit failures. A bounded textual transcript excerpt remains in the compaction summary so switching to another model or provider does not leave the session with only an unusable Codex checkpoint. The fallback may contain conversation content already present in the local session and is still subject to the user's normal Pi session-file permissions.
|
|
25
25
|
|
|
26
26
|
The extension never logs prompts, conversation contents, credentials, authorization headers, or raw provider responses. Install/update telemetry is best-effort and sends only package/version/runtime metadata; it can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, or Pi's `enableInstallTelemetry: false` setting.
|
|
27
27
|
|
|
28
|
+
Direct requests allow only the official `/backend-api/codex/responses` endpoint
|
|
29
|
+
on the default HTTPS port, without query strings or fragments. Total request
|
|
30
|
+
time is limited to five minutes, including retries. Completed streams are closed
|
|
31
|
+
without waiting for a server disconnect. A pre-aborted request does no network I/O.
|
|
32
|
+
Null auth headers remove matching model headers; beta features are merged.
|
|
33
|
+
|
|
34
|
+
Cooperating local extensions can inspect and transform compaction inputs through
|
|
35
|
+
the documented event bus before size checks. These events contain no credentials.
|
|
36
|
+
They have the same trust level as other installed Pi extensions.
|
|
37
|
+
|
|
28
38
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and validation instructions.
|
package/extensions/index.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type {
|
|
|
3
3
|
ExtensionHandler,
|
|
4
4
|
SessionBeforeCompactEvent,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { BETA_FEATURE } from "../src/codex-wire.js";
|
|
6
|
+
import { BETA_FEATURE, getCodexAccountFingerprint } from "../src/codex-wire.js";
|
|
7
7
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
8
8
|
import {
|
|
9
9
|
applyRemoteCompactionMarker,
|
|
10
10
|
createRemoteCompaction,
|
|
11
11
|
findActiveRemoteCompaction,
|
|
12
|
+
getCodexAuthKind,
|
|
13
|
+
isRemoteCompactionCompatible,
|
|
12
14
|
supportsRemoteCompaction,
|
|
13
15
|
} from "../src/remote-compaction.js";
|
|
14
16
|
|
|
@@ -21,9 +23,20 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
|
21
23
|
try {
|
|
22
24
|
const compaction = await createRemoteCompaction(event, ctx, () => {
|
|
23
25
|
const active = new Set(pi.getActiveTools());
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
26
|
+
const data = {
|
|
27
|
+
model: ctx.model,
|
|
28
|
+
tools: pi.getAllTools().filter((tool) => active.has(tool.name)),
|
|
29
|
+
};
|
|
30
|
+
// Pi's public ToolInfo omits constrainedSampling. Tool owners can supply
|
|
31
|
+
// it here without exposing executors or inspecting private registries.
|
|
32
|
+
pi.events?.emit("pi-codex-compaction:tools:v1", data);
|
|
33
|
+
return data.tools;
|
|
34
|
+
}, pi.getThinkingLevel?.(), (payload, messages) => {
|
|
35
|
+
const data = { payload, messages, ctx };
|
|
36
|
+
// Synchronous bus contract: apply pure request transforms before bounds
|
|
37
|
+
// and before network I/O. Never include auth in this event.
|
|
38
|
+
pi.events?.emit("pi-codex-compaction:request:v1", data);
|
|
39
|
+
return data.payload;
|
|
27
40
|
});
|
|
28
41
|
return compaction ? { compaction } : undefined;
|
|
29
42
|
} catch {
|
|
@@ -47,6 +60,23 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
|
47
60
|
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
48
61
|
const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
49
62
|
if (!details) return;
|
|
50
|
-
|
|
63
|
+
|
|
64
|
+
return (async () => {
|
|
65
|
+
const model = ctx.model;
|
|
66
|
+
if (!model) return undefined;
|
|
67
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
68
|
+
if (!auth.ok || !auth.apiKey) return undefined;
|
|
69
|
+
|
|
70
|
+
let accountFingerprint: string;
|
|
71
|
+
try {
|
|
72
|
+
accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (!isRemoteCompactionCompatible(details, model, accountFingerprint, getCodexAuthKind(ctx.modelRegistry, model))) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return applyRemoteCompactionMarker(event.payload, details);
|
|
80
|
+
})();
|
|
51
81
|
});
|
|
52
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-compaction",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"@earendil-works/pi-coding-agent": "*"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
57
|
-
"@types/node": "^26.
|
|
58
|
-
"tsx": "^4.23.
|
|
55
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
57
|
+
"@types/node": "^26.2.0",
|
|
58
|
+
"tsx": "^4.23.5",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
package/src/codex-wire.ts
CHANGED
|
@@ -1,44 +1,113 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
1
4
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
2
5
|
const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
|
|
3
|
-
const
|
|
6
|
+
const REQUEST_HEADER_TIMEOUT_MS = 30_000;
|
|
7
|
+
const REQUEST_IDLE_TIMEOUT_MS = 120_000;
|
|
8
|
+
const REQUEST_TOTAL_TIMEOUT_MS = 300_000;
|
|
9
|
+
const MAX_RETRIES = 2;
|
|
10
|
+
const RETRY_BASE_DELAY_MS = 200;
|
|
11
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
4
12
|
const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
|
|
13
|
+
const TRUSTED_CODEX_HOST = "chatgpt.com";
|
|
5
14
|
export const BETA_FEATURE = "remote_compaction_v2";
|
|
6
15
|
|
|
7
16
|
export interface CodexCompactionRequest {
|
|
8
17
|
model: {
|
|
9
18
|
id: string;
|
|
10
19
|
baseUrl: string;
|
|
11
|
-
headers?: Record<string, string>;
|
|
20
|
+
headers?: Record<string, string | null>;
|
|
12
21
|
};
|
|
13
22
|
apiKey: string;
|
|
14
|
-
authHeaders?: Record<string, string>;
|
|
23
|
+
authHeaders?: Record<string, string | null>;
|
|
15
24
|
sessionId?: string;
|
|
16
25
|
body: Record<string, unknown>;
|
|
17
26
|
signal?: AbortSignal;
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
export interface CodexCompactionUsage {
|
|
30
|
+
inputTokens?: number;
|
|
31
|
+
outputTokens?: number;
|
|
32
|
+
totalTokens?: number;
|
|
33
|
+
cachedInputTokens?: number;
|
|
34
|
+
cacheWriteInputTokens?: number;
|
|
35
|
+
reasoningTokens?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodexCompactionResult {
|
|
39
|
+
encryptedContent: string;
|
|
40
|
+
usage?: CodexCompactionUsage;
|
|
41
|
+
serviceTier?: "default" | "priority" | "flex";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Keep the original string-returning API for callers that only need the
|
|
46
|
+
* checkpoint. The extension uses requestRemoteCompactionWithUsage below.
|
|
47
|
+
*/
|
|
20
48
|
export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
|
|
49
|
+
return (await requestRemoteCompactionWithUsage(request)).encryptedContent;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function requestRemoteCompactionWithUsage(
|
|
53
|
+
request: CodexCompactionRequest,
|
|
54
|
+
): Promise<CodexCompactionResult> {
|
|
55
|
+
request.signal?.throwIfAborted();
|
|
56
|
+
const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
|
|
21
57
|
const accountId = extractAccountId(request.apiKey);
|
|
22
58
|
const headers = buildHeaders(request, accountId);
|
|
23
|
-
const
|
|
59
|
+
const body = JSON.stringify(request.body);
|
|
60
|
+
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
|
|
61
|
+
throw new Error("Codex compaction request exceeded the size limit");
|
|
62
|
+
}
|
|
24
63
|
|
|
64
|
+
const requestState = requestSignal(request.signal);
|
|
25
65
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
67
|
+
requestState.signal.throwIfAborted();
|
|
68
|
+
requestState.startAttempt();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(endpoint, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers,
|
|
74
|
+
body,
|
|
75
|
+
redirect: "error",
|
|
76
|
+
signal: requestState.signal,
|
|
77
|
+
});
|
|
78
|
+
requestState.headersReceived();
|
|
37
79
|
|
|
38
|
-
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
await response.body?.cancel().catch(() => undefined);
|
|
82
|
+
const error = new CodexCompactionError(
|
|
83
|
+
`Codex compaction request returned HTTP ${response.status}`,
|
|
84
|
+
isRetryableStatus(response.status),
|
|
85
|
+
);
|
|
86
|
+
if (!error.retryable || attempt >= MAX_RETRIES) throw error;
|
|
87
|
+
await waitForRetry(response, attempt, requestState.signal);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return await readCompactionSse(response, requestState.signal, requestState.touch);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!isRetryableError(error) || attempt >= MAX_RETRIES || requestState.signal.aborted) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
await waitForRetry(undefined, attempt, requestState.signal);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (requestState.signal.aborted || !isRetryableError(error) || attempt >= MAX_RETRIES) {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
await waitForRetry(undefined, attempt, requestState.signal);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
39
106
|
} finally {
|
|
40
|
-
cleanup();
|
|
107
|
+
requestState.cleanup();
|
|
41
108
|
}
|
|
109
|
+
|
|
110
|
+
throw new Error("Codex compaction request failed");
|
|
42
111
|
}
|
|
43
112
|
|
|
44
113
|
export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
@@ -46,11 +115,20 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
|
46
115
|
if (url.protocol !== "https:") {
|
|
47
116
|
throw new Error("Codex compaction requires an HTTPS endpoint");
|
|
48
117
|
}
|
|
118
|
+
if (url.hostname.toLowerCase() !== TRUSTED_CODEX_HOST) {
|
|
119
|
+
throw new Error("Codex compaction endpoint is not a trusted Codex origin");
|
|
120
|
+
}
|
|
49
121
|
if (url.username || url.password) {
|
|
50
122
|
throw new Error("Codex compaction endpoint must not contain URL credentials");
|
|
51
123
|
}
|
|
124
|
+
if (url.port || url.search || url.hash) {
|
|
125
|
+
throw new Error("Codex compaction endpoint must use the default port without query or fragment");
|
|
126
|
+
}
|
|
52
127
|
|
|
53
128
|
const path = url.pathname.replace(/\/+$/, "");
|
|
129
|
+
if (!["/backend-api", "/backend-api/codex", "/backend-api/codex/responses"].includes(path)) {
|
|
130
|
+
throw new Error("Codex compaction endpoint must use the Codex Responses path");
|
|
131
|
+
}
|
|
54
132
|
if (path.endsWith("/codex/responses")) {
|
|
55
133
|
return url.toString();
|
|
56
134
|
}
|
|
@@ -59,60 +137,34 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
|
59
137
|
}
|
|
60
138
|
|
|
61
139
|
export function parseCompactionSse(sse: string): string {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
for (const frame of sse.split(/\r?\n\r?\n/)) {
|
|
66
|
-
const data = frame
|
|
67
|
-
.split(/\r?\n/)
|
|
68
|
-
.filter((line) => line.startsWith("data:"))
|
|
69
|
-
.map((line) => line.slice(5).trim())
|
|
70
|
-
.join("\n")
|
|
71
|
-
.trim();
|
|
72
|
-
if (!data || data === "[DONE]") continue;
|
|
73
|
-
|
|
74
|
-
let event: unknown;
|
|
75
|
-
try {
|
|
76
|
-
event = JSON.parse(data) as unknown;
|
|
77
|
-
} catch {
|
|
78
|
-
throw new Error("Codex compaction returned invalid SSE data");
|
|
79
|
-
}
|
|
80
|
-
if (!isRecord(event)) continue;
|
|
81
|
-
|
|
82
|
-
if (event.type === "error" || event.type === "response.failed") {
|
|
83
|
-
throw new Error("Codex compaction response failed");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (event.type === "response.output_item.done") {
|
|
87
|
-
collectCompactionItem(event.item, encryptedContents);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (event.type === "response.completed" || event.type === "response.done") {
|
|
91
|
-
const response = isRecord(event.response) ? event.response : undefined;
|
|
92
|
-
collectOutputItems(response?.output, encryptedContents);
|
|
93
|
-
completed = response?.status === undefined || response.status === "completed";
|
|
94
|
-
}
|
|
95
|
-
}
|
|
140
|
+
return parseCompactionSseResult(sse).encryptedContent;
|
|
141
|
+
}
|
|
96
142
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (encryptedContents.size !== 1) {
|
|
101
|
-
throw new Error(`Codex compaction returned ${encryptedContents.size} checkpoint items`);
|
|
143
|
+
export function parseCompactionSseResult(sse: string): CodexCompactionResult {
|
|
144
|
+
if (new TextEncoder().encode(sse).byteLength > MAX_RESPONSE_BYTES) {
|
|
145
|
+
throw new Error("Codex compaction response exceeded the size limit");
|
|
102
146
|
}
|
|
103
|
-
|
|
147
|
+
const parser = new CompactionSseParser();
|
|
148
|
+
parser.push(sse);
|
|
149
|
+
return parser.finish();
|
|
104
150
|
}
|
|
105
151
|
|
|
106
152
|
function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
|
|
107
153
|
const headers = new Headers();
|
|
108
|
-
for (const [name, value] of Object.entries(request.model.headers ?? {}))
|
|
109
|
-
|
|
154
|
+
for (const [name, value] of Object.entries(request.model.headers ?? {})) {
|
|
155
|
+
if (value !== null) headers.set(name, value);
|
|
156
|
+
}
|
|
157
|
+
for (const [name, value] of Object.entries(request.authHeaders ?? {})) {
|
|
158
|
+
if (value !== null) headers.set(name, value);
|
|
159
|
+
else headers.delete(name);
|
|
160
|
+
}
|
|
110
161
|
|
|
111
162
|
headers.set("Authorization", `Bearer ${request.apiKey}`);
|
|
112
163
|
headers.set("chatgpt-account-id", accountId);
|
|
113
164
|
headers.set("originator", "pi");
|
|
114
165
|
headers.set("OpenAI-Beta", "responses=experimental");
|
|
115
|
-
headers.
|
|
166
|
+
const features = headers.get("x-codex-beta-features")?.split(",").map((part) => part.trim()).filter(Boolean) ?? [];
|
|
167
|
+
headers.set("x-codex-beta-features", [...new Set([...features, BETA_FEATURE])].join(","));
|
|
116
168
|
headers.set("accept", "text/event-stream");
|
|
117
169
|
headers.set("content-type", "application/json");
|
|
118
170
|
|
|
@@ -124,6 +176,10 @@ function buildHeaders(request: CodexCompactionRequest, accountId: string): Heade
|
|
|
124
176
|
return headers;
|
|
125
177
|
}
|
|
126
178
|
|
|
179
|
+
export function getCodexAccountFingerprint(token: string): string {
|
|
180
|
+
return `sha256:${createHash("sha256").update(extractAccountId(token)).digest("hex")}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
127
183
|
function extractAccountId(token: string): string {
|
|
128
184
|
const segment = token.split(".")[1];
|
|
129
185
|
if (!segment) throw new Error("Codex authentication token has no account claim");
|
|
@@ -146,61 +202,307 @@ function safeSessionId(value: string | undefined): string | undefined {
|
|
|
146
202
|
return value;
|
|
147
203
|
}
|
|
148
204
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const encryptedContent = value.encrypted_content;
|
|
157
|
-
if (
|
|
158
|
-
typeof encryptedContent === "string" &&
|
|
159
|
-
encryptedContent.length > 0 &&
|
|
160
|
-
encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
161
|
-
) {
|
|
162
|
-
encryptedContents.add(encryptedContent);
|
|
205
|
+
async function readCompactionSse(
|
|
206
|
+
response: Response,
|
|
207
|
+
signal: AbortSignal,
|
|
208
|
+
touch: () => void,
|
|
209
|
+
): Promise<CodexCompactionResult> {
|
|
210
|
+
if (!response.body) {
|
|
211
|
+
throw new CodexCompactionError("Codex compaction response has no body", true);
|
|
163
212
|
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function readBoundedBody(response: Response): Promise<string> {
|
|
167
|
-
if (!response.body) throw new Error("Codex compaction response has no body");
|
|
168
213
|
|
|
169
214
|
const reader = response.body.getReader();
|
|
170
215
|
const decoder = new TextDecoder();
|
|
216
|
+
const parser = new CompactionSseParser();
|
|
171
217
|
let bytes = 0;
|
|
172
|
-
let text = "";
|
|
173
218
|
try {
|
|
174
219
|
while (true) {
|
|
175
220
|
const { done, value } = await reader.read();
|
|
176
221
|
if (done) break;
|
|
222
|
+
touch();
|
|
177
223
|
bytes += value.byteLength;
|
|
178
224
|
if (bytes > MAX_RESPONSE_BYTES) {
|
|
179
|
-
throw new
|
|
225
|
+
throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
|
|
180
226
|
}
|
|
181
|
-
|
|
227
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
228
|
+
if (parser.isComplete) return parser.finish();
|
|
182
229
|
}
|
|
183
|
-
|
|
230
|
+
parser.push(decoder.decode());
|
|
231
|
+
return parser.finish();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof CodexCompactionError) throw error;
|
|
234
|
+
if (signal.aborted) throw error;
|
|
235
|
+
throw new CodexCompactionError("Codex compaction response stream failed", true);
|
|
184
236
|
} finally {
|
|
185
237
|
await reader.cancel().catch(() => undefined);
|
|
186
238
|
reader.releaseLock();
|
|
187
239
|
}
|
|
188
240
|
}
|
|
189
241
|
|
|
190
|
-
|
|
242
|
+
class CompactionSseParser {
|
|
243
|
+
private buffer = "";
|
|
244
|
+
private completed = false;
|
|
245
|
+
private status: string | undefined;
|
|
246
|
+
private compactionItems: Array<string | undefined> = [];
|
|
247
|
+
private usage: CodexCompactionUsage | undefined;
|
|
248
|
+
private serviceTier: CodexCompactionResult["serviceTier"];
|
|
249
|
+
|
|
250
|
+
get isComplete(): boolean {
|
|
251
|
+
return this.completed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
push(chunk: string): void {
|
|
255
|
+
this.buffer += chunk;
|
|
256
|
+
this.buffer = this.buffer.replace(/\r\n/g, "\n");
|
|
257
|
+
while (true) {
|
|
258
|
+
const separator = this.buffer.indexOf("\n\n");
|
|
259
|
+
if (separator < 0) break;
|
|
260
|
+
const frame = this.buffer.slice(0, separator);
|
|
261
|
+
this.buffer = this.buffer.slice(separator + 2);
|
|
262
|
+
this.processFrame(frame);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
finish(): CodexCompactionResult {
|
|
267
|
+
if (this.buffer.trim()) this.processFrame(this.buffer);
|
|
268
|
+
if (!this.completed) {
|
|
269
|
+
throw new CodexCompactionError("Codex compaction stream ended before response completion", true);
|
|
270
|
+
}
|
|
271
|
+
if (this.status && this.status !== "completed") {
|
|
272
|
+
throw new CodexCompactionError("Codex compaction response failed", false);
|
|
273
|
+
}
|
|
274
|
+
if (this.compactionItems.length !== 1) {
|
|
275
|
+
throw new CodexCompactionError(
|
|
276
|
+
`Codex compaction returned ${this.compactionItems.length} checkpoint items`,
|
|
277
|
+
false,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const encryptedContent = this.compactionItems[0];
|
|
282
|
+
if (!encryptedContent) {
|
|
283
|
+
throw new CodexCompactionError("Codex compaction returned an invalid checkpoint", false);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
encryptedContent,
|
|
287
|
+
...(this.usage ? { usage: this.usage } : {}),
|
|
288
|
+
...(this.serviceTier ? { serviceTier: this.serviceTier } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private processFrame(frame: string): void {
|
|
293
|
+
const data = frame
|
|
294
|
+
.split("\n")
|
|
295
|
+
.filter((line) => line.startsWith("data:"))
|
|
296
|
+
.map((line) => line.slice(5).trim())
|
|
297
|
+
.join("\n")
|
|
298
|
+
.trim();
|
|
299
|
+
if (!data || data === "[DONE]") return;
|
|
300
|
+
|
|
301
|
+
let event: unknown;
|
|
302
|
+
try {
|
|
303
|
+
event = JSON.parse(data) as unknown;
|
|
304
|
+
} catch {
|
|
305
|
+
throw new CodexCompactionError("Codex compaction returned invalid SSE data", false);
|
|
306
|
+
}
|
|
307
|
+
if (!isRecord(event)) return;
|
|
308
|
+
|
|
309
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
310
|
+
throw new CodexCompactionError("Codex compaction response failed", false);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (event.type === "response.output_item.done") {
|
|
314
|
+
this.collectCompactionItem(event.item);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (event.type === "response.completed" || event.type === "response.done") {
|
|
318
|
+
const response = isRecord(event.response) ? event.response : undefined;
|
|
319
|
+
this.status = typeof response?.status === "string" ? response.status : undefined;
|
|
320
|
+
this.usage = parseUsage(response?.usage ?? event.usage);
|
|
321
|
+
const tier = response?.service_tier;
|
|
322
|
+
this.serviceTier = tier === "default" || tier === "priority" || tier === "flex" ? tier : undefined;
|
|
323
|
+
|
|
324
|
+
// Codex emits output_item.done before response.completed. Retain this
|
|
325
|
+
// fallback for compact Responses implementations that only return the
|
|
326
|
+
// completed response's output array.
|
|
327
|
+
if (this.compactionItems.length === 0) {
|
|
328
|
+
collectOutputItems(response?.output, (item) => this.collectCompactionItem(item));
|
|
329
|
+
}
|
|
330
|
+
this.completed = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private collectCompactionItem(value: unknown): void {
|
|
335
|
+
if (!isRecord(value) || value.type !== "compaction") return;
|
|
336
|
+
const encryptedContent = value.encrypted_content;
|
|
337
|
+
this.compactionItems.push(
|
|
338
|
+
typeof encryptedContent === "string" &&
|
|
339
|
+
encryptedContent.length > 0 &&
|
|
340
|
+
encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
341
|
+
? encryptedContent
|
|
342
|
+
: undefined,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function collectOutputItems(value: unknown, collect: (item: unknown) => void): void {
|
|
348
|
+
if (!Array.isArray(value)) return;
|
|
349
|
+
for (const item of value) collect(item);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseUsage(value: unknown): CodexCompactionUsage | undefined {
|
|
353
|
+
if (!isRecord(value)) return undefined;
|
|
354
|
+
const inputTokens = finiteNonNegativeNumber(value.input_tokens);
|
|
355
|
+
const outputTokens = finiteNonNegativeNumber(value.output_tokens);
|
|
356
|
+
const totalTokens = finiteNonNegativeNumber(value.total_tokens);
|
|
357
|
+
const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
|
|
358
|
+
const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
|
|
359
|
+
const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
|
|
360
|
+
const cacheWriteInputTokens = finiteNonNegativeNumber(inputDetails?.cache_write_tokens);
|
|
361
|
+
const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
|
|
362
|
+
if (
|
|
363
|
+
inputTokens === undefined &&
|
|
364
|
+
outputTokens === undefined &&
|
|
365
|
+
totalTokens === undefined &&
|
|
366
|
+
cachedInputTokens === undefined &&
|
|
367
|
+
cacheWriteInputTokens === undefined &&
|
|
368
|
+
reasoningTokens === undefined
|
|
369
|
+
) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
374
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
375
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
376
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
377
|
+
...(cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens } : {}),
|
|
378
|
+
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function finiteNonNegativeNumber(value: unknown): number | undefined {
|
|
383
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isRetryableStatus(status: number): boolean {
|
|
387
|
+
return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function waitForRetry(
|
|
391
|
+
response: Response | undefined,
|
|
392
|
+
attempt: number,
|
|
393
|
+
signal: AbortSignal,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const retryAfter = response ? parseRetryAfter(response.headers) : undefined;
|
|
396
|
+
const delay = Math.min(
|
|
397
|
+
MAX_RETRY_DELAY_MS,
|
|
398
|
+
retryAfter ?? RETRY_BASE_DELAY_MS * 2 ** attempt,
|
|
399
|
+
);
|
|
400
|
+
await new Promise<void>((resolve, reject) => {
|
|
401
|
+
let settled = false;
|
|
402
|
+
const onAbort = () => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
signal.removeEventListener("abort", onAbort);
|
|
407
|
+
reject(signal.reason ?? new Error("Codex compaction request aborted"));
|
|
408
|
+
};
|
|
409
|
+
const timer = setTimeout(() => {
|
|
410
|
+
if (settled) return;
|
|
411
|
+
settled = true;
|
|
412
|
+
signal.removeEventListener("abort", onAbort);
|
|
413
|
+
resolve();
|
|
414
|
+
}, delay);
|
|
415
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
416
|
+
if (signal.aborted) onAbort();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function parseRetryAfter(headers: Headers): number | undefined {
|
|
421
|
+
const milliseconds = headers.get("retry-after-ms");
|
|
422
|
+
if (milliseconds) {
|
|
423
|
+
const value = Number(milliseconds);
|
|
424
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
425
|
+
}
|
|
426
|
+
const seconds = headers.get("retry-after");
|
|
427
|
+
if (!seconds) return undefined;
|
|
428
|
+
const numeric = Number(seconds);
|
|
429
|
+
if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1_000;
|
|
430
|
+
const date = Date.parse(seconds);
|
|
431
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
class CodexCompactionError extends Error {
|
|
435
|
+
constructor(
|
|
436
|
+
message: string,
|
|
437
|
+
readonly retryable: boolean,
|
|
438
|
+
) {
|
|
439
|
+
super(message);
|
|
440
|
+
this.name = "CodexCompactionError";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isRetryableError(error: unknown): boolean {
|
|
445
|
+
return error instanceof CodexCompactionError
|
|
446
|
+
? error.retryable
|
|
447
|
+
: error instanceof Error;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
interface RequestSignal {
|
|
451
|
+
signal: AbortSignal;
|
|
452
|
+
startAttempt: () => void;
|
|
453
|
+
headersReceived: () => void;
|
|
454
|
+
touch: () => void;
|
|
455
|
+
cleanup: () => void;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function requestSignal(signal: AbortSignal | undefined): RequestSignal {
|
|
191
459
|
const controller = new AbortController();
|
|
460
|
+
const totalTimeout = setTimeout(
|
|
461
|
+
() => controller.abort(new Error("Codex compaction request exceeded the total time limit")),
|
|
462
|
+
REQUEST_TOTAL_TIMEOUT_MS,
|
|
463
|
+
);
|
|
464
|
+
let headerTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
465
|
+
let idleTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
466
|
+
|
|
192
467
|
const onAbort = () => controller.abort(signal?.reason);
|
|
193
468
|
if (signal?.aborted) {
|
|
194
469
|
controller.abort(signal.reason);
|
|
195
470
|
} else {
|
|
196
471
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
197
472
|
}
|
|
198
|
-
|
|
473
|
+
|
|
474
|
+
const clearTimers = () => {
|
|
475
|
+
if (headerTimeout) clearTimeout(headerTimeout);
|
|
476
|
+
if (idleTimeout) clearTimeout(idleTimeout);
|
|
477
|
+
headerTimeout = undefined;
|
|
478
|
+
idleTimeout = undefined;
|
|
479
|
+
};
|
|
480
|
+
const touch = () => {
|
|
481
|
+
if (idleTimeout) clearTimeout(idleTimeout);
|
|
482
|
+
idleTimeout = setTimeout(
|
|
483
|
+
() => controller.abort(new Error("Codex compaction response idle timeout")),
|
|
484
|
+
REQUEST_IDLE_TIMEOUT_MS,
|
|
485
|
+
);
|
|
486
|
+
};
|
|
199
487
|
|
|
200
488
|
return {
|
|
201
489
|
signal: controller.signal,
|
|
490
|
+
startAttempt: () => {
|
|
491
|
+
clearTimers();
|
|
492
|
+
headerTimeout = setTimeout(
|
|
493
|
+
() => controller.abort(new Error("Codex compaction request timed out waiting for response headers")),
|
|
494
|
+
REQUEST_HEADER_TIMEOUT_MS,
|
|
495
|
+
);
|
|
496
|
+
},
|
|
497
|
+
headersReceived: () => {
|
|
498
|
+
if (headerTimeout) clearTimeout(headerTimeout);
|
|
499
|
+
headerTimeout = undefined;
|
|
500
|
+
touch();
|
|
501
|
+
},
|
|
502
|
+
touch,
|
|
202
503
|
cleanup: () => {
|
|
203
|
-
clearTimeout(
|
|
504
|
+
clearTimeout(totalTimeout);
|
|
505
|
+
clearTimers();
|
|
204
506
|
signal?.removeEventListener("abort", onAbort);
|
|
205
507
|
},
|
|
206
508
|
};
|
package/src/install-telemetry.ts
CHANGED
|
@@ -46,14 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
46
46
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
50
|
-
if (isTruthyEnvFlag(
|
|
51
|
-
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(
|
|
52
|
-
if (isTruthyEnvFlag(
|
|
53
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
49
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
54
53
|
|
|
55
|
-
const settings = readJsonFile(
|
|
56
|
-
|
|
54
|
+
const settings = readJsonFile(settingsPath) as PiSettingsDocument;
|
|
55
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
56
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
57
|
+
return true;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function getPackageVersion(): string {
|
package/src/remote-compaction.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { stream as captureProviderPayload } from "@earendil-works/pi-ai/compat";
|
|
2
|
-
import type
|
|
2
|
+
import { calculateCost, type Model, type Tool, type Usage } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getCodexAccountFingerprint,
|
|
8
|
+
requestRemoteCompactionWithUsage,
|
|
9
|
+
resolveCodexResponsesUrl,
|
|
10
|
+
type CodexCompactionUsage,
|
|
11
|
+
} from "./codex-wire.js";
|
|
7
12
|
|
|
8
13
|
export const REMOTE_SUMMARY_MARKER = "[pi-codex-compaction:v1]";
|
|
9
14
|
export const REMOTE_COMPACTION_KIND = "pi-codex-compaction";
|
|
@@ -16,14 +21,24 @@ const PI_COMPACTION_SUMMARY_PREFIX = "The conversation history before this point
|
|
|
16
21
|
|
|
17
22
|
export interface RemoteCompactionDetails {
|
|
18
23
|
kind: typeof REMOTE_COMPACTION_KIND;
|
|
19
|
-
version:
|
|
24
|
+
version: 2;
|
|
20
25
|
provider: "openai-codex";
|
|
21
26
|
model: string;
|
|
27
|
+
endpoint: string;
|
|
28
|
+
accountFingerprint: string;
|
|
29
|
+
authKind: CodexAuthKind;
|
|
22
30
|
encryptedContent: string;
|
|
31
|
+
usage?: CodexCompactionUsage;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
type
|
|
34
|
+
export type CodexAuthKind = "oauth" | "api-key" | "unknown";
|
|
35
|
+
|
|
36
|
+
type CodexModel = Pick<
|
|
37
|
+
Model<any>,
|
|
38
|
+
"id" | "api" | "provider" | "baseUrl" | "contextWindow" | "headers" | "reasoning"
|
|
39
|
+
>;
|
|
26
40
|
type AgentMessage = ReturnType<typeof sessionEntryToContextMessages>[number];
|
|
41
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
27
42
|
|
|
28
43
|
export function supportsRemoteCompaction(model: Pick<CodexModel, "api" | "provider"> | undefined): boolean {
|
|
29
44
|
return model?.provider === "openai-codex" && model.api === "openai-codex-responses";
|
|
@@ -38,6 +53,28 @@ export function findActiveRemoteCompaction(entries: readonly SessionEntry[]): Re
|
|
|
38
53
|
return undefined;
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Check the model, endpoint, and account before reusing an opaque checkpoint.
|
|
58
|
+
* The checkpoint is intentionally not treated as portable across those
|
|
59
|
+
* boundaries because the service can bind its encryption to them.
|
|
60
|
+
*/
|
|
61
|
+
export function isRemoteCompactionCompatible(
|
|
62
|
+
details: RemoteCompactionDetails,
|
|
63
|
+
model: Pick<CodexModel, "id" | "api" | "provider" | "baseUrl">,
|
|
64
|
+
accountFingerprint: string,
|
|
65
|
+
authKind: CodexAuthKind = "unknown",
|
|
66
|
+
): boolean {
|
|
67
|
+
if (!supportsRemoteCompaction(model) || details.model !== model.id) return false;
|
|
68
|
+
if (details.accountFingerprint !== accountFingerprint) return false;
|
|
69
|
+
if (details.authKind === "unknown" || authKind === "unknown") return false;
|
|
70
|
+
if (details.authKind !== authKind) return false;
|
|
71
|
+
try {
|
|
72
|
+
return details.endpoint === resolveCodexResponsesUrl(model.baseUrl);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
export function applyRemoteCompactionMarker(payload: unknown, details: RemoteCompactionDetails): unknown | undefined {
|
|
42
79
|
if (!isRecord(payload) || !Array.isArray(payload.input)) return undefined;
|
|
43
80
|
|
|
@@ -57,19 +94,33 @@ export async function createRemoteCompaction(
|
|
|
57
94
|
event: SessionBeforeCompactEvent,
|
|
58
95
|
ctx: ExtensionContext,
|
|
59
96
|
getTools: () => readonly ToolInfoLike[],
|
|
97
|
+
thinkingLevel?: string,
|
|
98
|
+
prepareRequest?: (payload: Record<string, unknown>, messages: readonly AgentMessage[]) => Record<string, unknown>,
|
|
60
99
|
): Promise<{
|
|
61
100
|
summary: string;
|
|
62
101
|
firstKeptEntryId: string;
|
|
63
102
|
tokensBefore: number;
|
|
64
103
|
details: RemoteCompactionDetails;
|
|
104
|
+
usage?: Usage;
|
|
65
105
|
} | undefined> {
|
|
106
|
+
// Pi's custom focus is part of the standard summarizer contract. The
|
|
107
|
+
// Responses compaction envelope has no documented equivalent, so do not
|
|
108
|
+
// silently discard it.
|
|
109
|
+
if (event.customInstructions?.trim()) return undefined;
|
|
110
|
+
|
|
66
111
|
const model = ctx.model as CodexModel | undefined;
|
|
67
112
|
if (!model || !supportsRemoteCompaction(model)) return undefined;
|
|
68
113
|
|
|
114
|
+
const endpoint = resolveCodexResponsesUrl(model.baseUrl);
|
|
69
115
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
|
|
70
116
|
if (!auth.ok || !auth.apiKey) return undefined;
|
|
117
|
+
const accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
|
|
118
|
+
const authKind = getCodexAuthKind(ctx.modelRegistry, model as Model<any>);
|
|
71
119
|
|
|
72
120
|
const previous = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
121
|
+
const compatiblePrevious = previous && isRemoteCompactionCompatible(previous, model, accountFingerprint, authKind)
|
|
122
|
+
? previous
|
|
123
|
+
: undefined;
|
|
73
124
|
const messages = [...event.preparation.messagesToSummarize, ...event.preparation.turnPrefixMessages];
|
|
74
125
|
const instructions = ctx.getSystemPrompt() || "You are a helpful assistant.";
|
|
75
126
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -79,28 +130,43 @@ export async function createRemoteCompaction(
|
|
|
79
130
|
convertToLlm(messages),
|
|
80
131
|
getTools(),
|
|
81
132
|
auth.apiKey,
|
|
133
|
+
auth.headers,
|
|
82
134
|
sessionId,
|
|
83
135
|
event.signal,
|
|
136
|
+
thinkingLevel,
|
|
84
137
|
);
|
|
85
138
|
if (!providerPayload) return undefined;
|
|
86
139
|
const providerInput = providerPayload.input;
|
|
87
140
|
if (!Array.isArray(providerInput)) return undefined;
|
|
88
141
|
const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
|
|
89
142
|
|
|
90
|
-
const input = appendCompactionItems(providerInput, event.preparation,
|
|
91
|
-
const
|
|
143
|
+
const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
|
|
144
|
+
const requestBody = prepareRequest?.({
|
|
145
|
+
...providerPayload,
|
|
146
|
+
input,
|
|
147
|
+
}, messages) ?? { ...providerPayload, input };
|
|
148
|
+
Object.assign(requestBody, {
|
|
149
|
+
model: model.id,
|
|
150
|
+
store: false,
|
|
151
|
+
stream: true,
|
|
152
|
+
});
|
|
153
|
+
if (!Array.isArray(requestBody.input)) return undefined;
|
|
154
|
+
const boundedInput = boundCompactionInput(
|
|
155
|
+
requestBody.input,
|
|
156
|
+
instructions,
|
|
157
|
+
Array.isArray(requestBody.tools) ? requestBody.tools : providerTools,
|
|
158
|
+
model.contextWindow,
|
|
159
|
+
requestBody,
|
|
160
|
+
);
|
|
92
161
|
if (!boundedInput) return undefined;
|
|
93
162
|
|
|
94
|
-
const
|
|
163
|
+
const result = await requestRemoteCompactionWithUsage({
|
|
95
164
|
model,
|
|
96
165
|
apiKey: auth.apiKey,
|
|
97
166
|
authHeaders: auth.headers,
|
|
98
167
|
sessionId,
|
|
99
168
|
body: {
|
|
100
|
-
...
|
|
101
|
-
model: model.id,
|
|
102
|
-
store: false,
|
|
103
|
-
stream: true,
|
|
169
|
+
...requestBody,
|
|
104
170
|
input: boundedInput,
|
|
105
171
|
},
|
|
106
172
|
signal: event.signal,
|
|
@@ -111,12 +177,21 @@ export async function createRemoteCompaction(
|
|
|
111
177
|
summary: `${REMOTE_SUMMARY_MARKER}\n\n${fallback}`,
|
|
112
178
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
113
179
|
tokensBefore: event.preparation.tokensBefore,
|
|
180
|
+
...(result.usage ? { usage: toPiUsage(
|
|
181
|
+
result.usage,
|
|
182
|
+
ctx.model!,
|
|
183
|
+
result.serviceTier && result.serviceTier !== "default" ? result.serviceTier : requestBody.service_tier,
|
|
184
|
+
) } : {}),
|
|
114
185
|
details: {
|
|
115
186
|
kind: REMOTE_COMPACTION_KIND,
|
|
116
|
-
version:
|
|
187
|
+
version: 2,
|
|
117
188
|
provider: "openai-codex",
|
|
118
189
|
model: model.id,
|
|
119
|
-
|
|
190
|
+
endpoint,
|
|
191
|
+
accountFingerprint,
|
|
192
|
+
authKind,
|
|
193
|
+
encryptedContent: result.encryptedContent,
|
|
194
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
120
195
|
},
|
|
121
196
|
};
|
|
122
197
|
}
|
|
@@ -135,29 +210,28 @@ export function boundCompactionInput(
|
|
|
135
210
|
instructions: string,
|
|
136
211
|
tools: readonly unknown[],
|
|
137
212
|
contextWindow: number,
|
|
213
|
+
requestPayload?: Record<string, unknown>,
|
|
138
214
|
): unknown[] | undefined {
|
|
139
|
-
const budget = Math.max(1, contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS);
|
|
215
|
+
const budget = Math.max(1, Math.floor(contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS));
|
|
216
|
+
// A byte-level bound is conservative when the active model's tokenizer is unavailable:
|
|
217
|
+
// a token can be represented by a single UTF-8 byte, but not fewer.
|
|
218
|
+
const budgetBytes = budget;
|
|
140
219
|
const bounded = input.map((item) => item);
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
220
|
+
let requestBytes = estimateConservativeBytes(
|
|
221
|
+
buildCompactionRequest(requestPayload, bounded, instructions, tools),
|
|
222
|
+
);
|
|
223
|
+
if (requestBytes <= budgetBytes) return bounded;
|
|
144
224
|
|
|
145
225
|
// ponytail: trim tool outputs first; if structural content still exceeds the active model window,
|
|
146
226
|
// let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
|
|
147
227
|
for (let index = bounded.length - 1; index >= 0; index--) {
|
|
148
228
|
const item = bounded[index];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const replacement = item.type === "function_call_output"
|
|
152
|
-
? { ...item, output: TRUNCATED_TOOL_OUTPUT }
|
|
153
|
-
: item.type === "tool_search_output"
|
|
154
|
-
? { ...item, tools: [] }
|
|
155
|
-
: undefined;
|
|
229
|
+
const replacement = trimToolOutput(item);
|
|
156
230
|
if (!replacement) continue;
|
|
157
231
|
|
|
158
|
-
|
|
232
|
+
requestBytes += estimateConservativeBytes(replacement) - estimateConservativeBytes(item);
|
|
159
233
|
bounded[index] = replacement;
|
|
160
|
-
if (
|
|
234
|
+
if (requestBytes <= budgetBytes) return bounded;
|
|
161
235
|
}
|
|
162
236
|
|
|
163
237
|
return undefined;
|
|
@@ -166,10 +240,12 @@ export function boundCompactionInput(
|
|
|
166
240
|
export function buildFallbackSummary(preparation: SessionBeforeCompactEvent["preparation"], messages: AgentMessage[]): string {
|
|
167
241
|
const transcript = serializeConversation(convertToLlm(messages));
|
|
168
242
|
const previous = preparation.previousSummary ? `Previous checkpoint fallback:\n${preparation.previousSummary}` : "";
|
|
243
|
+
const fileOperations = formatFileOperations(preparation.fileOps);
|
|
169
244
|
const content = [
|
|
170
245
|
"This checkpoint is opaque to non-Codex providers. Use the transcript excerpt below when continuing this session.",
|
|
171
246
|
previous,
|
|
172
247
|
transcript ? `Discarded conversation excerpt:\n${transcript}` : "",
|
|
248
|
+
fileOperations,
|
|
173
249
|
]
|
|
174
250
|
.filter(Boolean)
|
|
175
251
|
.join("\n\n");
|
|
@@ -182,26 +258,34 @@ async function captureCodexPayload(
|
|
|
182
258
|
messages: ReturnType<typeof convertToLlm>,
|
|
183
259
|
tools: readonly ToolInfoLike[],
|
|
184
260
|
apiKey: string,
|
|
261
|
+
authHeaders: Record<string, string | null> | undefined,
|
|
185
262
|
sessionId: string,
|
|
186
263
|
signal: AbortSignal,
|
|
264
|
+
thinkingLevel: string | undefined,
|
|
187
265
|
): Promise<Record<string, unknown> | undefined> {
|
|
188
266
|
if (signal.aborted) return undefined;
|
|
189
267
|
|
|
190
268
|
let payload: unknown;
|
|
191
269
|
const captureError = new Error("capture Codex request payload");
|
|
192
|
-
const
|
|
193
|
-
systemPrompt: instructions,
|
|
194
|
-
messages,
|
|
195
|
-
tools: [...tools],
|
|
196
|
-
}, {
|
|
270
|
+
const options: Record<string, unknown> = {
|
|
197
271
|
apiKey,
|
|
272
|
+
headers: authHeaders,
|
|
198
273
|
sessionId,
|
|
199
274
|
signal,
|
|
200
|
-
onPayload(next) {
|
|
275
|
+
onPayload(next: unknown) {
|
|
201
276
|
payload = next;
|
|
202
277
|
throw captureError;
|
|
203
278
|
},
|
|
204
|
-
}
|
|
279
|
+
};
|
|
280
|
+
if (model.reasoning && isThinkingLevel(thinkingLevel) && thinkingLevel !== "off") {
|
|
281
|
+
options.reasoningEffort = thinkingLevel;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const stream = captureProviderPayload(model as Model<any>, {
|
|
285
|
+
systemPrompt: instructions,
|
|
286
|
+
messages,
|
|
287
|
+
tools: [...tools],
|
|
288
|
+
}, options);
|
|
205
289
|
|
|
206
290
|
await stream.result();
|
|
207
291
|
return isRecord(payload) ? payload : undefined;
|
|
@@ -219,7 +303,9 @@ function appendCompactionItems(
|
|
|
219
303
|
output.unshift({
|
|
220
304
|
type: "message",
|
|
221
305
|
role: "user",
|
|
222
|
-
|
|
306
|
+
// This is bounded together with the complete request envelope below;
|
|
307
|
+
// the readable fallback limit is only for non-Codex continuation.
|
|
308
|
+
content: [{ type: "input_text", text: preparation.previousSummary }],
|
|
223
309
|
status: "completed",
|
|
224
310
|
});
|
|
225
311
|
}
|
|
@@ -358,19 +444,97 @@ function isRemoteCompactionDetails(value: unknown): value is RemoteCompactionDet
|
|
|
358
444
|
return (
|
|
359
445
|
isRecord(value) &&
|
|
360
446
|
value.kind === REMOTE_COMPACTION_KIND &&
|
|
361
|
-
value.version ===
|
|
447
|
+
value.version === 2 &&
|
|
362
448
|
value.provider === "openai-codex" &&
|
|
363
449
|
typeof value.model === "string" &&
|
|
364
450
|
value.model.length > 0 &&
|
|
451
|
+
value.model.length <= 256 &&
|
|
452
|
+
typeof value.endpoint === "string" &&
|
|
453
|
+
value.endpoint.length <= 2_048 &&
|
|
454
|
+
typeof value.accountFingerprint === "string" &&
|
|
455
|
+
/^sha256:[a-f0-9]{64}$/.test(value.accountFingerprint) &&
|
|
456
|
+
(value.authKind === "oauth" || value.authKind === "api-key" || value.authKind === "unknown") &&
|
|
365
457
|
typeof value.encryptedContent === "string" &&
|
|
366
458
|
value.encryptedContent.length > 0 &&
|
|
367
|
-
value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
459
|
+
value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS &&
|
|
460
|
+
(value.usage === undefined || isCodexCompactionUsage(value.usage))
|
|
368
461
|
);
|
|
369
462
|
}
|
|
370
463
|
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
|
|
464
|
+
export function getCodexAuthKind(
|
|
465
|
+
modelRegistry: unknown,
|
|
466
|
+
model: Model<any>,
|
|
467
|
+
): CodexAuthKind {
|
|
468
|
+
const isUsingOAuth = isRecord(modelRegistry) ? modelRegistry.isUsingOAuth : undefined;
|
|
469
|
+
if (typeof isUsingOAuth !== "function") return "unknown";
|
|
470
|
+
try {
|
|
471
|
+
return isUsingOAuth.call(modelRegistry, model) ? "oauth" : "api-key";
|
|
472
|
+
} catch {
|
|
473
|
+
return "unknown";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isCodexCompactionUsage(value: unknown): value is CodexCompactionUsage {
|
|
478
|
+
return isRecord(value) && Object.values(value).every(
|
|
479
|
+
(entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function buildCompactionRequest(
|
|
484
|
+
requestPayload: Record<string, unknown> | undefined,
|
|
485
|
+
input: readonly unknown[],
|
|
486
|
+
instructions: string,
|
|
487
|
+
tools: readonly unknown[],
|
|
488
|
+
): Record<string, unknown> {
|
|
489
|
+
const payload: Record<string, unknown> = requestPayload ? { ...requestPayload } : {};
|
|
490
|
+
payload.instructions = instructions;
|
|
491
|
+
payload.input = input;
|
|
492
|
+
if (tools.length > 0 || requestPayload && "tools" in requestPayload) payload.tools = tools;
|
|
493
|
+
return payload;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function trimToolOutput(value: unknown): unknown | undefined {
|
|
497
|
+
if (!isRecord(value)) return undefined;
|
|
498
|
+
if (
|
|
499
|
+
(value.type === "function_call_output" ||
|
|
500
|
+
value.type === "custom_tool_call_output" ||
|
|
501
|
+
value.type === "local_shell_call_output" ||
|
|
502
|
+
value.type === "computer_call_output") &&
|
|
503
|
+
value.output !== undefined
|
|
504
|
+
) {
|
|
505
|
+
return { ...value, output: TRUNCATED_TOOL_OUTPUT };
|
|
506
|
+
}
|
|
507
|
+
if (value.type === "tool_search_output" && Array.isArray(value.tools)) {
|
|
508
|
+
return { ...value, tools: [] };
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function estimateConservativeBytes(value: unknown): number {
|
|
514
|
+
try {
|
|
515
|
+
const json = JSON.stringify(value) ?? "";
|
|
516
|
+
return new TextEncoder().encode(json).byteLength;
|
|
517
|
+
} catch {
|
|
518
|
+
return Number.POSITIVE_INFINITY;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function formatFileOperations(fileOps: { read: Set<string>; edited: Set<string> }): string {
|
|
523
|
+
const read = [...fileOps.read].filter((path): path is string => typeof path === "string").sort();
|
|
524
|
+
const edited = [...fileOps.edited].filter((path): path is string => typeof path === "string").sort();
|
|
525
|
+
if (read.length === 0 && edited.length === 0) return "";
|
|
526
|
+
return [
|
|
527
|
+
"File operations in discarded history:",
|
|
528
|
+
read.length > 0 ? `- Read: ${read.join(", ")}` : "",
|
|
529
|
+
edited.length > 0 ? `- Modified: ${edited.join(", ")}` : "",
|
|
530
|
+
]
|
|
531
|
+
.filter(Boolean)
|
|
532
|
+
.join("\n");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isThinkingLevel(value: string | undefined): value is ThinkingLevel {
|
|
536
|
+
return value === "off" || value === "minimal" || value === "low" || value === "medium" ||
|
|
537
|
+
value === "high" || value === "xhigh" || value === "max";
|
|
374
538
|
}
|
|
375
539
|
|
|
376
540
|
function limitText(text: string, maxChars: number): string {
|
|
@@ -381,7 +545,27 @@ function limitText(text: string, maxChars: number): string {
|
|
|
381
545
|
return `${text.slice(0, head)}${omission}${text.slice(text.length - (available - head))}`;
|
|
382
546
|
}
|
|
383
547
|
|
|
384
|
-
type ToolInfoLike =
|
|
548
|
+
type ToolInfoLike = Tool;
|
|
549
|
+
|
|
550
|
+
function toPiUsage(raw: CodexCompactionUsage, model: Model<any>, tier: unknown): Usage {
|
|
551
|
+
const cacheRead = raw.cachedInputTokens ?? 0;
|
|
552
|
+
const cacheWrite = raw.cacheWriteInputTokens ?? 0;
|
|
553
|
+
const output = raw.outputTokens ?? 0;
|
|
554
|
+
const usage: Usage = {
|
|
555
|
+
input: Math.max(0, (raw.inputTokens ?? 0) - cacheRead - cacheWrite),
|
|
556
|
+
output,
|
|
557
|
+
cacheRead,
|
|
558
|
+
cacheWrite,
|
|
559
|
+
...(raw.reasoningTokens !== undefined ? { reasoning: raw.reasoningTokens } : {}),
|
|
560
|
+
totalTokens: raw.totalTokens ?? (raw.inputTokens ?? 0) + output,
|
|
561
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
562
|
+
};
|
|
563
|
+
calculateCost(model, usage);
|
|
564
|
+
// Match Pi 0.85.1's catalog estimates, not subscription credit accounting.
|
|
565
|
+
const multiplier = tier === "priority" ? (model.id === "gpt-5.5" ? 2.5 : 2) : tier === "flex" ? 0.5 : 1;
|
|
566
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) usage.cost[key] *= multiplier;
|
|
567
|
+
return usage;
|
|
568
|
+
}
|
|
385
569
|
|
|
386
570
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
387
571
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|