@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/README.md +54 -16
- package/cached-summary.ts +230 -0
- package/dist/index.js +945 -234
- package/index.ts +228 -71
- package/package.json +7 -5
- package/policy.ts +9 -16
- package/protocol.ts +27 -14
- package/provider.ts +222 -40
- package/usage-observer.ts +52 -0
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
if (!handler) return options;
|
|
21
|
-
|
|
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) =>
|
|
35
|
-
const streamSimple: Provider["streamSimple"] = (model, context, options) =>
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
87
|
-
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|