pi-openai-codex-compat 0.0.18 → 0.0.19

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +54 -35
  3. package/extensions/openai-codex-compat/apply-patch.ts +7 -0
  4. package/extensions/openai-codex-compat/codex-provider/codex-provider-contracts.ts +0 -2
  5. package/extensions/openai-codex-compat/codex-provider/codex-provider-runtime.ts +58 -23
  6. package/extensions/openai-codex-compat/codex-transport/codex-transport-contracts.ts +6 -0
  7. package/extensions/openai-codex-compat/codex-transport/codex-transport-errors.ts +13 -1
  8. package/extensions/openai-codex-compat/codex-transport/codex-transport-sse-stream.ts +1 -0
  9. package/extensions/openai-codex-compat/codex-transport/codex-transport-websocket-stream.ts +1 -0
  10. package/extensions/openai-codex-compat/codex-transport.ts +1 -0
  11. package/extensions/openai-codex-compat/command-tool-contract.ts +9 -0
  12. package/extensions/openai-codex-compat/command-tools.ts +4 -0
  13. package/extensions/openai-codex-compat/compaction-checkpoint.ts +35 -145
  14. package/extensions/openai-codex-compat/config.ts +0 -24
  15. package/extensions/openai-codex-compat/footer.ts +0 -1
  16. package/extensions/openai-codex-compat/image-generation.ts +8 -0
  17. package/extensions/openai-codex-compat/index.ts +2 -2
  18. package/extensions/openai-codex-compat/pi-runtime.ts +62 -9
  19. package/extensions/openai-codex-compat/remote-compaction.ts +46 -71
  20. package/extensions/openai-codex-compat/request-options.ts +2 -9
  21. package/extensions/openai-codex-compat/request-tools-entry.ts +113 -0
  22. package/extensions/openai-codex-compat/settings-pane.ts +2 -11
  23. package/extensions/openai-codex-compat/web-run.ts +2 -0
  24. package/extensions/settings-session.ts +11 -7
  25. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { isBoolean, isString } from "./value-contracts.ts";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
3
  import {
4
4
  buildSessionContext,
5
5
  buildSessionProjection,
@@ -7,9 +7,9 @@ import {
7
7
  sessionEntryToContextMessages,
8
8
  type ContextEditEntry,
9
9
  type SessionEntry,
10
- type ToolInfo,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import {
12
+ getDeclaredTools,
13
13
  getInitialSystemMessage,
14
14
  getSystemMessageText,
15
15
  normalizeContext,
@@ -19,24 +19,17 @@ import {
19
19
  type Message,
20
20
  type Model,
21
21
  type SystemMessage,
22
+ type Tool,
22
23
  type TranscriptContext,
23
24
  } from "@earendil-works/pi-ai";
24
25
  import { Value } from "typebox/value";
25
- import { APPLY_PATCH_LARK_GRAMMAR, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
26
26
  import { CODEX_TOOL_CALL_PROVIDERS } from "./codex-identifiers.ts";
27
27
  import type { ImageDetail } from "./config.ts";
28
- import {
29
- installCompactionItem,
30
- isObject,
31
- requireResponsesInputItems,
32
- type JsonRecord,
33
- } from "./codex-protocol.ts";
28
+ import { installCompactionItem, isObject, requireResponsesInputItems } from "./codex-protocol.ts";
34
29
  import { nativeCommittedPrefixBeforeOverflow, nativeResponseOverrides } from "./native-history.ts";
35
- import { stableResponsesJson } from "./responses-replay.ts";
36
30
  import {
37
31
  CODEX_NAMESPACED_TOOL_NAMES,
38
32
  CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
39
- splitNamespacedToolName,
40
33
  } from "./namespaced-tools.ts";
41
34
  import {
42
35
  RESPONSES_COMPACTION_ITEM_SCHEMA,
@@ -45,8 +38,12 @@ import {
45
38
  type ResponsesInputItem,
46
39
  type ResponsesOutputItem,
47
40
  } from "./responses-item-schema.ts";
48
- import type { ResponsesToolDefinition } from "./responses-tool-schema.ts";
49
- import { convertResponsesMessages } from "./vendor/pi-ai/openai-responses-serialization.ts";
41
+ import {
42
+ convertResponsesMessages,
43
+ convertResponsesTools,
44
+ createGrammarToolInputProperties,
45
+ type ResponsesItem,
46
+ } from "./vendor/pi-ai/openai-responses-serialization.ts";
50
47
 
51
48
  type AgentMessages = ReturnType<typeof sessionEntryToContextMessages>;
52
49
 
@@ -113,148 +110,41 @@ export function requestInstructions(context: TranscriptContext): string {
113
110
  return initial ? getSystemMessageText(initial) : "";
114
111
  }
115
112
 
113
+ /** A session branch as the Pi AI messages Pi sends, including the system messages that declare tools. */
114
+ export function branchTranscript(branch: readonly SessionEntry[]): Message[] {
115
+ return convertToLlm(buildSessionContext([...branch]).messages);
116
+ }
117
+
116
118
  /** `instructions` for a request built from a session branch rather than Pi's live transcript. */
117
119
  export function branchInstructions(model: Model<Api>, branch: readonly SessionEntry[]): string {
118
120
  return requestInstructions(
119
- resolveRequestTranscript(model, {
120
- messages: convertToLlm(buildSessionContext([...branch]).messages),
121
- }),
121
+ resolveRequestTranscript(model, { messages: branchTranscript(branch) }),
122
122
  );
123
123
  }
124
124
 
125
- function responsesToolParameters(tool: ToolInfo): JsonRecord {
126
- if (!isObject(tool.parameters)) {
127
- throw new Error(`Tool ${tool.name} must have JSON object parameters.`);
128
- }
129
- return tool.parameters;
130
- }
131
-
132
- function asResponsesTool(
133
- tool: ToolInfo,
134
- grammarToolInputProperties: GrammarToolInputProperties,
135
- ): ResponsesToolDefinition {
136
- if (tool.name === APPLY_PATCH_TOOL_NAME && grammarToolInputProperties.has(tool.name)) {
137
- return {
138
- type: "custom",
139
- name: tool.name,
140
- description: tool.description,
141
- format: {
142
- type: "grammar",
143
- syntax: "lark",
144
- definition: APPLY_PATCH_LARK_GRAMMAR,
145
- },
146
- };
147
- }
148
- const namespaced = splitNamespacedToolName(tool.name);
149
- if (namespaced) {
150
- return {
151
- type: "namespace",
152
- name: namespaced.namespace,
153
- description: `Tools in the ${namespaced.namespace} namespace.`,
154
- tools: [
155
- {
156
- type: "function",
157
- name: namespaced.name,
158
- description: tool.description,
159
- parameters: responsesToolParameters(tool),
160
- strict: false,
161
- },
162
- ],
163
- };
164
- }
165
- return {
166
- type: "function",
167
- name: tool.name,
168
- description: tool.description,
169
- parameters: responsesToolParameters(tool),
170
- strict: false,
171
- };
172
- }
173
-
174
- export function activeResponsesTools(
175
- allTools: readonly ToolInfo[],
176
- activeNames: readonly string[],
177
- grammarToolInputProperties: GrammarToolInputProperties = new Map(),
178
- ): ResponsesToolDefinition[] | undefined {
179
- const enabled = new Set(activeNames);
180
- const tools = allTools.filter((tool) => enabled.has(tool.name));
181
- return tools.length > 0
182
- ? tools.map((tool) => asResponsesTool(tool, grammarToolInputProperties))
183
- : undefined;
184
- }
185
-
186
- /** The raw tool definition fields that both Pi AI's `Tool` and Pi's `ToolInfo` carry. */
187
- export type ToolDeclarationSource = Pick<ToolInfo, "name" | "description" | "parameters">;
188
-
189
125
  /**
190
- * Fingerprint raw tool definitions by name, description, and parameter schema,
191
- * independent of declaration order. Both the transcript tools a turn request
192
- * declared and the registry's active `ToolInfo` list produce the same value for
193
- * the same definitions, so a cached turn template can be compared with the
194
- * current tools without reversing the strict schema conversion applied on the
195
- * wire.
126
+ * The top-level `tools` of a Codex request declaring the given transcript tools.
127
+ * Turn requests and native compaction share this conversion, so the same tools
128
+ * produce the same declarations and the same prompt-cache prefix.
196
129
  */
197
- export function toolDefinitionFingerprint(tools: readonly ToolDeclarationSource[]): string {
198
- const declarations = tools
199
- .map((tool) => {
200
- // The JSON round-trip drops typebox symbol keys and undefined fields, as
201
- // Pi AI's own declaration comparison does.
202
- const parameters: unknown = JSON.parse(JSON.stringify(tool.parameters));
203
- return { name: tool.name, description: tool.description, parameters };
204
- })
205
- .sort((left, right) => left.name.localeCompare(right.name));
206
- return createHash("sha256").update(stableResponsesJson(declarations)).digest("hex");
207
- }
208
-
209
- /** Fingerprint of the registry tools that are currently active. */
210
- export function activeToolFingerprint(
211
- allTools: readonly ToolInfo[],
212
- activeNames: readonly string[],
213
- ): string {
214
- const enabled = new Set(activeNames);
215
- return toolDefinitionFingerprint(allTools.filter((tool) => enabled.has(tool.name)));
130
+ export function requestTools(model: Model<Api>, tools: readonly Tool[]): ResponsesItem[] {
131
+ const compat = responsesCompatibility(model.compat);
132
+ return convertResponsesTools(tools, {
133
+ strict: false,
134
+ supportsStrictMode: compat.supportsStrictMode ?? true,
135
+ supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools ?? false,
136
+ namespacedToolNames: CODEX_NAMESPACED_TOOL_NAMES,
137
+ });
216
138
  }
217
139
 
218
- /**
219
- * Whether a cached turn template still declares exactly the active tools.
220
- *
221
- * The serialized `tools` list must name the active set (namespace members
222
- * count as `namespace.member`), and the raw definitions that produced it must
223
- * fingerprint identically to the active registry tools. Comparing names and
224
- * descriptions alone missed parameter-schema changes under an unchanged name.
225
- *
226
- * A cached turn template carries the exact declarations the turn requests sent,
227
- * including strict schema conversion that `ToolInfo` cannot reproduce. Reusing
228
- * it keeps the compaction request on the turn requests' prompt-cache prefix.
229
- */
230
- export function declaresActiveTools(
231
- serialized: readonly JsonRecord[],
232
- cachedToolFingerprint: string,
233
- allTools: readonly ToolInfo[],
234
- activeNames: readonly string[],
235
- ): boolean {
236
- if (cachedToolFingerprint !== activeToolFingerprint(allTools, activeNames)) return false;
237
- const declared = new Map<string, string | undefined>();
238
- for (const item of serialized) {
239
- if (item.type === "namespace" && isString(item.name) && Array.isArray(item.tools)) {
240
- for (const member of item.tools) {
241
- if (!isObject(member) || !isString(member.name)) return false;
242
- declared.set(
243
- `${item.name}.${member.name}`,
244
- isString(member["description"]) ? member["description"] : undefined,
245
- );
246
- }
247
- } else if ((item.type === "function" || item.type === "custom") && isString(item.name)) {
248
- declared.set(item.name, isString(item["description"]) ? item["description"] : undefined);
249
- } else {
250
- return false;
251
- }
252
- }
253
- const enabled = new Set(activeNames);
254
- const active = allTools.filter((tool) => enabled.has(tool.name));
255
- return (
256
- declared.size === active.length &&
257
- active.every((tool) => declared.has(tool.name) && declared.get(tool.name) === tool.description)
140
+ /** Input properties of the grammar tools a transcript ever declared, for replaying their calls. */
141
+ export function requestGrammarToolInputProperties(
142
+ model: Model<Api>,
143
+ messages: readonly Message[],
144
+ ): GrammarToolInputProperties {
145
+ return createGrammarToolInputProperties(
146
+ getDeclaredTools([...messages]),
147
+ responsesCompatibility(model.compat).supportsOpenAIGrammarTools ?? false,
258
148
  );
259
149
  }
260
150
 
@@ -24,12 +24,6 @@ export const REASONING_SUMMARY_SCHEMA = {
24
24
 
25
25
  export type ReasoningSummary = Static<typeof REASONING_SUMMARY_SCHEMA>;
26
26
 
27
- export const REASONING_MODE_SCHEMA = {
28
- enum: ["standard", "pro"],
29
- } as const;
30
-
31
- export type ReasoningMode = Static<typeof REASONING_MODE_SCHEMA>;
32
-
33
27
  export const IMAGE_DETAIL_SCHEMA = {
34
28
  enum: ["auto", "low", "high", "original"],
35
29
  } as const;
@@ -80,7 +74,6 @@ export interface CodexCompatConfig {
80
74
  webSearch: WebSearchMode;
81
75
  textVerbosity: TextVerbosity;
82
76
  reasoningSummary: ReasoningSummary;
83
- reasoningMode: ReasoningMode;
84
77
  }
85
78
 
86
79
  export const CONFIG_ENVIRONMENT_VARIABLES = {
@@ -98,7 +91,6 @@ export const CONFIG_ENVIRONMENT_VARIABLES = {
98
91
  webSearch: `${ENV_PREFIX}WEB_SEARCH_MODE`,
99
92
  textVerbosity: `${ENV_PREFIX}TEXT_VERBOSITY`,
100
93
  reasoningSummary: `${ENV_PREFIX}REASONING_SUMMARY`,
101
- reasoningMode: `${ENV_PREFIX}REASONING_MODE`,
102
94
  } as const satisfies Record<keyof CodexCompatConfig, string>;
103
95
 
104
96
  export type ConfigLayer = {
@@ -116,7 +108,6 @@ export type ConfigLayer = {
116
108
  webSearch?: WebSearchMode;
117
109
  textVerbosity?: TextVerbosity;
118
110
  reasoningSummary?: ReasoningSummary;
119
- reasoningMode?: ReasoningMode;
120
111
  };
121
112
 
122
113
  export const CONFIG_FILE = "openai-codex-compat.json";
@@ -134,7 +125,6 @@ export const DEFAULT_CONFIG: CodexCompatConfig = {
134
125
  webSearch: "disabled",
135
126
  textVerbosity: "low",
136
127
  reasoningSummary: "auto",
137
- reasoningMode: "standard",
138
128
  };
139
129
 
140
130
  type Environment = Readonly<Record<string, string | undefined>>;
@@ -277,13 +267,6 @@ export function parseEnvironmentConfig(environment: Environment = process.env):
277
267
  );
278
268
  if (reasoningSummary !== undefined) layer.reasoningSummary = reasoningSummary;
279
269
 
280
- const reasoningMode = environmentEnum(
281
- environment,
282
- CONFIG_ENVIRONMENT_VARIABLES.reasoningMode,
283
- REASONING_MODE_SCHEMA,
284
- );
285
- if (reasoningMode !== undefined) layer.reasoningMode = reasoningMode;
286
-
287
270
  return layer;
288
271
  }
289
272
 
@@ -357,11 +340,6 @@ export function parseConfig(value: unknown): ConfigLayer {
357
340
  layer.reasoningSummary = reasoningSummary;
358
341
  }
359
342
 
360
- const reasoningMode = value["reasoningMode"];
361
- if (Value.Check(REASONING_MODE_SCHEMA, reasoningMode)) {
362
- layer.reasoningMode = reasoningMode;
363
- }
364
-
365
343
  return layer;
366
344
  }
367
345
 
@@ -404,7 +382,6 @@ export function resolveConfig(
404
382
  if (merged.webSearch) config.webSearch = merged.webSearch;
405
383
  if (merged.textVerbosity) config.textVerbosity = merged.textVerbosity;
406
384
  if (merged.reasoningSummary) config.reasoningSummary = merged.reasoningSummary;
407
- if (merged.reasoningMode) config.reasoningMode = merged.reasoningMode;
408
385
  return config;
409
386
  }
410
387
 
@@ -448,7 +425,6 @@ export function configLayer(config: CodexCompatConfig): ConfigLayer {
448
425
  webSearch: config.webSearch,
449
426
  textVerbosity: config.textVerbosity,
450
427
  reasoningSummary: config.reasoningSummary,
451
- reasoningMode: config.reasoningMode,
452
428
  };
453
429
  }
454
430
 
@@ -43,7 +43,6 @@ export type FooterContext = ConfigContext &
43
43
  export function footerSettingLabels(config: CodexCompatConfig): string[] {
44
44
  return [
45
45
  config.fastMode ? "fast" : undefined,
46
- config.reasoningMode === "pro" ? "pro" : undefined,
47
46
  config.textVerbosity !== "low" ? `verbosity ${config.textVerbosity}` : undefined,
48
47
  config.reasoningSummary !== "auto" ? `summary ${config.reasoningSummary}` : undefined,
49
48
  ].filter((label): label is string => label !== undefined);
@@ -288,6 +288,14 @@ export default function registerImageGeneration(
288
288
  parameters: IMAGE_GENERATION_PARAMETERS,
289
289
  executionMode: "sequential",
290
290
  renderShell: "self",
291
+ // Each call adds a new image file and refuses to replace a different one. The request goes
292
+ // to the OpenAI image service.
293
+ annotations: {
294
+ readOnlyHint: false,
295
+ destructiveHint: false,
296
+ idempotentHint: false,
297
+ openWorldHint: true,
298
+ },
291
299
  async execute(toolCallId, params, signal, onUpdate, ctx) {
292
300
  const model = ctx.model;
293
301
  if (!isCodexModel(model)) {
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import * as piCodingAgent from "@earendil-works/pi-coding-agent";
4
- import { requirePiTranscriptRuntime } from "./pi-runtime.ts";
4
+ import { requirePiRuntime } from "./pi-runtime.ts";
5
5
  import { DEFAULT_CONFIG, loadConfig, type CodexCompatConfig } from "./config.ts";
6
6
  import type { ConfigContext } from "./config-context.ts";
7
7
  import { registerCodexProvider } from "./codex-provider.ts";
@@ -24,7 +24,7 @@ export {
24
24
  } from "./codex-transport.ts";
25
25
 
26
26
  export default function registerOpenAICodexCompat(pi: ExtensionAPI): void {
27
- requirePiTranscriptRuntime(piAi, piCodingAgent);
27
+ requirePiRuntime(piAi, piCodingAgent, pi);
28
28
  let activeConfig: CodexCompatConfig | undefined;
29
29
  let sessionContext: Parameters<typeof loadSessionConfig>[0] | undefined;
30
30
  const resolveConfig = (ctx: ConfigContext): CodexCompatConfig => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Every Pi 0.86 transcript API this extension imports as a value from
2
+ * Every transcript API this extension imports as a value from
3
3
  * `@earendil-works/pi-ai`. Pi's extension loader turns a missing named import
4
4
  * into `undefined` rather than a load error, so an older host fails at first use
5
5
  * unless the surface is checked up front. Keep this list equal to the value
@@ -18,27 +18,80 @@ export const TRANSCRIPT_APIS = [
18
18
  ] as const;
19
19
 
20
20
  /**
21
- * Every Pi 0.87 session API this extension imports as a value from
21
+ * Every session API this extension imports as a value from
22
22
  * `@earendil-works/pi-coding-agent`. Keep this list equal to the value imports
23
23
  * in `compaction-checkpoint.ts`.
24
24
  */
25
25
  export const SESSION_APIS = ["buildSessionProjection"] as const;
26
26
 
27
- export const REQUIRED_PI = "Pi 0.99.x";
27
+ /**
28
+ * Host APIs that first shipped in Pi 0.99: the footer's `SessionManager.getEntryCount()`
29
+ * and the extension API's `getSettings()`. They prove the loaded runtime is 0.99 even when
30
+ * `PI_PACKAGE_DIR` points package metadata at another installation.
31
+ */
32
+ export const PI_099_APIS = ["SessionManager.getEntryCount", "ExtensionAPI.getSettings"] as const;
33
+
34
+ export const MINIMUM_PI_VERSION = "0.99.1";
35
+ export const REQUIRED_PI = `Pi ${MINIMUM_PI_VERSION} or later`;
36
+
37
+ function isFunction(value: unknown): boolean {
38
+ return typeof value === "function";
39
+ }
40
+
41
+ function prototypeMethod(value: unknown, name: string): unknown {
42
+ if (typeof value !== "function") return undefined;
43
+ const prototype: unknown = value.prototype;
44
+ return typeof prototype === "object" && prototype !== null
45
+ ? Reflect.get(prototype, name)
46
+ : undefined;
47
+ }
28
48
 
29
- /** Check the loaded host, not package metadata that PI_PACKAGE_DIR can override. */
30
- export function requirePiTranscriptRuntime(
49
+ /** Compare dotted numeric release versions; prerelease suffixes rank below their release. */
50
+ export function atLeastVersion(version: unknown, minimum: string): boolean {
51
+ if (typeof version !== "string") return false;
52
+ const match = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(version);
53
+ if (!match) return false;
54
+ const actual = [Number(match[1]), Number(match[2]), Number(match[3])];
55
+ const required = minimum.split(".").map(Number);
56
+ for (let index = 0; index < 3; index++) {
57
+ const left = actual[index] ?? 0;
58
+ const right = required[index] ?? 0;
59
+ if (left !== right) return left > right;
60
+ }
61
+ return match[4] === undefined;
62
+ }
63
+
64
+ /**
65
+ * Require the Pi 0.99 host surface and a reported version of at least 0.99.1. The version comes
66
+ * from package metadata, so the API checks also run to catch an older executable whose
67
+ * `PI_PACKAGE_DIR` names a newer package.
68
+ */
69
+ export function requirePiRuntime(
31
70
  transcriptApi: Record<string, unknown>,
32
71
  sessionApi: Record<string, unknown>,
72
+ extensionApi: { readonly getSettings?: unknown },
33
73
  ): void {
34
74
  const missing = [
35
- ...TRANSCRIPT_APIS.filter((name) => typeof transcriptApi[name] !== "function"),
36
- ...SESSION_APIS.filter((name) => typeof sessionApi[name] !== "function"),
75
+ ...TRANSCRIPT_APIS.filter((name) => !isFunction(transcriptApi[name])),
76
+ ...SESSION_APIS.filter((name) => !isFunction(sessionApi[name])),
77
+ ...(isFunction(prototypeMethod(sessionApi["SessionManager"], "getEntryCount"))
78
+ ? []
79
+ : ["SessionManager.getEntryCount"]),
80
+ ...(isFunction(extensionApi.getSettings) ? [] : ["ExtensionAPI.getSettings"]),
81
+ ];
82
+ const version = sessionApi["VERSION"];
83
+ const problems = [
84
+ ...(missing.length > 0 ? [`Missing host APIs: ${missing.join(", ")}.`] : []),
85
+ ...(atLeastVersion(version, MINIMUM_PI_VERSION)
86
+ ? []
87
+ : [
88
+ `The host reports Pi ${typeof version === "string" ? version : "with no version"}, below ${MINIMUM_PI_VERSION}.`,
89
+ ]),
37
90
  ];
38
- if (missing.length > 0) {
91
+ if (problems.length > 0) {
39
92
  throw new Error(
40
93
  `pi-openai-codex-compat requires a running ${REQUIRED_PI} runtime. ` +
41
- `Missing host APIs: ${missing.join(", ")}. ` +
94
+ `${problems.join(" ")} ` +
42
95
  `Exit Pi and start ${REQUIRED_PI} after updating. /reload cannot upgrade the running ` +
43
96
  "runtime, and PI_PACKAGE_DIR can make an older executable report a newer version.",
44
97
  );
@@ -1,4 +1,3 @@
1
- import { isString } from "./value-contracts.ts";
2
1
  import type {
3
2
  BeforeProviderHeadersEvent,
4
3
  CompactionResult,
@@ -6,31 +5,34 @@ import type {
6
5
  ExtensionAPI,
7
6
  ExtensionContext,
8
7
  SessionBeforeCompactEvent,
9
- ToolInfo,
10
8
  } from "@earendil-works/pi-coding-agent";
11
- import type {
12
- Api,
13
- Model,
14
- OpenAICodexResponsesOptions,
15
- ProviderHeaders,
9
+ import {
10
+ getCurrentTools,
11
+ type Api,
12
+ type Message,
13
+ type Model,
14
+ type OpenAICodexResponsesOptions,
15
+ type ProviderHeaders,
16
16
  } from "@earendil-works/pi-ai";
17
- import { addRemoteCompactionFeature, isObject, type JsonRecord } from "./codex-protocol.ts";
17
+ import { addRemoteCompactionFeature, type JsonRecord } from "./codex-protocol.ts";
18
18
  import { CODEX_API, CODEX_PROVIDER } from "./codex-identifiers.ts";
19
19
  import {
20
- activeResponsesTools,
21
20
  branchInstructions,
22
- declaresActiveTools,
21
+ branchTranscript,
23
22
  providerHistory,
24
23
  remoteCompactionMarkerSummary,
24
+ requestGrammarToolInputProperties,
25
+ requestTools,
25
26
  responsesCompatibility,
26
27
  searchCheckpoint,
27
28
  type GrammarToolInputProperties,
28
29
  } from "./compaction-checkpoint.ts";
30
+ import { APPLY_PATCH_INPUT_PROPERTY, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
29
31
  import { resolveFileConfig, type ConfigResolver } from "./config-context.ts";
30
32
  import type { CodexProviderRuntime } from "./codex-provider.ts";
31
33
  import { responsesCompactionV2Metadata, type CodexCompactionMetadata } from "./codex-metadata.ts";
32
- import { APPLY_PATCH_INPUT_PROPERTY, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
33
34
  import { errorFromThrown } from "./error-from-thrown.ts";
35
+ import { latestRequestTools } from "./request-tools-entry.ts";
34
36
  import { selectedRegistryModel } from "./model-context.ts";
35
37
 
36
38
  export type RemoteCompactionContext = Parameters<ConfigResolver>[0] & {
@@ -88,7 +90,6 @@ export type RemoteCompactionHookHandler = (
88
90
 
89
91
  export type RemoteCompactionApi = {
90
92
  getActiveTools: () => string[];
91
- getAllTools: () => ToolInfo[];
92
93
  onBeforeProviderHeaders: (handler: RemoteCompactionHeadersHandler) => void;
93
94
  onContext: (handler: RemoteCompactionContextHandler) => void;
94
95
  onSessionBeforeCompact: (handler: RemoteCompactionHookHandler) => void;
@@ -113,7 +114,6 @@ export function remoteCompactionApi(pi: ExtensionAPI): RemoteCompactionApi {
113
114
 
114
115
  return {
115
116
  getActiveTools: () => pi.getActiveTools(),
116
- getAllTools: () => pi.getAllTools(),
117
117
  onBeforeProviderHeaders: (handler) =>
118
118
  pi.on("before_provider_headers", (event, ctx) => handler(event, context(ctx))),
119
119
  // The full-transcript event returns messages verbatim. A changed `context`
@@ -156,45 +156,28 @@ function instructionsForCompaction(systemPrompt: string, customInstructions?: st
156
156
  : systemPrompt;
157
157
  }
158
158
 
159
- function toolInputProperty(tool: ToolInfo | undefined): string | undefined {
160
- if (tool?.name === APPLY_PATCH_TOOL_NAME) return APPLY_PATCH_INPUT_PROPERTY;
161
- if (!tool || !isObject(tool.parameters)) return undefined;
162
- const required = Array.isArray(tool.parameters["required"])
163
- ? tool.parameters["required"].filter((name): name is string => typeof name === "string")
164
- : [];
165
- if (required.length !== 1 || !isObject(tool.parameters["properties"])) return undefined;
166
- const requiredProperty = required[0];
167
- if (requiredProperty === undefined) return undefined;
168
- const property = tool.parameters["properties"][requiredProperty];
169
- return isObject(property) && property.type === "string" ? requiredProperty : undefined;
170
- }
171
-
172
- function requestGrammarToolInputProperties(
173
- payload: JsonRecord,
174
- tools: readonly ToolInfo[],
175
- ): GrammarToolInputProperties {
176
- const byName = new Map(tools.map((tool) => [tool.name, tool]));
177
- const properties = new Map<string, string>();
178
- if (!Array.isArray(payload.tools)) return properties;
179
-
180
- for (const declaration of payload.tools) {
181
- if (!isObject(declaration)) continue;
182
- const tool = declaration;
183
- if (tool.type !== "custom" || !isString(tool.name)) continue;
184
- const property = toolInputProperty(byName.get(tool.name));
185
- if (property) properties.set(tool.name, property);
186
- }
187
- return properties;
188
- }
189
-
159
+ /**
160
+ * Grammar input properties for a branch without saved request tools. The next
161
+ * turn declares the transcript's tools plus the active ones. Sessions from
162
+ * before Pi recorded tool declarations declare none, so an active
163
+ * `apply_patch`, the only grammar tool Pi's tool registry can identify, is
164
+ * added as that turn would add it. Its replayed calls then keep their custom
165
+ * tool call and output types.
166
+ */
190
167
  function fallbackGrammarToolInputProperties(
191
- activeNames: readonly string[],
192
168
  model: Model<Api>,
169
+ transcript: readonly Message[],
170
+ activeNames: readonly string[],
193
171
  ): GrammarToolInputProperties {
194
- const compat = responsesCompatibility(model.compat);
195
- return activeNames.includes(APPLY_PATCH_TOOL_NAME) && compat?.supportsOpenAIGrammarTools
196
- ? new Map([[APPLY_PATCH_TOOL_NAME, APPLY_PATCH_INPUT_PROPERTY]])
197
- : new Map();
172
+ const properties = new Map(requestGrammarToolInputProperties(model, transcript));
173
+ if (
174
+ !properties.has(APPLY_PATCH_TOOL_NAME) &&
175
+ activeNames.includes(APPLY_PATCH_TOOL_NAME) &&
176
+ responsesCompatibility(model.compat).supportsOpenAIGrammarTools
177
+ ) {
178
+ properties.set(APPLY_PATCH_TOOL_NAME, APPLY_PATCH_INPUT_PROPERTY);
179
+ }
180
+ return properties;
198
181
  }
199
182
 
200
183
  function compactionMetadata(reason: SessionBeforeCompactEvent["reason"]): CodexCompactionMetadata {
@@ -242,13 +225,19 @@ export default function registerRemoteCompaction(
242
225
  if (!authentication.apiKey) throw new Error("OpenAI Codex authentication is unavailable.");
243
226
 
244
227
  const sessionId = ctx.sessionManager.getSessionId();
245
- const allTools = pi.getAllTools();
246
228
  const cached = runtime.latestTemplate(sessionId);
247
229
  const matching = cached?.modelId === ctx.model.id ? cached : undefined;
230
+ // Declare the tools the branch's latest request to this model sent, as
231
+ // saved in the session. The transcript also lists tools Pi leaves out of
232
+ // requests (codemode `only` mode), and other extensions can edit them.
233
+ // Sessions without a saved entry, such as those older versions wrote,
234
+ // declare the transcript's current tools.
235
+ const saved = latestRequestTools(event.branchEntries, ctx.model.id);
236
+ const transcript = saved ? undefined : branchTranscript(event.branchEntries);
248
237
  const config = resolveConfig(ctx);
249
238
  const grammarToolInputProperties =
250
- matching?.grammarToolInputProperties ??
251
- fallbackGrammarToolInputProperties(pi.getActiveTools(), ctx.model);
239
+ saved?.grammarToolInputProperties ??
240
+ fallbackGrammarToolInputProperties(ctx.model, transcript ?? [], pi.getActiveTools());
252
241
  const history = providerHistory({
253
242
  branch: event.branchEntries,
254
243
  wireModel: ctx.model,
@@ -256,22 +245,10 @@ export default function registerRemoteCompaction(
256
245
  imageDetail: config.imageDetail,
257
246
  recoverLatestOverflowPrefix: event.reason === "overflow" && event.willRetry,
258
247
  });
259
- // Reuse the last turn's exact declarations when they still describe the
260
- // active tools, so the compaction request shares the turn requests'
261
- // prompt-cache prefix. Rebuild from the registry when the active set or
262
- // any definition (including a parameter schema under an unchanged name)
263
- // changed since that turn, or when no turn template exists.
264
- const template: JsonRecord = { ...matching?.payload };
265
- const activeNames = pi.getActiveTools();
266
- const cachedTools = Array.isArray(matching?.payload.tools)
267
- ? matching.payload.tools.filter(isObject)
268
- : undefined;
269
- template.tools =
270
- cachedTools &&
271
- matching &&
272
- declaresActiveTools(cachedTools, matching.toolFingerprint, allTools, activeNames)
273
- ? cachedTools
274
- : (activeResponsesTools(allTools, activeNames, grammarToolInputProperties) ?? []);
248
+ const template: JsonRecord = {
249
+ ...matching?.payload,
250
+ tools: saved?.tools ?? requestTools(ctx.model, getCurrentTools(transcript ?? [])),
251
+ };
275
252
  const requestOptions: OpenAICodexResponsesOptions = {
276
253
  ...matching?.requestOptions,
277
254
  apiKey: authentication.apiKey,
@@ -291,9 +268,7 @@ export default function registerRemoteCompaction(
291
268
  branchInstructions(ctx.model, event.branchEntries) || ctx.getSystemPrompt(),
292
269
  event.customInstructions,
293
270
  ),
294
- grammarToolInputProperties:
295
- matching?.grammarToolInputProperties ??
296
- requestGrammarToolInputProperties(template, allTools),
271
+ grammarToolInputProperties,
297
272
  template,
298
273
  priority: config.fastMode,
299
274
  compactionMetadata: compactionMetadata(event.reason),
@@ -19,10 +19,6 @@ export function isCodexModel(model: Model<Api> | undefined): model is Model<type
19
19
  return model !== undefined && model.provider === CODEX_PROVIDER && hasApi(model, CODEX_API);
20
20
  }
21
21
 
22
- export function supportsReasoningMode(modelId: string): boolean {
23
- return modelId === "gpt-6-astra" || /^gpt-5\.6(?:-|$)/.test(modelId);
24
- }
25
-
26
22
  function isWebSearchTool(value: unknown): boolean {
27
23
  return isObject(value) && value.type === "web_search";
28
24
  }
@@ -72,11 +68,8 @@ export function applyCodexRequestOptions(
72
68
  } else {
73
69
  updatedReasoning["summary"] = config.reasoningSummary;
74
70
  }
75
- if (supportsReasoningMode(options.modelId) && config.reasoningMode === "pro") {
76
- updatedReasoning["mode"] = "pro";
77
- } else {
78
- Reflect.deleteProperty(updatedReasoning, "mode");
79
- }
71
+ // The Codex endpoint rejects `reasoning.mode`.
72
+ Reflect.deleteProperty(updatedReasoning, "mode");
80
73
  result["reasoning"] = updatedReasoning;
81
74
  }
82
75