pi-openai-toolkit 0.14.6 → 0.14.8

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.
@@ -0,0 +1,116 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Context property added by the toolkit's host patch. */
5
+ export const PI_CONTEXT_PROJECTOR_FIELD = "projectContextForCompaction" as const;
6
+ const PI_CONTEXT_PROJECTION_REPORTER_FIELD = "__piOpenAiToolkitReportContextProjectionFailure" as const;
7
+ const PATCH_MARKER_FIELD = "__piOpenAiToolkitContextHookPatch" as const;
8
+
9
+ type ProjectionState = {
10
+ failure?: string;
11
+ };
12
+
13
+ const activeProjectionByRunner = new WeakMap<ExtensionRunner, ProjectionState>();
14
+
15
+ type PatchedExtensionContext = ExtensionContext & {
16
+ [PI_CONTEXT_PROJECTOR_FIELD]?: PiContextHookProjector;
17
+ [PI_CONTEXT_PROJECTION_REPORTER_FIELD]?: (reason: string) => void;
18
+ };
19
+
20
+ type MarkedCreateContext = typeof ExtensionRunner.prototype.createContext & {
21
+ [PATCH_MARKER_FIELD]?: true;
22
+ };
23
+
24
+ /** The ordered context-hook chain, applied to a cloned compaction context. */
25
+ export type PiContextHookProjector = (
26
+ messages: readonly AgentMessage[],
27
+ ) => Promise<readonly AgentMessage[]>;
28
+
29
+ export type PiContextHookPatchResult =
30
+ | { ok: true; installed: boolean; reason?: "already-installed" }
31
+ | { ok: false; reason: "emit-context-unavailable" | "create-context-unavailable" };
32
+
33
+ /**
34
+ * Add a narrow projection method to every ExtensionContext created by Pi.
35
+ *
36
+ * Pi 0.85.1 already has the ordered `ExtensionRunner.emitContext()` method,
37
+ * but the event-level ExtensionContext does not expose the runner. The patch
38
+ * bridges only the method needed by Remote V2; it does not expose the runner
39
+ * or alter Pi's scheduler.
40
+ */
41
+ export function installPiContextHookPatch(): PiContextHookPatchResult {
42
+ const prototype = ExtensionRunner.prototype;
43
+ if (typeof prototype.emitContext !== "function") {
44
+ return { ok: false, reason: "emit-context-unavailable" };
45
+ }
46
+
47
+ const original = prototype.createContext;
48
+ if (typeof original !== "function") {
49
+ return { ok: false, reason: "create-context-unavailable" };
50
+ }
51
+
52
+ const markedOriginal = original as MarkedCreateContext;
53
+ if (markedOriginal[PATCH_MARKER_FIELD] === true) {
54
+ return { ok: true, installed: false, reason: "already-installed" };
55
+ }
56
+
57
+ const patched = function patchedCreateContext(this: ExtensionRunner): ExtensionContext {
58
+ const context = original.call(this) as PatchedExtensionContext;
59
+ if (!(PI_CONTEXT_PROJECTOR_FIELD in context)) {
60
+ Object.defineProperty(context, PI_CONTEXT_PROJECTOR_FIELD, {
61
+ configurable: false,
62
+ enumerable: false,
63
+ writable: false,
64
+ value: async (messages: readonly AgentMessage[]) => {
65
+ const state: ProjectionState = {};
66
+ activeProjectionByRunner.set(this, state);
67
+ try {
68
+ const projected = await this.emitContext([...messages]);
69
+ if (state.failure) {
70
+ throw new Error(`context projection failed: ${state.failure}`);
71
+ }
72
+ return projected;
73
+ } finally {
74
+ if (activeProjectionByRunner.get(this) === state) {
75
+ activeProjectionByRunner.delete(this);
76
+ }
77
+ }
78
+ },
79
+ });
80
+ }
81
+ if (!(PI_CONTEXT_PROJECTION_REPORTER_FIELD in context)) {
82
+ Object.defineProperty(context, PI_CONTEXT_PROJECTION_REPORTER_FIELD, {
83
+ configurable: false,
84
+ enumerable: false,
85
+ writable: false,
86
+ value: (reason: string) => {
87
+ const state = activeProjectionByRunner.get(this);
88
+ if (state) state.failure = reason;
89
+ },
90
+ });
91
+ }
92
+ return context;
93
+ };
94
+
95
+ Object.defineProperty(patched, PATCH_MARKER_FIELD, {
96
+ configurable: false,
97
+ enumerable: false,
98
+ writable: false,
99
+ value: true,
100
+ });
101
+ prototype.createContext = patched;
102
+ return { ok: true, installed: true };
103
+ }
104
+
105
+ /** Return the patched projection method, if this Pi process supports it. */
106
+ export function getPiContextHookProjector(
107
+ ctx: ExtensionContext,
108
+ ): PiContextHookProjector | undefined {
109
+ const candidate = (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTOR_FIELD];
110
+ return typeof candidate === "function" ? candidate : undefined;
111
+ }
112
+
113
+ /** Preserve fail-closed context-hook errors across Pi's emitContext boundary. */
114
+ export function reportPiContextHookFailure(ctx: ExtensionContext, reason: string): void {
115
+ (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTION_REPORTER_FIELD]?.(reason);
116
+ }
@@ -30,12 +30,23 @@ type CachedRequestContext = {
30
30
  extras: CompactionRequestExtras;
31
31
  };
32
32
 
33
+ export type RequestContextCaptureOptions = {
34
+ /** Standalone Web Search is a local tool and must not enter synthetic compact requests. */
35
+ excludeWebSearchTools?: boolean;
36
+ };
37
+
33
38
  let cached: CachedRequestContext | undefined;
34
39
 
35
40
  function isRecord(value: unknown): value is Record<string, unknown> {
36
41
  return !!value && typeof value === "object" && !Array.isArray(value);
37
42
  }
38
43
 
44
+ function isWebSearchTool(value: unknown): boolean {
45
+ if (!isRecord(value)) return false;
46
+ if (value.type === "web_search" || value.type === "web_search_preview") return true;
47
+ return value.type === "function" && (value.name === "web_search" || value.name === "web.run");
48
+ }
49
+
39
50
  function sameIdentity(cachedIdentity: RequestContextIdentity, current: RequestContextIdentity): boolean {
40
51
  return (
41
52
  cachedIdentity.provider === current.provider &&
@@ -54,6 +65,7 @@ function sameIdentity(cachedIdentity: RequestContextIdentity, current: RequestCo
54
65
  export function rememberRequestContext(
55
66
  payload: ResponsesCompatibleRequestPayload,
56
67
  identity: RequestContextIdentity,
68
+ options: RequestContextCaptureOptions = {},
57
69
  ): void {
58
70
  try {
59
71
  if (payload.model !== identity.model) {
@@ -63,7 +75,10 @@ export function rememberRequestContext(
63
75
 
64
76
  const extras: CompactionRequestExtras = {};
65
77
  if (Array.isArray(payload.tools)) {
66
- extras.tools = structuredClone(payload.tools);
78
+ const tools = options.excludeWebSearchTools
79
+ ? payload.tools.filter((tool) => !isWebSearchTool(tool))
80
+ : payload.tools;
81
+ extras.tools = structuredClone(tools);
67
82
  }
68
83
  if (typeof payload.parallel_tool_calls === "boolean") {
69
84
  extras.parallel_tool_calls = payload.parallel_tool_calls;
package/src/runtime.ts CHANGED
@@ -11,6 +11,7 @@ const OPENAI_RESPONSES_PATH = "responses";
11
11
  const CODEX_RESPONSES_PATH = "codex/responses";
12
12
  const OPENAI_COMPACT_PATH = "responses/compact";
13
13
  const CODEX_COMPACT_PATH = "codex/responses/compact";
14
+ const ALPHA_SEARCH_PATH = "alpha/search";
14
15
 
15
16
  export type ResponsesApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
16
17
 
@@ -215,6 +216,57 @@ export function buildCompactPath(api: ResponsesCompactApi): string {
215
216
  return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
216
217
  }
217
218
 
219
+ /**
220
+ * Build the provider-relative standalone-search endpoint. This deliberately
221
+ * rejects a full Responses endpoint so an already-resolved URL cannot become
222
+ * `/responses/alpha/search` or `/codex/responses/alpha/search` by accident.
223
+ */
224
+ export function buildAlphaSearchUrl(baseUrl: string): string | undefined {
225
+ const normalized = normalizeBaseUrl(baseUrl);
226
+ if (!normalized) return undefined;
227
+
228
+ let parsed: URL;
229
+ try {
230
+ parsed = new URL(normalized);
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ if (
235
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
236
+ parsed.username ||
237
+ parsed.password ||
238
+ parsed.search ||
239
+ parsed.hash
240
+ ) {
241
+ return undefined;
242
+ }
243
+
244
+ const pathname = parsed.pathname.replace(/\/+$/, "");
245
+ if (
246
+ pathname.endsWith("/responses/alpha/search") ||
247
+ pathname.endsWith("/codex/responses/alpha/search") ||
248
+ pathname.endsWith("/responses/compact/alpha/search") ||
249
+ pathname.endsWith("/codex/responses/compact/alpha/search")
250
+ ) {
251
+ return undefined;
252
+ }
253
+ if (pathname.endsWith("/alpha/search")) {
254
+ parsed.pathname = pathname;
255
+ return parsed.toString();
256
+ }
257
+ if (
258
+ pathname.endsWith("/responses") ||
259
+ pathname.endsWith("/codex/responses") ||
260
+ pathname.endsWith("/responses/compact") ||
261
+ pathname.endsWith("/codex/responses/compact")
262
+ ) {
263
+ return undefined;
264
+ }
265
+
266
+ parsed.pathname = `${pathname}/${ALPHA_SEARCH_PATH}`.replace(/^\/\//, "/");
267
+ return parsed.toString();
268
+ }
269
+
218
270
  async function resolveRequestAuth(ctx: ExtensionContext, model: RuntimeModel): Promise<ResolvedRequestAuth> {
219
271
  const modelRegistry = ctx.modelRegistry as {
220
272
  getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<ResolvedRequestAuth>;
package/src/types.ts CHANGED
@@ -22,6 +22,17 @@ export const ASTRA_MODEL_ID = "gpt-6-astra";
22
22
  export const LEGACY_NATIVE_COMPACTION_STRATEGY = "openai-native-compact-v1";
23
23
  export const REMOTE_V2_COMPACTION_STRATEGY = "openai-remote-compaction-v2";
24
24
  export const NATIVE_COMPACTION_STRATEGY = REMOTE_V2_COMPACTION_STRATEGY;
25
+ /**
26
+ * Provenance recorded when the checkpoint input came from Pi's ordered context
27
+ * hook projection. Older persisted entries may omit it and must not be
28
+ * replayed by Remote V2.
29
+ */
30
+ export const NATIVE_COMPACTION_INPUT_PROVENANCE = "pi-context-hook-v1" as const;
31
+ /** Explicit opt-in marker for the pre-projection raw session/branch chain. */
32
+ export const LEGACY_REMOTE_V2_INPUT_PROVENANCE = "legacy-raw-context-v1" as const;
33
+ export type NativeCompactionInputProvenance =
34
+ | typeof NATIVE_COMPACTION_INPUT_PROVENANCE
35
+ | typeof LEGACY_REMOTE_V2_INPUT_PROVENANCE;
25
36
  /**
26
37
  * Pi currently requires CompactionResult.summary to be text. This marker is only a
27
38
  * replay shim; the provider receives the opaque item from details.compactedWindow.
@@ -46,6 +57,9 @@ export type DebugArtifactKind =
46
57
 
47
58
  export type ContextManagementMode = "off" | "remote";
48
59
 
60
+ /** How Remote V2 obtains the history it sends to the compaction endpoint. */
61
+ export type RemoteV2ContextSource = "pi-context-hook" | "legacy";
62
+
49
63
  /** Native-method fallback compaction: which model runs pi's compact() and how deeply. */
50
64
  export type NativeFallbackConfig = {
51
65
  enabled: boolean;
@@ -74,6 +88,11 @@ export type CompactionConfig = {
74
88
  * remains the checkpoint consumer and continues handling normal requests.
75
89
  */
76
90
  remoteCompactModel?: string;
91
+ /**
92
+ * Remote V2 input source. Pi's patched context hook preserves the normal
93
+ * provider-visible projection; legacy keeps the pre-patch raw session/branch chain.
94
+ */
95
+ remoteV2ContextSource: RemoteV2ContextSource;
77
96
  /** Native-method fallback compaction policy (non-Responses APIs, or when the compact endpoint fails). */
78
97
  nativeFallback: NativeFallbackConfig;
79
98
  /**
@@ -99,10 +118,16 @@ export type CompactionConfig = {
99
118
  artifactRoot: string;
100
119
  };
101
120
 
121
+ export type WebSearchRoute = "local" | "hosted" | "standalone-alpha";
122
+
102
123
  export type WebSearchConfig = {
103
124
  enabled: boolean;
104
125
  /** Exact provider/model keys allowed to use toolkit-native Web Search. */
105
126
  models: string[];
127
+ /** Explicit route for every model not matched by `routes`. */
128
+ defaultRoute?: WebSearchRoute;
129
+ /** Exact `provider/model-id` overrides; no globs or fuzzy matching. */
130
+ routes?: Record<string, WebSearchRoute>;
106
131
  };
107
132
 
108
133
  export type ImageGenerationConfig = {
@@ -252,6 +277,12 @@ export type DeferredToolCarryoverV1 = {
252
277
 
253
278
  export type NativeCompactionDetails = NativeCompactionIdentity & {
254
279
  strategy: NativeCompactionStrategy;
280
+ /**
281
+ * Input source recorded for checkpoints created by this extension. Older
282
+ * persisted entries may omit it and must not be treated as verified for
283
+ * Remote V2 replay.
284
+ */
285
+ inputProvenance?: NativeCompactionInputProvenance;
255
286
  /** Actual producer of the opaque checkpoint; absent on legacy same-model entries. */
256
287
  compactionModel?: NativeCompactionIdentity;
257
288
  /** Cache-stack activation state captured at this opaque checkpoint. */
@@ -265,6 +296,7 @@ export type NativeCompactionDetails = NativeCompactionIdentity & {
265
296
  export type NativeCompactionEntry = CompactionEntry<NativeCompactionDetails>;
266
297
 
267
298
  export type CreateNativeCompactionDetailsInput = NativeCompactionIdentity & {
299
+ inputProvenance: NativeCompactionInputProvenance;
268
300
  compactionModel?: NativeCompactionIdentity;
269
301
  deferredToolCarryover?: DeferredToolCarryoverV1;
270
302
  compactedWindow: unknown[];
@@ -380,6 +412,16 @@ export function isNativeCompactionIdentity(value: unknown): value is NativeCompa
380
412
  );
381
413
  }
382
414
 
415
+ export function isNativeCompactionInputProvenance(value: unknown): value is NativeCompactionInputProvenance {
416
+ return value === NATIVE_COMPACTION_INPUT_PROVENANCE || value === LEGACY_REMOTE_V2_INPUT_PROVENANCE;
417
+ }
418
+
419
+ export function getRemoteV2InputProvenance(source: RemoteV2ContextSource): NativeCompactionInputProvenance {
420
+ return source === "pi-context-hook"
421
+ ? NATIVE_COMPACTION_INPUT_PROVENANCE
422
+ : LEGACY_REMOTE_V2_INPUT_PROVENANCE;
423
+ }
424
+
383
425
  export function isDeferredToolCarryover(value: unknown): value is DeferredToolCarryoverV1 {
384
426
  if (!isRecord(value) || value.version !== 1 || value.source !== CACHE_STACK_ACTIVATION_ENTRY_TYPE) {
385
427
  return false;
@@ -447,6 +489,7 @@ export function isNativeCompactionDetails(value: unknown): value is NativeCompac
447
489
  return (
448
490
  (candidate.strategy === LEGACY_NATIVE_COMPACTION_STRATEGY ||
449
491
  candidate.strategy === REMOTE_V2_COMPACTION_STRATEGY) &&
492
+ (candidate.inputProvenance === undefined || isNativeCompactionInputProvenance(candidate.inputProvenance)) &&
450
493
  (candidate.compactionModel === undefined || isNativeCompactionIdentity(candidate.compactionModel)) &&
451
494
  (candidate.deferredToolCarryover === undefined || isDeferredToolCarryover(candidate.deferredToolCarryover)) &&
452
495
  Array.isArray(candidate.compactedWindow) &&
@@ -464,6 +507,7 @@ export function isNativeCompactionEntry(value: unknown): value is NativeCompacti
464
507
  export function createNativeCompactionDetails(input: CreateNativeCompactionDetailsInput): NativeCompactionDetails {
465
508
  return {
466
509
  strategy: NATIVE_COMPACTION_STRATEGY,
510
+ inputProvenance: input.inputProvenance,
467
511
  provider: normalizeString(input.provider),
468
512
  api: normalizeString(input.api),
469
513
  model: normalizeString(input.model),
@@ -516,6 +560,8 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
516
560
  contextManagement: "off",
517
561
  allowCompactionContinuityBreak: false,
518
562
  remoteCompactModel: undefined,
563
+ remoteV2ContextSource: "legacy",
564
+
519
565
  nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
520
566
  responsesApis: [...RESPONSES_COMPACT_CAPABLE_APIS],
521
567
  gatewayContextModels: [],