@nijaru/pi-provider-compaction 0.0.8 → 0.0.10

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/protocol.ts CHANGED
@@ -208,7 +208,7 @@ async function collectProviderUsage(stream: AssistantMessageEventStream, signal:
208
208
  let usage: Usage | undefined;
209
209
  for await (const event of stream) {
210
210
  if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
211
- if (event.type === "error") throw new Error(event.error.errorMessage ?? "Native compaction provider request failed");
211
+ if (event.type === "error") throw new NativeCompactionValidationError("Native compaction provider stream failed", event.error.usage);
212
212
  if (event.type === "done") usage = event.message.usage;
213
213
  }
214
214
  if (!usage) throw new Error("Native compaction provider stream ended without usage");
@@ -227,10 +227,17 @@ interface TransportRequest {
227
227
  env?: Record<string, string>;
228
228
  /** Cost-relevant request option captured with the original request. */
229
229
  serviceTier?: string;
230
+ sessionId?: string;
230
231
  signal: AbortSignal;
232
+ /** Revalidate request ownership after asynchronous provider preparation. */
233
+ isCurrent?: () => boolean;
231
234
  fetch?: typeof globalThis.fetch;
232
235
  }
233
236
 
237
+ function assertCurrent(request: TransportRequest): void {
238
+ if (request.signal.aborted || request.isCurrent?.() === false) throw new DOMException("Compaction invalidated", "AbortError");
239
+ }
240
+
234
241
  /** The provider answered and was billed, but its window cannot be committed. */
235
242
  export class NativeCompactionValidationError extends Error {
236
243
  constructor(message: string, readonly usage?: Usage) {
@@ -246,12 +253,12 @@ export interface TransportResponse {
246
253
 
247
254
  async function requestResponsesCompact(request: TransportRequest): Promise<TransportResponse> {
248
255
  let preparedPayload: JsonObject | undefined;
249
- let compacted: { response: JsonObject; output: ResponseItem[] } | undefined;
256
+ let compacted: ResponseItem[] | undefined;
250
257
  let bridgeError: unknown;
251
258
  let dispatches = 0;
252
259
  const baseFetch = request.fetch ?? globalThis.fetch;
253
260
  const bridgeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
254
- if (request.signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
261
+ assertCurrent(request);
255
262
  dispatches += 1;
256
263
  if (dispatches !== 1 || !preparedPayload) {
257
264
  bridgeError = new Error("Responses Compact provider dispatched an unexpected additional request");
@@ -268,13 +275,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
268
275
  });
269
276
  if (!response.ok) return response;
270
277
  const parsed = await readJsonBounded(response, request.signal);
271
- try {
272
- if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
273
- compacted = { response: parsed, output: validateCompactedOutput(parsed) };
274
- } catch (error) {
275
- throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
276
- }
277
- return syntheticCompletion(parsed, preparedPayload);
278
+ if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
279
+ // Let the SDK normalize and price reported usage even when the returned
280
+ // checkpoint is invalid. A synthetic HTTP error would discard that bill.
281
+ const completion = syntheticCompletion(parsed, preparedPayload);
282
+ try { compacted = validateCompactedOutput(parsed); }
283
+ catch (error) { bridgeError = new NativeCompactionValidationError(error instanceof Error ? error.message : String(error)); }
284
+ return completion;
278
285
  } catch (error) {
279
286
  bridgeError = error;
280
287
  return errorResponse(error);
@@ -290,8 +297,10 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
290
297
  transport: "sse",
291
298
  cacheRetention: "none",
292
299
  maxRetries: 0,
300
+ sessionId: request.sessionId,
293
301
  fetch: bridgeFetch,
294
302
  onPayload: (payload: unknown) => {
303
+ assertCurrent(request);
295
304
  if (!isObject(payload)) throw new Error("Responses provider exposed a non-object payload");
296
305
  if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
297
306
  preparedPayload = { ...structuredClone(request.preparedPayload ?? payload), input: structuredClone(request.input) };
@@ -300,13 +309,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
300
309
  });
301
310
 
302
311
  const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
303
- if (bridgeError) throw bridgeError;
312
+ if (bridgeError) throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
304
313
  throw error;
305
314
  });
306
315
  if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
307
316
  if (bridgeError) throw bridgeError;
308
317
  if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
309
- return { output: compacted.output, usage };
318
+ return { output: compacted, usage };
310
319
  }
311
320
 
312
321
  interface CollectedSse {
@@ -408,6 +417,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
408
417
  const signal = AbortSignal.any([request.signal, controller.signal]);
409
418
  const inspections: Promise<{ result: CollectedSse } | { error: unknown }>[] = [];
410
419
  const inspectedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
420
+ assertCurrent(request);
411
421
  const response = await baseFetch(input, init);
412
422
  if (!response.ok || !response.body) return response;
413
423
  const [providerBody, inspectionBody] = response.body.tee();
@@ -430,8 +440,10 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
430
440
  transport: "sse",
431
441
  cacheRetention: "none",
432
442
  maxRetries: 0,
443
+ sessionId: request.sessionId,
433
444
  fetch: inspectedFetch,
434
445
  onPayload: (payload: unknown) => {
446
+ assertCurrent(request);
435
447
  if (!isObject(payload)) throw new Error("Codex provider exposed a non-object payload");
436
448
  sentInput = structuredClone(request.input);
437
449
  if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
@@ -440,9 +452,9 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
440
452
  });
441
453
  try {
442
454
  const usage = await collectProviderUsage(stream, request.signal);
443
- if (!sentInput || inspections.length !== 1) throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
455
+ if (!sentInput || inspections.length !== 1) throw new NativeCompactionValidationError(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`, usage);
444
456
  const inspection = await inspections[0]!;
445
- if ("error" in inspection) throw inspection.error;
457
+ if ("error" in inspection) throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
446
458
  return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
447
459
  } finally {
448
460
  controller.abort();
@@ -452,6 +464,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
452
464
  export async function requestProviderCompaction(
453
465
  request: TransportRequest & { protocol: NativeProtocol },
454
466
  ): Promise<TransportResponse> {
467
+ assertCurrent(request);
455
468
  const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
456
469
  try {
457
470
  return { ...result, output: validateCompactedOutput(result) };
package/provider.ts CHANGED
@@ -1,10 +1,27 @@
1
- import type { Provider, StreamOptions, Model, TranscriptContext } from "@earendil-works/pi-ai";
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type CacheRetention, type Provider, type StreamOptions, type Model, type TranscriptContext } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ interface UsageScope { sessionId?: string; }
4
+ const USAGE_OWNERS = Symbol.for("@nijaru/pi-provider-compaction/usage-owners");
5
+ const usageGlobal = globalThis as typeof globalThis & { [USAGE_OWNERS]?: WeakMap<object, string> };
6
+ const usageOwners = usageGlobal[USAGE_OWNERS] ??= new WeakMap<object, string>();
3
7
 
4
- export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => ((payload: unknown) => unknown | Promise<unknown>) | undefined;
8
+ /** Associate the SDK-owned header record without reading or changing any headers. */
9
+ export function bindUsageHeaders(headers: object, sessionId: string): void {
10
+ usageOwners.set(headers, sessionId);
11
+ }
12
+
13
+ export type PayloadObserver = ((payload: unknown) => unknown | Promise<unknown>) & {
14
+ complete?: (message: AssistantMessage) => void;
15
+ /** Adjust only usage before any completion observer or consumer sees it. */
16
+ priceUsage?: (message: AssistantMessage) => void;
17
+ cacheRetention?: CacheRetention;
18
+ };
19
+ export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions, scope: UsageScope) => PayloadObserver | undefined;
5
20
 
6
21
  export interface InstalledObserver {
7
22
  provider: string;
23
+ /** installObserver leases one dispatch API; lower-level leases can be unscoped. */
24
+ api?: string;
8
25
  isInstalled(): boolean;
9
26
  remove(): void;
10
27
  }
@@ -13,26 +30,148 @@ export interface InstalledObserver {
13
30
  // streams: this observer delegates unchanged and can release only its own slot.
14
31
  export const STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
15
32
 
16
- function optionsWithObserver<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest): T | undefined {
33
+ function observedStream<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest, dispatch: (options: T | undefined) => AssistantMessageEventStream): AssistantMessageEventStream {
34
+ const headerOwner = options?.headers ? usageOwners.get(options.headers) : undefined;
35
+ const scope: UsageScope = { sessionId: headerOwner ?? options?.sessionId };
17
36
  let handler: ReturnType<ObserveRequest>;
18
- try { handler = observe(model, context, options ?? {}); }
19
- catch { return options; } // An expired or failing optional observer must not veto dispatch.
20
- if (!handler) return options;
21
- return {
37
+ try { handler = observe(model, context, options ?? {}, scope); }
38
+ catch { return dispatch(options); } // An expired optional observer must not veto dispatch.
39
+ if (!handler) return dispatch(options);
40
+ const run = () => dispatch({
22
41
  ...options,
42
+ ...(handler.cacheRetention === undefined ? {} : { cacheRetention: handler.cacheRetention }),
23
43
  onPayload: async (payload: unknown, requestModel: Model<any>) => {
24
44
  const replacement = await options?.onPayload?.(payload, requestModel);
25
45
  const prepared = replacement === undefined ? payload : replacement;
26
46
  // Observation failures must not suppress or alter the portable request.
27
47
  try { return await handler(prepared); } catch { return prepared; }
28
48
  },
29
- } as T;
49
+ } as T);
50
+ const stream = run();
51
+ if (handler.priceUsage) return pricedStream(stream, model, handler);
52
+ // Pure capture remains transparent, including the original stream identity.
53
+ if (handler.complete) void stream.result().then(handler.complete).catch(() => {});
54
+ return stream;
55
+ }
56
+
57
+ /** Pricing must precede both terminal-event delivery and result() resolution. */
58
+ function pricedStream(stream: AssistantMessageEventStream, model: Model<any>, handler: PayloadObserver): AssistantMessageEventStream {
59
+ const out = createAssistantMessageEventStream();
60
+ void (async () => {
61
+ let final: AssistantMessage | undefined;
62
+ let partial: AssistantMessage | undefined;
63
+ try {
64
+ for await (const event of stream) {
65
+ if ("partial" in event) partial = event.partial;
66
+ if (event.type === "done" || event.type === "error") {
67
+ final = event.type === "done" ? event.message : event.error;
68
+ try { handler.priceUsage?.(final); } catch { /* Optional estimates never veto completion. */ }
69
+ try { handler.complete?.(final); } catch { /* Capture failure never vetoes completion. */ }
70
+ }
71
+ out.push(event);
72
+ }
73
+ if (final) { out.end(final); return; }
74
+ } catch { /* Malformed plugin streams still need a terminal outcome. */ }
75
+ const message: AssistantMessage = {
76
+ ...(partial ?? {
77
+ role: "assistant", api: model.api, provider: model.provider, model: model.id,
78
+ content: [], timestamp: Date.now(),
79
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
80
+ }),
81
+ stopReason: "error", errorMessage: "Provider stream ended before a terminal event",
82
+ };
83
+ try { handler.priceUsage?.(message); } catch { /* Preserve the known usage on pricing failure. */ }
84
+ out.push({ type: "error", reason: "error", error: message });
85
+ out.end(message);
86
+ })();
87
+ return out;
88
+ }
89
+
90
+ /** One dispatch layer, with session-local selection and no released lease history. */
91
+ function observerLeases(provider: string, owned: () => boolean, restore: () => void) {
92
+ const leases = new Map<symbol, { observe: ObserveRequest; current: () => boolean }>();
93
+ let closed = false;
94
+ const live = (token: symbol) => !closed && leases.has(token);
95
+ const active = (token: symbol) => live(token) && leases.get(token)?.current() === true && owned();
96
+ const release = () => {
97
+ if (closed) return;
98
+ closed = true;
99
+ leases.clear();
100
+ restore();
101
+ };
102
+ const observe: ObserveRequest = (...args) => {
103
+ const handlers: PayloadObserver[] = [];
104
+ for (const [token, lease] of leases) {
105
+ try {
106
+ if (!active(token)) continue;
107
+ const handler = lease.observe(...args);
108
+ if (!handler) continue;
109
+ handlers.push(Object.assign((payload: unknown) => active(token) ? handler(payload) : payload, {
110
+ cacheRetention: handler.cacheRetention,
111
+ // A catalog/API recomposition may detach a provider after dispatch.
112
+ // Its in-flight bill still uses the sampled policy; explicit lease
113
+ // removal/disposal, unlike recomposition, revokes that callback.
114
+ priceUsage: handler.priceUsage ? (message: AssistantMessage) => { if (live(token)) handler.priceUsage!(message); } : undefined,
115
+ complete: handler.complete ? (message: AssistantMessage) => { if (active(token)) handler.complete!(message); } : undefined,
116
+ }));
117
+ } catch { /* A failed optional observer must not disable the other sessions. */ }
118
+ }
119
+ if (!handlers.length) return;
120
+ return Object.assign(async (payload: unknown) => {
121
+ for (const handler of handlers) {
122
+ try {
123
+ const replacement = await handler(payload);
124
+ if (replacement !== undefined) payload = replacement;
125
+ } catch { /* Keep the last valid request on optional observation failure. */ }
126
+ }
127
+ return payload;
128
+ }, {
129
+ cacheRetention: [...handlers].reverse().find((handler) => handler.cacheRetention !== undefined)?.cacheRetention,
130
+ priceUsage: handlers.some((handler) => handler.priceUsage) ? (message: AssistantMessage) => {
131
+ for (const handler of handlers) {
132
+ try { handler.priceUsage?.(message); } catch { /* Isolate independent pricing owners. */ }
133
+ }
134
+ } : undefined,
135
+ complete: handlers.some((handler) => handler.complete) ? (message: AssistantMessage) => {
136
+ for (const handler of handlers) {
137
+ try { handler.complete?.(message); } catch { /* Isolate optional completion observers too. */ }
138
+ }
139
+ } : undefined,
140
+ });
141
+ };
142
+ return {
143
+ observe, release, isOpen: () => !closed,
144
+ add(observe: ObserveRequest, current: () => boolean = () => true): InstalledObserver {
145
+ const token = Symbol();
146
+ leases.set(token, { observe, current });
147
+ return { provider, isInstalled: () => active(token), remove() {
148
+ leases.delete(token);
149
+ if (!leases.size) release();
150
+ } };
151
+ },
152
+ };
30
153
  }
31
154
 
155
+ type ObserverLeases = ReturnType<typeof observerLeases>;
156
+ // Pi's uncached module loader can instantiate this extension separately for
157
+ // sessions sharing one model runtime. Weak ownership must span those instances.
158
+ const LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v2");
159
+ interface LeaseTables {
160
+ effectiveLeases: WeakMap<Provider, { owned(): boolean; leases: ObserverLeases }>;
161
+ effectiveRestores: WeakMap<Function, { base: Provider; restore(): void }>;
162
+ nativeLeases: WeakMap<Provider, ObserverLeases>;
163
+ legacyLeases: WeakMap<Provider["streamSimple"], ObserverLeases>;
164
+ }
165
+ const shared = globalThis as typeof globalThis & { [LEASE_TABLES]?: LeaseTables };
166
+ const { effectiveLeases, effectiveRestores, nativeLeases, legacyLeases } = shared[LEASE_TABLES] ??= {
167
+ effectiveLeases: new WeakMap(), effectiveRestores: new WeakMap(),
168
+ nativeLeases: new WeakMap(), legacyLeases: new WeakMap(),
169
+ };
170
+
32
171
  /** Preserve method receivers, getters, optional members and dynamic catalogs. */
33
172
  export function decorateProvider(base: Provider, observe: ObserveRequest): Provider {
34
- const stream: Provider["stream"] = (model, context, options) => base.stream(model, context, optionsWithObserver(model, context, options, observe));
35
- const streamSimple: Provider["streamSimple"] = (model, context, options) => base.streamSimple(model, context, optionsWithObserver(model, context, options, observe));
173
+ const stream: Provider["stream"] = (model, context, options) => observedStream(model, context, options, observe, (next) => base.stream(model, context, next));
174
+ const streamSimple: Provider["streamSimple"] = (model, context, options) => observedStream(model, context, options, observe, (next) => base.streamSimple(model, context, next));
36
175
  const bound = new Map<PropertyKey, unknown>();
37
176
  return new Proxy(base, {
38
177
  get(target, key) {
@@ -46,63 +185,106 @@ export function decorateProvider(base: Provider, observe: ObserveRequest): Provi
46
185
  });
47
186
  }
48
187
 
188
+ /** Observe a builtin/composer in place, preserving its auth, catalogs and dispatch closure. */
189
+ export function leaseEffectiveProvider(base: Provider | undefined, current: () => Provider | undefined, observe: ObserveRequest): InstalledObserver | undefined {
190
+ if (!base) return;
191
+ const existing = effectiveLeases.get(base);
192
+ if (existing?.leases.isOpen() && existing.owned()) return existing.leases.add(observe, () => current() === base);
193
+ // External replacement invalidated the old pair. Restore its still-owned
194
+ // method before capturing delegates, rather than burying a released wrapper.
195
+ existing?.leases.release();
196
+ // A third party may have restored saved method values after their release.
197
+ // They remain inert, but must not become delegates of another observation layer.
198
+ const keys = ["stream", "streamSimple"] as const;
199
+ for (const key of keys) {
200
+ const saved = effectiveRestores.get(base[key]);
201
+ if (saved?.base === base) saved.restore();
202
+ }
203
+ const descriptors = keys.map((key) => Object.getOwnPropertyDescriptor(base, key));
204
+ if (descriptors.some((descriptor) => descriptor && (!("value" in descriptor) || !descriptor.configurable && !descriptor.writable)) || !Object.isExtensible(base) && descriptors.some((descriptor) => !descriptor)) return;
205
+ const originalStream = base.stream, originalSimple = base.streamSimple;
206
+ const owned = () => base.stream === stream && base.streamSimple === streamSimple;
207
+ const restore = () => {
208
+ for (const [index, key] of keys.entries()) if (base[key] === (key === "stream" ? stream : streamSimple)) {
209
+ const descriptor = descriptors[index];
210
+ if (descriptor) Object.defineProperty(base, key, descriptor);
211
+ else Reflect.deleteProperty(base, key);
212
+ }
213
+ };
214
+ const leases = observerLeases(base.id, owned, restore);
215
+ const stream: Provider["stream"] = (model, context, options) => observedStream(model, context, options, leases.observe, (next) => originalStream.call(base, model, context, next));
216
+ const streamSimple: Provider["streamSimple"] = (model, context, options) => observedStream(model, context, options, leases.observe, (next) => originalSimple.call(base, model, context, next));
217
+ try {
218
+ for (const [index, key] of keys.entries()) Object.defineProperty(base, key, { ...descriptors[index], value: key === "stream" ? stream : streamSimple, ...(descriptors[index] ? {} : { configurable: true, writable: true, enumerable: true }) });
219
+ } catch { leases.release(); return; }
220
+ for (const method of [stream, streamSimple]) effectiveRestores.set(method, { base, restore });
221
+ effectiveLeases.set(base, { owned, leases });
222
+ return leases.add(observe, () => current() === base);
223
+ }
224
+
49
225
  /**
50
226
  * Pi 0.87 has replacement registration, not middleware. Native BASE providers can
51
227
  * be decorated directly. Legacy stream overlays retain their configuration and
52
228
  * delegate their original callback (including /fast). Never register an effective
53
229
  * composed provider as a native base: that loses model headers and freezes config.
54
230
  */
55
- export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): InstalledObserver | undefined {
231
+ export function installObserver(pi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, observe: ObserveRequest): InstalledObserver | undefined {
56
232
  const model = ctx.model;
57
233
  if (!model) return;
58
234
  const registry = ctx.modelRegistry;
59
235
  const id = model.provider;
236
+ const observeApi: ObserveRequest = (requestModel, ...args) => requestModel.api === model.api ? observe(requestModel, ...args) : undefined;
237
+ const tagged = (lease: InstalledObserver | undefined) => lease ? Object.assign(lease, { api: model.api }) : undefined;
238
+ const effective = () => {
239
+ const base = registry.getProvider(id);
240
+ if (!base?.getModels().some((entry) => entry.api === model.api)) return;
241
+ return tagged(leaseEffectiveProvider(base, () => registry.getProvider(id), observeApi));
242
+ };
60
243
  const native = registry.getRegisteredNativeProvider(id);
61
244
  if (native) {
62
245
  // Pi composes a native base only when its catalog serves the active API.
63
246
  // Otherwise the request goes to the global API provider and bypasses this
64
247
  // wrapper; claiming an observer there would report native compaction as
65
248
  // installed while never capturing anything.
66
- if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return;
67
- const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
68
- const guarded: ObserveRequest = (...args) => {
69
- if (!isInstalled()) return;
70
- const handler = observe(...args);
71
- return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
72
- };
73
- const wrapper = decorateProvider(native, guarded);
249
+ if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return effective();
250
+ const existing = nativeLeases.get(native);
251
+ if (existing) return tagged(existing.add(observeApi));
252
+ const leases = observerLeases(id, () => registry.getRegisteredNativeProvider(id) === wrapper, () => {
253
+ // Keep cleanup usable after bare SDK disposal invalidates the extension API.
254
+ if (registry.getRegisteredNativeProvider(id) === wrapper) registry.registerProvider(native);
255
+ nativeLeases.delete(wrapper);
256
+ });
257
+ const wrapper = decorateProvider(native, leases.observe);
258
+ nativeLeases.set(wrapper, leases);
74
259
  pi.registerProvider(wrapper);
75
- return {
76
- provider: id, isInstalled,
77
- remove() {
78
- // Keep cleanup usable after bare SDK disposal invalidates the extension API.
79
- if (isInstalled()) registry.registerProvider(native);
80
- },
81
- };
260
+ return tagged(leases.add(observeApi));
82
261
  }
83
262
  const original = registry.getRegisteredProviderConfig(id);
84
263
  // Refresh callbacks own mutable catalog state inside the composer. Replacing
85
264
  // that composer cannot promise preservation, so leave it alone.
86
- if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels) return;
87
- if (original.api !== model.api) return;
265
+ if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels || original.api !== model.api) {
266
+ // Lease the effective object's methods, NOT its registry slot. Re-registering
267
+ // a composed provider freezes catalogs and loses overlay/header semantics.
268
+ return effective();
269
+ }
88
270
  // An injected legacy streamSimple overlay would also intercept full stream()
89
271
  // calls and change reasoning semantics. Only decorate an existing stream owner.
272
+ const existing = legacyLeases.get(original.streamSimple);
273
+ if (existing) return tagged(existing.add(observeApi));
90
274
  const delegate = original.streamSimple.bind(original);
91
- const isInstalled = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
92
- const guarded: ObserveRequest = (...args) => {
93
- if (!isInstalled()) return;
94
- const handler = observe(...args);
95
- return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
96
- };
97
- const wrapper: Provider["streamSimple"] = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
98
- const remove = () => {
99
- if (!isInstalled()) return;
275
+ const owned = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
276
+ const leases = observerLeases(id, owned, () => {
277
+ legacyLeases.delete(wrapper);
278
+ if (!owned()) return;
100
279
  // registerProvider merges undefined properties: remove our layer before restore.
101
280
  const restored = { ...registry.getRegisteredProviderConfig(id)!, streamSimple: original.streamSimple };
102
281
  registry.unregisterProvider(id);
103
282
  registry.registerProvider(id, restored);
104
- };
105
- Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: remove } });
283
+ });
284
+ const wrapper: Provider["streamSimple"] = (requestModel, context, options) => observedStream(requestModel, context, options, leases.observe, (next) => delegate(requestModel, context, next));
285
+ // /fast owns the delegate: its cleanup must release all observing sessions.
286
+ Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: leases.release } });
287
+ legacyLeases.set(wrapper, leases);
106
288
  pi.registerProvider(id, { ...original, streamSimple: wrapper });
107
- return { provider: id, isInstalled, remove };
289
+ return tagged(leases.add(observeApi));
108
290
  }
@@ -0,0 +1,52 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { installObserver, type InstalledObserver } from "./provider";
4
+
5
+ const REQUEST = "pi-provider-compaction:observe-usage";
6
+ const READY = "pi-provider-compaction:usage-observer-ready";
7
+ type ModelKey = { provider: string; id: string };
8
+ type PriceUsage = (timestamp: number, usage: Usage) => void;
9
+ interface UsageRequest {
10
+ context: ExtensionContext;
11
+ model: NonNullable<ExtensionContext["model"]>;
12
+ priceFor(model: ModelKey): PriceUsage;
13
+ respond(observer: InstalledObserver | undefined): void;
14
+ handled?: boolean;
15
+ }
16
+
17
+ /** Share the existing stream owner, rather than stacking competing pricing wrappers. */
18
+ export function serveUsageObservers(pi: ExtensionAPI, current: () => ExtensionContext | undefined) {
19
+ const leases = new Set<InstalledObserver>();
20
+ const remove = pi.events.on(REQUEST, (value) => {
21
+ const request = value as Partial<UsageRequest> | undefined;
22
+ const owner = current();
23
+ if (!owner || !request || request.handled || !request.context || !request.model || typeof request.priceFor !== "function" || typeof request.respond !== "function") return;
24
+ try {
25
+ if (request.context.modelRegistry !== owner.modelRegistry || request.context.sessionManager.getSessionId() !== owner.sessionManager.getSessionId()) return;
26
+ request.handled = true;
27
+ const sessionId = owner.sessionManager.getSessionId();
28
+ const installed = installObserver(pi, { model: request.model, modelRegistry: owner.modelRegistry }, (model, _context, _options, scope) => {
29
+ // The SDK header event identifies the owner even when a branch
30
+ // summary has an independent routing id. Sample policy at dispatch.
31
+ const price = request.priceFor!({ provider: model.provider, id: model.id });
32
+ return Object.assign((payload: unknown) => payload, { priceUsage: (message: { timestamp: number; usage: Usage }) => {
33
+ if (scope.sessionId === sessionId) price(message.timestamp, message.usage);
34
+ } });
35
+ });
36
+ if (!installed) { request.respond(undefined); return; }
37
+ const lease: InstalledObserver = {
38
+ provider: installed.provider,
39
+ api: installed.api,
40
+ isInstalled: () => installed.isInstalled(),
41
+ remove() { leases.delete(lease); installed.remove(); },
42
+ };
43
+ leases.add(lease);
44
+ request.respond(lease);
45
+ } catch { /* Optional cost estimates must not veto a provider request. */ }
46
+ });
47
+ return {
48
+ ready(context: ExtensionContext) { pi.events.emit(READY, { context }); },
49
+ clear() { for (const lease of leases) lease.remove(); },
50
+ dispose() { remove(); for (const lease of leases) lease.remove(); },
51
+ };
52
+ }