pi-openai-codex-compat 0.0.11 → 0.0.13

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.13 - 2026-09-21
6
+
7
+ ### Fixed
8
+
9
+ - Declare the complete current tool set in the top-level `tools` field on
10
+ every request and never inside `input`, as the official Codex client does.
11
+ Tools added mid-session were replayed as inline `additional_tools` items;
12
+ the Codex backend then often treated other tools as unavailable, and after a
13
+ percentage-triggered compaction the inline declarations were gone entirely,
14
+ so the model reported that it had no callable tools. A mid-session tool
15
+ change now costs one prompt-cache miss instead.
16
+
17
+ ### Changed
18
+
19
+ - Use Pi 0.86.1 as the development and packaged-CLI integration baseline. The
20
+ supported range stays `>=0.86.0 <0.87.0`.
21
+
22
+ ## 0.0.12 - 2026-09-20
23
+
24
+ ### Fixed
25
+
26
+ - Send Pi's strict-sampling tools (`read`, `bash`, `edit`, `write`) with the strict
27
+ JSON-schema subset Codex requires. On Pi 0.86 every request that declared a
28
+ built-in tool failed with an `Invalid schema for function` error.
29
+
5
30
  ## 0.0.11 - 2026-09-20
6
31
 
7
32
  ### Fixed
package/README.md CHANGED
@@ -68,7 +68,7 @@ The compatibility baseline is official Codex CLI `0.149.1`, released August 24,
68
68
  | Branching | Checkpoints and native response overrides follow Pi's active session branch. Official Codex uses its own thread, turn, rollback, fork, and context-window lineage. |
69
69
  | Model switching | This package rejects model switches while the active Pi branch contains a native Codex checkpoint because the checkpoint is model-specific. |
70
70
  | System instructions | Replay Pi's system-message sections into the complete current prompt. Responses Lite models prepend it as developer input after `additional_tools`; other models send it through Responses `instructions`. Do not replay older Pi system-prompt text alongside that current prompt. `/reload`, forced prompts, compaction snapshots, and resumed sessions use the current state without rewriting old checkpoints. |
71
- | Dynamic tool declarations | Replay additive `toolsAdded` system messages in place as `additional_tools` or tool-search pairs on capable models. Tool removal or redefinition uses the complete current top-level tool set instead. Compaction rebases declarations so tools remain available after their history is replaced. |
71
+ | Dynamic tool declarations | Send the complete current tool set, replayed from Pi's `toolsAdded` and `toolsRemoved` system messages, in the top-level `tools` field on every request, as the official Codex client does. Tool declarations never travel inside `input`: inline `additional_tools` items and synthetic tool-search pairs made the Codex backend unreliable about which tools exist. A mid-session tool change costs one prompt-cache miss, and native checkpoints need no declaration rebase. |
72
72
  | Turn metadata | Requests send a persisted installation id plus Pi-derived session, thread, context-window, turn, source, sandbox, request-kind, and nested compaction-operation metadata in `client_metadata` and compatible headers. The in-memory context-window number advances after successful compaction. One turn id is reused throughout a Pi agent run, while prewarm has its own id. First-party requests also carry Codex's model-and-tier routing hint. The provider captures the server-issued `x-codex-turn-state` once per agent run, replays it on WebSocket retries, SSE requests, and WebSocket-to-SSE fallback, and records all identity values in transport diagnostics. Pi does not reconstruct prior window number after extension reload/session resume or reproduce workspace Git/parent/subagent/Code Mode metadata. Each marked Pi tree branch receives its own persisted thread UUID. |
73
73
  | Cache preparation | Before the first cache-enabled WebSocket turn, the package prewarms only the stable instruction/tool prefix: ordinary Responses uses empty `input`, while Responses Lite uses `additional_tools` plus the developer instructions. The first generated request then contributes only dynamic conversation input to the continuation. No explicit prompt-cache breakpoints are added. |
74
74
  | Mid-turn compaction | Provider-boundary percentage compaction preserves a successful `end_turn:false` prefix as its own Pi assistant message, installs a checkpoint, and continues without synthetic model input. Pi threshold compaction normally runs after the agent response; after Codex output-token truncation, the extension queues a hidden continuation so threshold compaction completes before sampling resumes. Official Codex owns this sampling and compaction loop directly. |
@@ -115,19 +115,19 @@ The package implements the Codex-specific pieces that fit a provider compatibili
115
115
 
116
116
  The following official Codex facilities are not exact equivalents in this package:
117
117
 
118
- | Official Codex facility | Pi/package behavior |
119
- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
- | `view_image` | Pi `read` already accepts images; no canonical `view_image` alias is registered. |
121
- | `update_plan`, `request_user_input`, permissions, and environment tools | Not implemented by this package. |
122
- | `send_user_message_async` | Not implemented; Pi owns visible assistant output and user-message history, while this package does not own Codex's asynchronous message-injection lifecycle. |
123
- | Context-window and clock tools | Not implemented. Compaction remains host/provider managed rather than model managed. |
124
- | MCP resources and dynamic MCP tools | Pi does not provide this package with Codex's MCP runtime. |
125
- | Plugin/connector installation | Not implemented; package installation remains an explicit Pi/user operation. |
126
- | Searchable `tool_search` catalog | Pi can replay additive tool-search history for capable models, but this package does not implement Codex's searchable deferred-tool catalog and ranking runtime. |
127
- | Multi-agent V1/V2 coordination | Out of scope; no Codex agent tree, mailbox, task-path, or fork-depth runtime is implemented. |
128
- | JavaScript Code Mode and yielded cells | Out of scope; no V8 isolate, nested tool namespace, cell storage, or `wait` lifecycle is implemented. |
129
- | Goals, memories, and remote skill-resource tools | Not implemented; Pi's sessions, files, and native skills remain separate systems. |
130
- | Remote/deferred execution environments | Not implemented. |
118
+ | Official Codex facility | Pi/package behavior |
119
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `view_image` | Pi `read` already accepts images; no canonical `view_image` alias is registered. |
121
+ | `update_plan`, `request_user_input`, permissions, and environment tools | Not implemented by this package. |
122
+ | `send_user_message_async` | Not implemented; Pi owns visible assistant output and user-message history, while this package does not own Codex's asynchronous message-injection lifecycle. |
123
+ | Context-window and clock tools | Not implemented. Compaction remains host/provider managed rather than model managed. |
124
+ | MCP resources and dynamic MCP tools | Pi does not provide this package with Codex's MCP runtime. |
125
+ | Plugin/connector installation | Not implemented; package installation remains an explicit Pi/user operation. |
126
+ | Searchable `tool_search` catalog | This package declares every tool at the top level and does not implement Codex's searchable deferred-tool catalog and ranking runtime. |
127
+ | Multi-agent V1/V2 coordination | Out of scope; no Codex agent tree, mailbox, task-path, or fork-depth runtime is implemented. |
128
+ | JavaScript Code Mode and yielded cells | Out of scope; no V8 isolate, nested tool namespace, cell storage, or `wait` lifecycle is implemented. |
129
+ | Goals, memories, and remote skill-resource tools | Not implemented; Pi's sessions, files, and native skills remain separate systems. |
130
+ | Remote/deferred execution environments | Not implemented. |
131
131
 
132
132
  See [`OFFICIAL_CODEX_CLI_TOOL_CATALOG.md`](OFFICIAL_CODEX_CLI_TOOL_CATALOG.md) for the complete researched Codex tool inventory, and [`CUSTOM_CODEX_PROVIDER_WEB_REFERENCES.md`](CUSTOM_CODEX_PROVIDER_WEB_REFERENCES.md) for the unimplemented citation/reference design.
133
133
 
@@ -587,7 +587,7 @@ mise run test:live:codex
587
587
  The task obtains the local Codex bearer token and runs the tests with
588
588
  `gpt-5.6-luna` at medium reasoning effort.
589
589
  It also packs the extension and loads that archive through the shipped Pi
590
- 0.86.0 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
590
+ 0.86.1 CLI. Ordinary Responses and Responses Lite tests exercise tool calls,
591
591
  prompt reload, native compaction, and persisted-session resume against Codex.
592
592
  Test credentials stay in memory and child-process environments. Test sessions
593
593
  and configuration are isolated from the user's agent directory.
@@ -606,7 +606,9 @@ search, and compaction continuation.
606
606
  2. The command builds the exact package locally and runs live CLI tests against that archive. It also runs the existing live SDK tests against checkout source. Both suites must pass before it records the archive's SHA-256 in an SSH-signed release commit, proves a clean rebuild is reproducible, and creates a lightweight tag. Missing credentials or failing live tests stop the release.
607
607
  3. Inspect the result, then push atomically with `git push --atomic origin main vX.Y.Z`.
608
608
  4. A read-only GitHub Actions job validates and packs the package. After approval in the tag-restricted `npm-publish` environment, a separate GitHub-owned job verifies the signature and signed digest before attesting and staging that exact archive through npm trusted publishing.
609
- 5. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
609
+ 5. A final job creates the immutable GitHub release for the tag from the same verified archive, its
610
+ checksum, and the version's `CHANGELOG.md` section (`Unreleased` for prereleases).
611
+ 6. Approve the staged package on npmjs.com, or with `npm stage approve <stage-id>`.
610
612
 
611
613
  Stable releases use `latest`; prereleases derive their npm dist-tag from the first prerelease identifier.
612
614
 
@@ -59,7 +59,6 @@ export function splitUnsampledUserInput(options: {
59
59
  allTools: options.allTools,
60
60
  grammarToolInputProperties: options.grammarToolInputProperties,
61
61
  imageDetail: options.imageDetail,
62
- anchorsToolAdditions: false,
63
62
  });
64
63
  if (encoded.length === 0 || encoded.length > options.history.length) return { kind: "unsafe" };
65
64
 
@@ -6,9 +6,9 @@ import {
6
6
  clampThinkingLevel,
7
7
  createAssistantMessageEventStream,
8
8
  getCurrentSystemPrompt,
9
+ getCurrentTools,
9
10
  getDeclaredTools,
10
11
  normalizeContext,
11
- resolveTranscriptTools,
12
12
  type Api,
13
13
  type AssistantMessage,
14
14
  type AssistantMessageEventStream,
@@ -495,7 +495,6 @@ export class CodexProviderRuntime {
495
495
  context: TranscriptContext,
496
496
  grammarToolInputProperties: GrammarToolInputProperties,
497
497
  sessionId: string | undefined,
498
- anchorsToolAdditions: boolean,
499
498
  ): ResponsesInputItem[] {
500
499
  const scope = sessionId ? this.scopes.get(sessionId) : undefined;
501
500
  const compat = responsesCompatibility(model.compat);
@@ -505,8 +504,8 @@ export class CodexProviderRuntime {
505
504
  includeSystemPrompt: false,
506
505
  includeSystemUpdates: false,
507
506
  supportsMidConvoSystemMessages: true,
508
- supportsAdditionalTools: anchorsToolAdditions && (compat.supportsAdditionalTools ?? false),
509
- supportsToolSearch: anchorsToolAdditions && (compat.supportsToolSearch ?? false),
507
+ supportsAdditionalTools: false,
508
+ supportsToolSearch: false,
510
509
  grammarToolInputProperties,
511
510
  toolOptions: {
512
511
  strict: false,
@@ -528,7 +527,6 @@ export class CodexProviderRuntime {
528
527
  allTools: this.pi.getAllTools(),
529
528
  grammarToolInputProperties,
530
529
  imageDetail: scope.config.imageDetail,
531
- anchorsToolAdditions,
532
530
  });
533
531
  }
534
532
 
@@ -543,39 +541,18 @@ export class CodexProviderRuntime {
543
541
  turnId,
544
542
  } = options;
545
543
  const compat = responsesCompatibility(model.compat);
546
- const scope = runtimeSessionId ? this.scopes.get(runtimeSessionId) : undefined;
547
- const threshold = scope?.config.autoCompactAtPercent;
548
- // Percentage compaction drops historical tool additions. Rebase declarations
549
- // before serialization so both the compaction and its continuation stay valid.
550
- const compactionPending =
551
- threshold !== undefined &&
552
- scope?.contextPercent !== null &&
553
- scope?.contextPercent !== undefined &&
554
- scope.contextPercent >= threshold;
555
- const toolPlacement = resolveTranscriptTools(
556
- context.messages,
557
- !compactionPending &&
558
- ((compat.supportsAdditionalTools ?? false) || (compat.supportsToolSearch ?? false)),
559
- );
560
- // A forced prompt can project all tools into a single leading system message.
561
- // Do not replay additions from the stored branch after that projection.
562
- const anchorsToolAdditions =
563
- toolPlacement.anchorsAdditions &&
564
- context.messages
565
- .slice(1)
566
- .some((message) => message.role === "system" && (message.toolsAdded?.length ?? 0) > 0);
544
+ // Every request declares the complete current tool set at the top level,
545
+ // as the official Codex client does. Replaying additions inside the input
546
+ // made the Codex backend unreliable about which tools exist, and a native
547
+ // checkpoint drops such history anyway. A mid-session tool change costs one
548
+ // cache miss.
549
+ const requestTools = getCurrentTools(context.messages);
567
550
  let body: JsonRecord = {
568
551
  model: model.id,
569
552
  store: false,
570
553
  stream: true,
571
554
  instructions: getCurrentSystemPrompt(context.messages) || "You are a helpful assistant.",
572
- input: this.wireHistory(
573
- model,
574
- context,
575
- grammarToolInputProperties,
576
- runtimeSessionId,
577
- anchorsToolAdditions,
578
- ),
555
+ input: this.wireHistory(model, context, grammarToolInputProperties, runtimeSessionId),
579
556
  text: { verbosity: requestOptions.textVerbosity ?? "low" },
580
557
  include: ["reasoning.encrypted_content"],
581
558
  tool_choice: requestOptions.toolChoice ?? "auto",
@@ -594,8 +571,8 @@ export class CodexProviderRuntime {
594
571
  ),
595
572
  });
596
573
  if (requestOptions.serviceTier !== undefined) body.service_tier = requestOptions.serviceTier;
597
- if (toolPlacement.requestTools.length > 0) {
598
- body.tools = convertResponsesTools(toolPlacement.requestTools, {
574
+ if (requestTools.length > 0) {
575
+ body.tools = convertResponsesTools(requestTools, {
599
576
  strict: false,
600
577
  supportsStrictMode: compat.supportsStrictMode ?? true,
601
578
  supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools ?? false,
@@ -153,7 +153,6 @@ type EncodeMessagesOptions = {
153
153
  allTools: readonly ToolInfo[];
154
154
  grammarToolInputProperties: GrammarToolInputProperties;
155
155
  imageDetail: ImageDetail;
156
- anchorsToolAdditions?: boolean;
157
156
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
158
157
  };
159
158
 
@@ -165,10 +164,16 @@ export type EncodeSessionEntriesOptions = {
165
164
  imageDetail?: ImageDetail;
166
165
  nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesOutputItem[]>;
167
166
  initialSystemMessage?: SystemMessage;
168
- anchorsToolAdditions?: boolean;
169
167
  };
170
168
 
171
- /** Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer. */
169
+ /**
170
+ * Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer.
171
+ *
172
+ * Tool declarations never enter the input: every request carries the complete
173
+ * current tool set at the top level, as the official Codex client does. Inline
174
+ * `additional_tools` items and synthetic tool-search pairs made the Codex
175
+ * backend unreliable about which tools exist, so neither is emitted.
176
+ */
172
177
  function encodeMessages(options: EncodeMessagesOptions): ResponsesInputItem[] {
173
178
  const { model, messages, grammarToolInputProperties, imageDetail, nativeAssistantItems } =
174
179
  options;
@@ -177,10 +182,8 @@ function encodeMessages(options: EncodeMessagesOptions): ResponsesInputItem[] {
177
182
  includeSystemPrompt: false,
178
183
  includeSystemUpdates: false,
179
184
  supportsMidConvoSystemMessages: true,
180
- supportsAdditionalTools:
181
- options.anchorsToolAdditions !== false && (compat.supportsAdditionalTools ?? false),
182
- supportsToolSearch:
183
- options.anchorsToolAdditions !== false && (compat.supportsToolSearch ?? false),
185
+ supportsAdditionalTools: false,
186
+ supportsToolSearch: false,
184
187
  grammarToolInputProperties,
185
188
  toolOptions: {
186
189
  strict: false,
@@ -221,9 +224,6 @@ export function encodeSessionEntries(options: EncodeSessionEntriesOptions): Resp
221
224
  grammarToolInputProperties,
222
225
  imageDetail,
223
226
  };
224
- if (options.anchorsToolAdditions !== undefined) {
225
- encodeOptions.anchorsToolAdditions = options.anchorsToolAdditions;
226
- }
227
227
  if (nativeAssistantItems) encodeOptions.nativeAssistantItems = nativeAssistantItems;
228
228
  return encodeMessages(encodeOptions);
229
229
  }
@@ -347,7 +347,6 @@ export function providerHistory(options: {
347
347
  grammarToolInputProperties?: GrammarToolInputProperties;
348
348
  imageDetail?: ImageDetail;
349
349
  recoverLatestOverflowPrefix?: boolean;
350
- anchorsToolAdditions?: boolean;
351
350
  }): ResponsesInputItem[] {
352
351
  const branch = [...options.branch];
353
352
  let recoveredPrefix: ResponsesInputItem[] = [];
@@ -397,9 +396,6 @@ export function providerHistory(options: {
397
396
  imageDetail: options.imageDetail ?? "auto",
398
397
  nativeAssistantItems,
399
398
  ...(initialSystemMessage ? { initialSystemMessage } : {}),
400
- ...(options.anchorsToolAdditions === undefined
401
- ? {}
402
- : { anchorsToolAdditions: options.anchorsToolAdditions }),
403
399
  }),
404
400
  ...recoveredPrefix,
405
401
  ];
@@ -414,9 +410,6 @@ export function providerHistory(options: {
414
410
  grammarToolInputProperties: options.grammarToolInputProperties ?? new Map(),
415
411
  imageDetail: options.imageDetail ?? "auto",
416
412
  nativeAssistantItems,
417
- ...(options.anchorsToolAdditions === undefined
418
- ? {}
419
- : { anchorsToolAdditions: options.anchorsToolAdditions }),
420
413
  }),
421
414
  ...recoveredPrefix,
422
415
  ];
@@ -1,9 +1,9 @@
1
1
  const TRANSCRIPT_APIS = [
2
2
  "normalizeContext",
3
3
  "getCurrentSystemPrompt",
4
+ "getCurrentTools",
4
5
  "getDeclaredTools",
5
6
  "getInitialSystemMessage",
6
- "resolveTranscriptTools",
7
7
  ] as const;
8
8
 
9
9
  /** Check the loaded host, not package metadata that PI_PACKAGE_DIR can override. */
@@ -249,9 +249,7 @@ export default function registerRemoteCompaction(
249
249
  grammarToolInputProperties,
250
250
  imageDetail: config.imageDetail,
251
251
  recoverLatestOverflowPrefix: event.reason === "overflow" && event.willRetry,
252
- anchorsToolAdditions: false,
253
252
  });
254
- // A cached turn template can contain only the initial declarations.
255
253
  // Compaction rebases history, so use the complete current tool set.
256
254
  const template: JsonRecord = { ...matching?.payload };
257
255
  template.tools =
@@ -2,7 +2,7 @@
2
2
 
3
3
  This directory intentionally contains only the Pi AI methods needed to serialize checkpoint and native replay history for OpenAI's Responses API. It does not contain Pi AI's complete source dependency graph.
4
4
 
5
- [`openai-responses-serialization.ts`](openai-responses-serialization.ts) adapts the relevant methods from `@earendil-works/pi-ai@0.86.0`. Its header lists the upstream source files. Keep the wire-equivalence tests in [`test/pi-ai-serialization.test.ts`](../../../../test/pi-ai-serialization.test.ts) passing when updating the Pi dependencies.
5
+ [`openai-responses-serialization.ts`](openai-responses-serialization.ts) adapts the relevant methods from `@earendil-works/pi-ai@0.86.0`. Its header lists the upstream source files. It also carries the strict JSON-schema conversion from `src/api/constrained-sampling.ts`; Pi's built-in tools request strict sampling, and Codex rejects `strict: true` without that subset. Keep the wire-equivalence tests in [`test/pi-ai-serialization.test.ts`](../../../../test/pi-ai-serialization.test.ts) passing when updating the Pi dependencies.
6
6
 
7
7
  Pi AI's transcript helpers replay system sections and tool declarations.
8
8
  `supportsAdditionalTools` and `supportsToolSearch` select the dynamic tool
@@ -138,13 +138,170 @@ function inferGrammarInputProperty(tool: Tool): string {
138
138
  return inputProperty;
139
139
  }
140
140
 
141
+ interface JsonSchemaObject {
142
+ [key: string]: unknown;
143
+ type?: unknown;
144
+ properties?: Record<string, JsonSchemaObject | undefined>;
145
+ required?: unknown;
146
+ }
147
+
148
+ export class UnsupportedStrictJsonSchemaError extends Error {}
149
+
150
+ const UNSUPPORTED_STRICT_SCHEMA_KEYS = [
151
+ "$ref",
152
+ "$defs",
153
+ "definitions",
154
+ "allOf",
155
+ "oneOf",
156
+ "patternProperties",
157
+ "dependentSchemas",
158
+ "dependencies",
159
+ "unevaluatedProperties",
160
+ "propertyNames",
161
+ "contains",
162
+ "prefixItems",
163
+ "not",
164
+ "if",
165
+ "then",
166
+ "else",
167
+ ] as const;
168
+
169
+ function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
170
+ return typeof value === "object" && value !== null && !Array.isArray(value);
171
+ }
172
+
173
+ function isStructuredSchema(schema: unknown): boolean {
174
+ if (!isJsonSchemaObject(schema)) return false;
175
+ const types =
176
+ typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : [];
177
+ return (
178
+ types.includes("object") ||
179
+ types.includes("array") ||
180
+ schema.properties !== undefined ||
181
+ schema["items"] !== undefined
182
+ );
183
+ }
184
+
185
+ function schemaAllowsNull(schema: unknown): boolean {
186
+ if (!isJsonSchemaObject(schema)) return false;
187
+ if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) {
188
+ return true;
189
+ }
190
+ if (
191
+ schema["const"] === null ||
192
+ (Array.isArray(schema["enum"]) && schema["enum"].includes(null))
193
+ ) {
194
+ return true;
195
+ }
196
+ return (
197
+ Array.isArray(schema["anyOf"]) &&
198
+ schema["anyOf"].some((variant: unknown) => schemaAllowsNull(variant))
199
+ );
200
+ }
201
+
202
+ function makeJsonSchemaNodeStrict(schema: unknown): void {
203
+ if (!isJsonSchemaObject(schema)) {
204
+ throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
205
+ }
206
+ for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) {
207
+ if (schema[key] !== undefined) {
208
+ throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
209
+ }
210
+ }
211
+
212
+ const anyOf: unknown = schema["anyOf"];
213
+ if (anyOf !== undefined) {
214
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
215
+ throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
216
+ }
217
+ for (const variant of anyOf) {
218
+ if (isStructuredSchema(variant)) {
219
+ throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
220
+ }
221
+ makeJsonSchemaNodeStrict(variant);
222
+ }
223
+ }
224
+
225
+ const items: unknown = schema["items"];
226
+ if (items !== undefined) {
227
+ if (Array.isArray(items)) {
228
+ throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
229
+ }
230
+ makeJsonSchemaNodeStrict(items);
231
+ }
232
+
233
+ const isObjectSchema = schema.type === "object";
234
+ if (schema.properties !== undefined && !isObjectSchema) {
235
+ throw new UnsupportedStrictJsonSchemaError("properties require type object");
236
+ }
237
+ if (!isObjectSchema) return;
238
+ if (schema["additionalProperties"] !== undefined && schema["additionalProperties"] !== false) {
239
+ throw new UnsupportedStrictJsonSchemaError(
240
+ "schema-valued or true additionalProperties is unsupported",
241
+ );
242
+ }
243
+ if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) {
244
+ throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map");
245
+ }
246
+ if (
247
+ schema.required !== undefined &&
248
+ (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))
249
+ ) {
250
+ throw new UnsupportedStrictJsonSchemaError("object required must be a string array");
251
+ }
252
+
253
+ const properties = schema.properties ?? {};
254
+ const propertyNames = Object.keys(properties);
255
+ const required = new Set<unknown>(Array.isArray(schema.required) ? schema.required : []);
256
+ if ([...required].some((key) => !isString(key) || !propertyNames.includes(key))) {
257
+ throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
258
+ }
259
+ for (const [key, property] of Object.entries(properties)) {
260
+ makeJsonSchemaNodeStrict(property);
261
+ if (!required.has(key) && !schemaAllowsNull(property)) {
262
+ properties[key] = { anyOf: [property, { type: "null" }] };
263
+ }
264
+ }
265
+ schema.required = propertyNames;
266
+ schema["additionalProperties"] = false;
267
+ }
268
+
269
+ /** Convert a tool schema to the strict subset expected by provider constrained sampling. */
270
+ export function makeStrictJsonSchema(schema: Tool["parameters"]): Record<string, unknown> {
271
+ const cloned: unknown = structuredClone(schema);
272
+ if (!isJsonSchemaObject(cloned)) {
273
+ throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
274
+ }
275
+ makeJsonSchemaNodeStrict(cloned);
276
+ if (cloned.type !== "object") {
277
+ throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
278
+ }
279
+ return cloned;
280
+ }
281
+
282
+ function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): unknown {
283
+ return strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters;
284
+ }
285
+
141
286
  function resolveJsonSchemaStrictSampling(
142
287
  tool: Tool,
143
288
  supportsStrictMode: boolean,
144
289
  ): boolean | undefined {
145
290
  const config = tool.constrainedSampling;
146
291
  if (!config || config.type !== "json_schema") return undefined;
147
- if (supportsStrictMode) return true;
292
+ if (supportsStrictMode) {
293
+ try {
294
+ makeStrictJsonSchema(tool.parameters);
295
+ return true;
296
+ } catch (error) {
297
+ if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error;
298
+ if (config.strict !== "require") return undefined;
299
+ throw new Error(
300
+ `Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`,
301
+ { cause: error },
302
+ );
303
+ }
304
+ }
148
305
  if (config.strict === "require") {
149
306
  throw new Error(
150
307
  `Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`,
@@ -226,17 +383,21 @@ export function convertResponsesTools(
226
383
  }
227
384
 
228
385
  const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
229
- if (!isJsonValue(tool.parameters)) {
386
+ const strict = constrainedStrict ?? defaultStrict;
387
+ // Strict function tools must send the strict schema subset: every property required,
388
+ // optional properties nullable, and `additionalProperties: false` on each object.
389
+ const parameters = getJsonSchemaToolParameters(tool, strict === true);
390
+ if (!isJsonValue(parameters)) {
230
391
  throw new Error(`Tool "${tool.name}" has non-JSON parameters.`);
231
392
  }
232
393
  const converted: ResponsesItem = {
233
394
  type: "function",
234
395
  name: tool.name,
235
396
  description: tool.description,
236
- parameters: tool.parameters,
397
+ parameters,
237
398
  };
238
399
  if (options?.deferLoading) converted["defer_loading"] = true;
239
- if (supportsStrictMode) converted["strict"] = constrainedStrict ?? defaultStrict;
400
+ if (supportsStrictMode) converted["strict"] = strict;
240
401
  return converted;
241
402
  };
242
403
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-codex-compat",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "OpenAI Codex compatibility for Pi with native compaction, fast mode, and Codex-optimized capabilities",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -51,9 +51,9 @@
51
51
  "devDependencies": {
52
52
  "@2h2d/oxlint-config": "0.1.1",
53
53
  "@2h2d/ts-config": "0.0.1",
54
- "@earendil-works/pi-ai": "0.86.0",
55
- "@earendil-works/pi-coding-agent": "0.86.0",
56
- "@earendil-works/pi-tui": "0.86.0",
54
+ "@earendil-works/pi-ai": "0.86.1",
55
+ "@earendil-works/pi-coding-agent": "0.86.1",
56
+ "@earendil-works/pi-tui": "0.86.1",
57
57
  "@types/node": "22.20.1",
58
58
  "oxfmt": "0.64.0",
59
59
  "oxlint": "1.79.0",