@nijaru/pi-provider-compaction 0.0.9 → 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 +6 -4
- package/dist/index.js +211 -61
- package/index.ts +44 -21
- package/package.json +3 -2
- package/policy.ts +9 -16
- package/protocol.ts +16 -13
- package/provider.ts +81 -14
- package/usage-observer.ts +52 -0
package/README.md
CHANGED
|
@@ -14,7 +14,9 @@ Pi has replacement provider registration, not composable middleware. This extens
|
|
|
14
14
|
- **Existing static legacy `streamSimple` registrations:** the original callback and configuration are retained. This includes `pi-fast-mode`'s Codex registration.
|
|
15
15
|
- **Built-in and composed providers:** the existing provider object's full and simple streaming methods are leased separately, without re-registering the provider or rebuilding auth/catalog configuration. Each original method retains its receiver. Cleanup restores only still-owned methods; replacing the provider revokes observation until it is reacquired.
|
|
16
16
|
|
|
17
|
-
Sessions sharing a model runtime share observation dispatchers, not their captured histories. Releasing one session's lease leaves the others active; the final lease restores the original provider.
|
|
17
|
+
Sessions sharing a model runtime share observation dispatchers, not their captured histories. Leases are scoped to the provider/API dispatch path, including models.json APIs that bypass a native base. Releasing one session's lease leaves the others active; the final lease restores the original provider.
|
|
18
|
+
|
|
19
|
+
With the current `pi-peak-hours` companion, the same dispatcher applies request-scoped pricing before terminal usage reaches consumers or cache evidence. This covers foreground responses, warming, portable/native compaction and branch summaries without stacking competing provider wrappers. The SDK's header-record identity establishes ownership for summaries with independent routing IDs; no header content is read, copied or changed. Unknown ownership stays unadjusted. Prices remain estimates, not invoices.
|
|
18
20
|
|
|
19
21
|
For an eligible request, protocol selection is automatic:
|
|
20
22
|
|
|
@@ -63,7 +65,7 @@ Request-only suffix additions, deletions, redactions and reordering can survive
|
|
|
63
65
|
|
|
64
66
|
## Safety and persistence
|
|
65
67
|
|
|
66
|
-
Version 3 stores the portable summary, native output, usage, route identity and validated prefix metadata in one compaction entry. Both generation requests contribute to usage accounting. Raw credentials, complete auth headers and request snapshots are never persisted.
|
|
68
|
+
Version 3 stores the portable summary, native output, usage, route identity and validated prefix metadata in one compaction entry. Both generation requests contribute to usage accounting, including reported native usage when checkpoint validation fails. Raw credentials, complete auth headers and request snapshots are never persisted.
|
|
67
69
|
|
|
68
70
|
Native eligibility is deliberately **session-local and short-lived**. Restart, reload, SDK disposal, resume, fork, tree navigation, model changes, covered-history edits, and relevant prompt/tool/route changes revoke it. A credential refresh may conservatively revoke it too. Persisted native metadata alone is not authority to replay hidden content under a new runtime's privacy policy.
|
|
69
71
|
|
|
@@ -75,7 +77,7 @@ Legacy v1/v2 whole-history checkpoints remain inspectable but are never reinterp
|
|
|
75
77
|
|
|
76
78
|
## Diagnose portable fallback
|
|
77
79
|
|
|
78
|
-
Run `/provider-compaction-status` to see observer/capture eligibility, estimated cache lifetime, the last selected path, elapsed time, reported input/cache/output tokens, and
|
|
80
|
+
Run `/provider-compaction-status` to see observer/capture eligibility, estimated cache lifetime, the last selected path, elapsed time, reported input/cache/output tokens, and estimated cost (including available companion corrections). Optional outcome notifications show measured usage after a committed compaction; hypothetical savings are not presented as measured. Failed/cancelled expiry attempts remain visible in runtime status; if no checkpoint is saved, their usage cannot be attached to compaction totals through Pi's extension API. Portable fallback also appends a `pi-provider-compaction:diagnostic` session entry containing only a fixed reason string. It is not included in model context. Diagnostics never include request content, credentials, or backend error text.
|
|
79
81
|
|
|
80
82
|
These diagnostics require the extension to be loaded when the attempt happens; they cannot explain earlier compactions retroactively. A committed native checkpoint does not by itself prove that a later request replayed it.
|
|
81
83
|
|
|
@@ -100,7 +102,7 @@ pi --extension ./dist/index.js
|
|
|
100
102
|
|
|
101
103
|
The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
|
|
102
104
|
|
|
103
|
-
Tests include real Pi 1.4.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence and real idle-expiry transactions in both extension orders. Cached-summary fixtures also exercise built-in DeepSeek/Xiaomi dispatch, Anthropic Messages, late tool output, truncated-output fallback and measured usage. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
|
|
105
|
+
Tests include real Pi 1.4.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence and real idle-expiry transactions in both extension orders. Cached-summary fixtures also exercise built-in DeepSeek/Xiaomi dispatch, Anthropic Messages, late tool output, truncated-output fallback and measured usage. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration. `PI_PEAK_HOURS_FIXTURE_PATH` adds request-wide pricing checks in both extension orders, including warming, branch summaries, mixed-API dispatch and composition with `/fast`.
|
|
104
106
|
|
|
105
107
|
Authenticated cached-summary smoke tests use synthetic history, including a value beyond the ordinary tool-result excerpt, and check continuation:
|
|
106
108
|
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,6 @@ var COMPACTION_MODEL_FLAG = "compaction-model";
|
|
|
16
16
|
var PROJECT_POLICY = "compaction-policy.json";
|
|
17
17
|
var AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
|
|
18
18
|
var MAX_POLICY_BYTES = 64 * 1024;
|
|
19
|
-
var MAX_MODEL_SELECTORS = 8;
|
|
20
19
|
var MAX_SELECTOR_LENGTH = 512;
|
|
21
20
|
function normalizeSelector(value) {
|
|
22
21
|
if (typeof value !== "string")
|
|
@@ -26,23 +25,14 @@ function normalizeSelector(value) {
|
|
|
26
25
|
return;
|
|
27
26
|
return selector;
|
|
28
27
|
}
|
|
29
|
-
function
|
|
28
|
+
function policyHasSelectors(value) {
|
|
30
29
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
31
30
|
return;
|
|
32
31
|
if (!("models" in value) || !Array.isArray(value.models))
|
|
33
32
|
return;
|
|
34
|
-
|
|
35
|
-
for (const model of value.models) {
|
|
36
|
-
const selector = normalizeSelector(model);
|
|
37
|
-
if (selector === undefined || selectors.includes(selector))
|
|
38
|
-
continue;
|
|
39
|
-
selectors.push(selector);
|
|
40
|
-
if (selectors.length === MAX_MODEL_SELECTORS)
|
|
41
|
-
break;
|
|
42
|
-
}
|
|
43
|
-
return selectors;
|
|
33
|
+
return value.models.some((model) => normalizeSelector(model) !== undefined);
|
|
44
34
|
}
|
|
45
|
-
function
|
|
35
|
+
function readPolicySelection(configPath) {
|
|
46
36
|
let fd;
|
|
47
37
|
try {
|
|
48
38
|
fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
@@ -59,7 +49,7 @@ function readPolicySelectors(configPath) {
|
|
|
59
49
|
}
|
|
60
50
|
if (bytesRead > MAX_POLICY_BYTES)
|
|
61
51
|
return;
|
|
62
|
-
return
|
|
52
|
+
return policyHasSelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")));
|
|
63
53
|
} catch {
|
|
64
54
|
return;
|
|
65
55
|
} finally {
|
|
@@ -141,9 +131,9 @@ function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
|
|
|
141
131
|
}
|
|
142
132
|
candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
|
|
143
133
|
for (const candidate of candidates) {
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
return
|
|
134
|
+
const hasSelectors = readPolicySelection(candidate.path);
|
|
135
|
+
if (hasSelectors !== undefined) {
|
|
136
|
+
return hasSelectors ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
|
|
147
137
|
}
|
|
148
138
|
}
|
|
149
139
|
return { hasSelectors: false };
|
|
@@ -1170,7 +1160,7 @@ async function collectProviderUsage(stream, signal) {
|
|
|
1170
1160
|
if (signal.aborted)
|
|
1171
1161
|
throw new DOMException("Compaction aborted", "AbortError");
|
|
1172
1162
|
if (event.type === "error")
|
|
1173
|
-
throw new
|
|
1163
|
+
throw new NativeCompactionValidationError("Native compaction provider stream failed", event.error.usage);
|
|
1174
1164
|
if (event.type === "done")
|
|
1175
1165
|
usage = event.message.usage;
|
|
1176
1166
|
}
|
|
@@ -1216,14 +1206,15 @@ async function requestResponsesCompact(request) {
|
|
|
1216
1206
|
if (!response.ok)
|
|
1217
1207
|
return response;
|
|
1218
1208
|
const parsed = await readJsonBounded(response, request.signal);
|
|
1209
|
+
if (!isObject(parsed))
|
|
1210
|
+
throw new Error("Responses Compact returned a non-object response");
|
|
1211
|
+
const completion = syntheticCompletion(parsed, preparedPayload);
|
|
1219
1212
|
try {
|
|
1220
|
-
|
|
1221
|
-
throw new Error("Responses Compact returned a non-object response");
|
|
1222
|
-
compacted = { response: parsed, output: validateCompactedOutput(parsed) };
|
|
1213
|
+
compacted = validateCompactedOutput(parsed);
|
|
1223
1214
|
} catch (error) {
|
|
1224
|
-
|
|
1215
|
+
bridgeError = new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
|
|
1225
1216
|
}
|
|
1226
|
-
return
|
|
1217
|
+
return completion;
|
|
1227
1218
|
} catch (error) {
|
|
1228
1219
|
bridgeError = error;
|
|
1229
1220
|
return errorResponse(error);
|
|
@@ -1238,6 +1229,7 @@ async function requestResponsesCompact(request) {
|
|
|
1238
1229
|
transport: "sse",
|
|
1239
1230
|
cacheRetention: "none",
|
|
1240
1231
|
maxRetries: 0,
|
|
1232
|
+
sessionId: request.sessionId,
|
|
1241
1233
|
fetch: bridgeFetch,
|
|
1242
1234
|
onPayload: (payload) => {
|
|
1243
1235
|
assertCurrent(request);
|
|
@@ -1251,7 +1243,7 @@ async function requestResponsesCompact(request) {
|
|
|
1251
1243
|
});
|
|
1252
1244
|
const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
|
|
1253
1245
|
if (bridgeError)
|
|
1254
|
-
throw
|
|
1246
|
+
throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
|
|
1255
1247
|
throw error;
|
|
1256
1248
|
});
|
|
1257
1249
|
if (bridgeError instanceof NativeCompactionValidationError)
|
|
@@ -1260,7 +1252,7 @@ async function requestResponsesCompact(request) {
|
|
|
1260
1252
|
throw bridgeError;
|
|
1261
1253
|
if (!compacted || dispatches !== 1)
|
|
1262
1254
|
throw new Error("Responses Compact did not complete exactly one request");
|
|
1263
|
-
return { output: compacted
|
|
1255
|
+
return { output: compacted, usage };
|
|
1264
1256
|
}
|
|
1265
1257
|
async function collectCompactionSse(stream, signal) {
|
|
1266
1258
|
const reader = stream.getReader();
|
|
@@ -1405,6 +1397,7 @@ async function requestRemoteV2(request) {
|
|
|
1405
1397
|
transport: "sse",
|
|
1406
1398
|
cacheRetention: "none",
|
|
1407
1399
|
maxRetries: 0,
|
|
1400
|
+
sessionId: request.sessionId,
|
|
1408
1401
|
fetch: inspectedFetch,
|
|
1409
1402
|
onPayload: (payload) => {
|
|
1410
1403
|
assertCurrent(request);
|
|
@@ -1419,10 +1412,10 @@ async function requestRemoteV2(request) {
|
|
|
1419
1412
|
try {
|
|
1420
1413
|
const usage = await collectProviderUsage(stream, request.signal);
|
|
1421
1414
|
if (!sentInput || inspections.length !== 1)
|
|
1422
|
-
throw new
|
|
1415
|
+
throw new NativeCompactionValidationError(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`, usage);
|
|
1423
1416
|
const inspection = await inspections[0];
|
|
1424
1417
|
if ("error" in inspection)
|
|
1425
|
-
throw
|
|
1418
|
+
throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
|
|
1426
1419
|
return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
|
|
1427
1420
|
} finally {
|
|
1428
1421
|
controller.abort();
|
|
@@ -1825,17 +1818,26 @@ async function cachedSummary(request, context, preparation, instructions, signal
|
|
|
1825
1818
|
}
|
|
1826
1819
|
|
|
1827
1820
|
// provider.ts
|
|
1821
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
1822
|
+
var USAGE_OWNERS = Symbol.for("@nijaru/pi-provider-compaction/usage-owners");
|
|
1823
|
+
var usageGlobal = globalThis;
|
|
1824
|
+
var usageOwners = usageGlobal[USAGE_OWNERS] ??= new WeakMap;
|
|
1825
|
+
function bindUsageHeaders(headers, sessionId) {
|
|
1826
|
+
usageOwners.set(headers, sessionId);
|
|
1827
|
+
}
|
|
1828
1828
|
var STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
|
|
1829
1829
|
function observedStream(model, context, options, observe, dispatch) {
|
|
1830
|
+
const headerOwner = options?.headers ? usageOwners.get(options.headers) : undefined;
|
|
1831
|
+
const scope = { sessionId: headerOwner ?? options?.sessionId };
|
|
1830
1832
|
let handler;
|
|
1831
1833
|
try {
|
|
1832
|
-
handler = observe(model, context, options ?? {});
|
|
1834
|
+
handler = observe(model, context, options ?? {}, scope);
|
|
1833
1835
|
} catch {
|
|
1834
1836
|
return dispatch(options);
|
|
1835
1837
|
}
|
|
1836
1838
|
if (!handler)
|
|
1837
1839
|
return dispatch(options);
|
|
1838
|
-
const
|
|
1840
|
+
const run = () => dispatch({
|
|
1839
1841
|
...options,
|
|
1840
1842
|
...handler.cacheRetention === undefined ? {} : { cacheRetention: handler.cacheRetention },
|
|
1841
1843
|
onPayload: async (payload, requestModel) => {
|
|
@@ -1848,14 +1850,64 @@ function observedStream(model, context, options, observe, dispatch) {
|
|
|
1848
1850
|
}
|
|
1849
1851
|
}
|
|
1850
1852
|
});
|
|
1853
|
+
const stream = run();
|
|
1854
|
+
if (handler.priceUsage)
|
|
1855
|
+
return pricedStream(stream, model, handler);
|
|
1851
1856
|
if (handler.complete)
|
|
1852
1857
|
stream.result().then(handler.complete).catch(() => {});
|
|
1853
1858
|
return stream;
|
|
1854
1859
|
}
|
|
1860
|
+
function pricedStream(stream, model, handler) {
|
|
1861
|
+
const out = createAssistantMessageEventStream();
|
|
1862
|
+
(async () => {
|
|
1863
|
+
let final;
|
|
1864
|
+
let partial;
|
|
1865
|
+
try {
|
|
1866
|
+
for await (const event of stream) {
|
|
1867
|
+
if ("partial" in event)
|
|
1868
|
+
partial = event.partial;
|
|
1869
|
+
if (event.type === "done" || event.type === "error") {
|
|
1870
|
+
final = event.type === "done" ? event.message : event.error;
|
|
1871
|
+
try {
|
|
1872
|
+
handler.priceUsage?.(final);
|
|
1873
|
+
} catch {}
|
|
1874
|
+
try {
|
|
1875
|
+
handler.complete?.(final);
|
|
1876
|
+
} catch {}
|
|
1877
|
+
}
|
|
1878
|
+
out.push(event);
|
|
1879
|
+
}
|
|
1880
|
+
if (final) {
|
|
1881
|
+
out.end(final);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
} catch {}
|
|
1885
|
+
const message = {
|
|
1886
|
+
...partial ?? {
|
|
1887
|
+
role: "assistant",
|
|
1888
|
+
api: model.api,
|
|
1889
|
+
provider: model.provider,
|
|
1890
|
+
model: model.id,
|
|
1891
|
+
content: [],
|
|
1892
|
+
timestamp: Date.now(),
|
|
1893
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
|
|
1894
|
+
},
|
|
1895
|
+
stopReason: "error",
|
|
1896
|
+
errorMessage: "Provider stream ended before a terminal event"
|
|
1897
|
+
};
|
|
1898
|
+
try {
|
|
1899
|
+
handler.priceUsage?.(message);
|
|
1900
|
+
} catch {}
|
|
1901
|
+
out.push({ type: "error", reason: "error", error: message });
|
|
1902
|
+
out.end(message);
|
|
1903
|
+
})();
|
|
1904
|
+
return out;
|
|
1905
|
+
}
|
|
1855
1906
|
function observerLeases(provider, owned, restore) {
|
|
1856
1907
|
const leases = new Map;
|
|
1857
1908
|
let closed = false;
|
|
1858
|
-
const
|
|
1909
|
+
const live = (token) => !closed && leases.has(token);
|
|
1910
|
+
const active = (token) => live(token) && leases.get(token)?.current() === true && owned();
|
|
1859
1911
|
const release = () => {
|
|
1860
1912
|
if (closed)
|
|
1861
1913
|
return;
|
|
@@ -1874,6 +1926,10 @@ function observerLeases(provider, owned, restore) {
|
|
|
1874
1926
|
continue;
|
|
1875
1927
|
handlers.push(Object.assign((payload) => active(token) ? handler(payload) : payload, {
|
|
1876
1928
|
cacheRetention: handler.cacheRetention,
|
|
1929
|
+
priceUsage: handler.priceUsage ? (message) => {
|
|
1930
|
+
if (live(token))
|
|
1931
|
+
handler.priceUsage(message);
|
|
1932
|
+
} : undefined,
|
|
1877
1933
|
complete: handler.complete ? (message) => {
|
|
1878
1934
|
if (active(token))
|
|
1879
1935
|
handler.complete(message);
|
|
@@ -1894,6 +1950,13 @@ function observerLeases(provider, owned, restore) {
|
|
|
1894
1950
|
return payload;
|
|
1895
1951
|
}, {
|
|
1896
1952
|
cacheRetention: [...handlers].reverse().find((handler) => handler.cacheRetention !== undefined)?.cacheRetention,
|
|
1953
|
+
priceUsage: handlers.some((handler) => handler.priceUsage) ? (message) => {
|
|
1954
|
+
for (const handler of handlers) {
|
|
1955
|
+
try {
|
|
1956
|
+
handler.priceUsage?.(message);
|
|
1957
|
+
} catch {}
|
|
1958
|
+
}
|
|
1959
|
+
} : undefined,
|
|
1897
1960
|
complete: handlers.some((handler) => handler.complete) ? (message) => {
|
|
1898
1961
|
for (const handler of handlers) {
|
|
1899
1962
|
try {
|
|
@@ -1918,7 +1981,7 @@ function observerLeases(provider, owned, restore) {
|
|
|
1918
1981
|
}
|
|
1919
1982
|
};
|
|
1920
1983
|
}
|
|
1921
|
-
var LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-
|
|
1984
|
+
var LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v2");
|
|
1922
1985
|
var shared = globalThis;
|
|
1923
1986
|
var { effectiveLeases, effectiveRestores, nativeLeases, legacyLeases } = shared[LEASE_TABLES] ??= {
|
|
1924
1987
|
effectiveLeases: new WeakMap,
|
|
@@ -1994,13 +2057,21 @@ function installObserver(pi, ctx, observe) {
|
|
|
1994
2057
|
return;
|
|
1995
2058
|
const registry = ctx.modelRegistry;
|
|
1996
2059
|
const id = model.provider;
|
|
2060
|
+
const observeApi = (requestModel, ...args) => requestModel.api === model.api ? observe(requestModel, ...args) : undefined;
|
|
2061
|
+
const tagged = (lease) => lease ? Object.assign(lease, { api: model.api }) : undefined;
|
|
2062
|
+
const effective = () => {
|
|
2063
|
+
const base = registry.getProvider(id);
|
|
2064
|
+
if (!base?.getModels().some((entry) => entry.api === model.api))
|
|
2065
|
+
return;
|
|
2066
|
+
return tagged(leaseEffectiveProvider(base, () => registry.getProvider(id), observeApi));
|
|
2067
|
+
};
|
|
1997
2068
|
const native = registry.getRegisteredNativeProvider(id);
|
|
1998
2069
|
if (native) {
|
|
1999
2070
|
if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api))
|
|
2000
|
-
return;
|
|
2071
|
+
return effective();
|
|
2001
2072
|
const existing = nativeLeases.get(native);
|
|
2002
2073
|
if (existing)
|
|
2003
|
-
return existing.add(
|
|
2074
|
+
return tagged(existing.add(observeApi));
|
|
2004
2075
|
const leases = observerLeases(id, () => registry.getRegisteredNativeProvider(id) === wrapper, () => {
|
|
2005
2076
|
if (registry.getRegisteredNativeProvider(id) === wrapper)
|
|
2006
2077
|
registry.registerProvider(native);
|
|
@@ -2009,15 +2080,15 @@ function installObserver(pi, ctx, observe) {
|
|
|
2009
2080
|
const wrapper = decorateProvider(native, leases.observe);
|
|
2010
2081
|
nativeLeases.set(wrapper, leases);
|
|
2011
2082
|
pi.registerProvider(wrapper);
|
|
2012
|
-
return leases.add(
|
|
2083
|
+
return tagged(leases.add(observeApi));
|
|
2013
2084
|
}
|
|
2014
2085
|
const original = registry.getRegisteredProviderConfig(id);
|
|
2015
2086
|
if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels || original.api !== model.api) {
|
|
2016
|
-
return
|
|
2087
|
+
return effective();
|
|
2017
2088
|
}
|
|
2018
2089
|
const existing = legacyLeases.get(original.streamSimple);
|
|
2019
2090
|
if (existing)
|
|
2020
|
-
return existing.add(
|
|
2091
|
+
return tagged(existing.add(observeApi));
|
|
2021
2092
|
const delegate = original.streamSimple.bind(original);
|
|
2022
2093
|
const owned = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
|
|
2023
2094
|
const leases = observerLeases(id, owned, () => {
|
|
@@ -2032,7 +2103,62 @@ function installObserver(pi, ctx, observe) {
|
|
|
2032
2103
|
Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: leases.release } });
|
|
2033
2104
|
legacyLeases.set(wrapper, leases);
|
|
2034
2105
|
pi.registerProvider(id, { ...original, streamSimple: wrapper });
|
|
2035
|
-
return leases.add(
|
|
2106
|
+
return tagged(leases.add(observeApi));
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// usage-observer.ts
|
|
2110
|
+
var REQUEST = "pi-provider-compaction:observe-usage";
|
|
2111
|
+
var READY = "pi-provider-compaction:usage-observer-ready";
|
|
2112
|
+
function serveUsageObservers(pi, current) {
|
|
2113
|
+
const leases = new Set;
|
|
2114
|
+
const remove = pi.events.on(REQUEST, (value) => {
|
|
2115
|
+
const request = value;
|
|
2116
|
+
const owner = current();
|
|
2117
|
+
if (!owner || !request || request.handled || !request.context || !request.model || typeof request.priceFor !== "function" || typeof request.respond !== "function")
|
|
2118
|
+
return;
|
|
2119
|
+
try {
|
|
2120
|
+
if (request.context.modelRegistry !== owner.modelRegistry || request.context.sessionManager.getSessionId() !== owner.sessionManager.getSessionId())
|
|
2121
|
+
return;
|
|
2122
|
+
request.handled = true;
|
|
2123
|
+
const sessionId = owner.sessionManager.getSessionId();
|
|
2124
|
+
const installed = installObserver(pi, { model: request.model, modelRegistry: owner.modelRegistry }, (model, _context, _options, scope) => {
|
|
2125
|
+
const price = request.priceFor({ provider: model.provider, id: model.id });
|
|
2126
|
+
return Object.assign((payload) => payload, { priceUsage: (message) => {
|
|
2127
|
+
if (scope.sessionId === sessionId)
|
|
2128
|
+
price(message.timestamp, message.usage);
|
|
2129
|
+
} });
|
|
2130
|
+
});
|
|
2131
|
+
if (!installed) {
|
|
2132
|
+
request.respond(undefined);
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
const lease = {
|
|
2136
|
+
provider: installed.provider,
|
|
2137
|
+
api: installed.api,
|
|
2138
|
+
isInstalled: () => installed.isInstalled(),
|
|
2139
|
+
remove() {
|
|
2140
|
+
leases.delete(lease);
|
|
2141
|
+
installed.remove();
|
|
2142
|
+
}
|
|
2143
|
+
};
|
|
2144
|
+
leases.add(lease);
|
|
2145
|
+
request.respond(lease);
|
|
2146
|
+
} catch {}
|
|
2147
|
+
});
|
|
2148
|
+
return {
|
|
2149
|
+
ready(context) {
|
|
2150
|
+
pi.events.emit(READY, { context });
|
|
2151
|
+
},
|
|
2152
|
+
clear() {
|
|
2153
|
+
for (const lease of leases)
|
|
2154
|
+
lease.remove();
|
|
2155
|
+
},
|
|
2156
|
+
dispose() {
|
|
2157
|
+
remove();
|
|
2158
|
+
for (const lease of leases)
|
|
2159
|
+
lease.remove();
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2036
2162
|
}
|
|
2037
2163
|
|
|
2038
2164
|
// index.ts
|
|
@@ -2136,9 +2262,10 @@ function pi_provider_compaction_default(pi) {
|
|
|
2136
2262
|
const routeDigest = (value) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
2137
2263
|
let runtimeId = randomUUID2();
|
|
2138
2264
|
let ctx;
|
|
2265
|
+
let usageObservers = serveUsageObservers(pi, () => ctx);
|
|
2139
2266
|
let snapshot;
|
|
2140
2267
|
let cache;
|
|
2141
|
-
let
|
|
2268
|
+
let compactionRequest = false;
|
|
2142
2269
|
let expiryAttempt;
|
|
2143
2270
|
let lastOutcome;
|
|
2144
2271
|
let active;
|
|
@@ -2168,7 +2295,7 @@ Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current reg
|
|
|
2168
2295
|
Capture: ${captureStatus}
|
|
2169
2296
|
Cache: ${state.reason}; ${remaining}
|
|
2170
2297
|
Last attempt: ${compactionStatus}${lastOutcome ? `
|
|
2171
|
-
Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output};
|
|
2298
|
+
Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output}; estimated cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
|
|
2172
2299
|
}
|
|
2173
2300
|
});
|
|
2174
2301
|
const invalidate = () => {
|
|
@@ -2182,6 +2309,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2182
2309
|
captureStatus = "invalidated by lifecycle/history/policy change";
|
|
2183
2310
|
};
|
|
2184
2311
|
const retire = () => {
|
|
2312
|
+
usageObservers.clear();
|
|
2185
2313
|
invalidate();
|
|
2186
2314
|
const installed = observer;
|
|
2187
2315
|
observer = undefined;
|
|
@@ -2191,6 +2319,10 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2191
2319
|
installed?.remove();
|
|
2192
2320
|
lastOutcome = undefined;
|
|
2193
2321
|
};
|
|
2322
|
+
pi.on("before_provider_headers", (event, context) => {
|
|
2323
|
+
bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
|
|
2324
|
+
usageObservers.ready(context);
|
|
2325
|
+
});
|
|
2194
2326
|
const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
2195
2327
|
const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
2196
2328
|
const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
@@ -2262,7 +2394,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2262
2394
|
}
|
|
2263
2395
|
if (options.sessionId !== manager.getSessionId())
|
|
2264
2396
|
return;
|
|
2265
|
-
if (
|
|
2397
|
+
if (compactionRequest)
|
|
2266
2398
|
return;
|
|
2267
2399
|
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) {
|
|
2268
2400
|
captureRejected("unsupported API");
|
|
@@ -2398,7 +2530,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2398
2530
|
};
|
|
2399
2531
|
const install = (context) => {
|
|
2400
2532
|
ctx = context;
|
|
2401
|
-
if (observer?.provider === context.model?.provider && observer?.isInstalled())
|
|
2533
|
+
if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled())
|
|
2402
2534
|
return;
|
|
2403
2535
|
if (observer) {
|
|
2404
2536
|
invalidate();
|
|
@@ -2412,11 +2544,16 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2412
2544
|
};
|
|
2413
2545
|
pi.on("session_start", (_event, context) => {
|
|
2414
2546
|
retire();
|
|
2547
|
+
usageObservers.dispose();
|
|
2548
|
+
usageObservers = serveUsageObservers(pi, () => ctx);
|
|
2415
2549
|
install(context);
|
|
2550
|
+
usageObservers.ready(context);
|
|
2416
2551
|
const sessionId = context.sessionManager.getSessionId();
|
|
2417
2552
|
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
2418
|
-
if (disposedId === undefined || disposedId === sessionId)
|
|
2553
|
+
if (disposedId === undefined || disposedId === sessionId) {
|
|
2419
2554
|
retire();
|
|
2555
|
+
usageObservers.dispose();
|
|
2556
|
+
}
|
|
2420
2557
|
});
|
|
2421
2558
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
2422
2559
|
if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
|
|
@@ -2424,15 +2561,20 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2424
2561
|
});
|
|
2425
2562
|
pi.on("before_agent_start", (_event, context) => {
|
|
2426
2563
|
install(context);
|
|
2564
|
+
usageObservers.ready(context);
|
|
2427
2565
|
});
|
|
2428
2566
|
pi.on("model_select", (_event, context) => {
|
|
2429
2567
|
invalidate();
|
|
2430
2568
|
install(context);
|
|
2569
|
+
usageObservers.ready(context);
|
|
2431
2570
|
});
|
|
2432
2571
|
pi.on("session_tree", () => {
|
|
2433
2572
|
invalidate();
|
|
2434
2573
|
});
|
|
2435
|
-
pi.on("session_shutdown",
|
|
2574
|
+
pi.on("session_shutdown", () => {
|
|
2575
|
+
retire();
|
|
2576
|
+
usageObservers.dispose();
|
|
2577
|
+
});
|
|
2436
2578
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
2437
2579
|
pi.on("session_shutdown", () => {
|
|
2438
2580
|
if (typeof removeInvalidation === "function")
|
|
@@ -2489,7 +2631,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2489
2631
|
let summaryReason;
|
|
2490
2632
|
if (cached) {
|
|
2491
2633
|
compactionStatus = "generating cached portable summary";
|
|
2492
|
-
|
|
2634
|
+
compactionRequest = true;
|
|
2493
2635
|
try {
|
|
2494
2636
|
const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
|
|
2495
2637
|
portable = result.result;
|
|
@@ -2498,7 +2640,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2498
2640
|
if (portable)
|
|
2499
2641
|
summaryPath = "cached";
|
|
2500
2642
|
} finally {
|
|
2501
|
-
|
|
2643
|
+
compactionRequest = false;
|
|
2502
2644
|
}
|
|
2503
2645
|
if (!current())
|
|
2504
2646
|
return cancelled(attemptedUsage);
|
|
@@ -2539,27 +2681,35 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2539
2681
|
return outcome(summaryPath, portable, "native auth changed");
|
|
2540
2682
|
}
|
|
2541
2683
|
compactionStatus = "requesting native checkpoint";
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2684
|
+
let native;
|
|
2685
|
+
compactionRequest = true;
|
|
2686
|
+
try {
|
|
2687
|
+
native = await requestProviderCompaction({
|
|
2688
|
+
provider: capture.provider,
|
|
2689
|
+
model: capture.model,
|
|
2690
|
+
context: capture.prepared.context,
|
|
2691
|
+
input,
|
|
2692
|
+
preparedPayload: capture.prepared.payload,
|
|
2693
|
+
protocol,
|
|
2694
|
+
apiKey: capture.options.apiKey,
|
|
2695
|
+
headers: capture.options.headers,
|
|
2696
|
+
env: capture.options.env,
|
|
2697
|
+
signal: event.signal,
|
|
2698
|
+
isCurrent: current,
|
|
2699
|
+
fetch: capture.options.fetch,
|
|
2700
|
+
sessionId: source.sessionId,
|
|
2701
|
+
...capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }
|
|
2702
|
+
});
|
|
2703
|
+
} finally {
|
|
2704
|
+
compactionRequest = false;
|
|
2705
|
+
}
|
|
2557
2706
|
if (!current())
|
|
2558
2707
|
return cancelled(combineUsage(native.usage, portable.usage));
|
|
2559
2708
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
2560
2709
|
if (!current())
|
|
2561
2710
|
return cancelled(combineUsage(native.usage, portable.usage));
|
|
2562
2711
|
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
2712
|
+
portable.usage = combineUsage(native.usage, portable.usage);
|
|
2563
2713
|
fallback("auth changed after native generation");
|
|
2564
2714
|
return outcome(summaryPath, portable, "native auth changed");
|
|
2565
2715
|
}
|
|
@@ -2602,7 +2752,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
|
|
|
2602
2752
|
fallback("another handler supplied the committed result");
|
|
2603
2753
|
if (owned) {
|
|
2604
2754
|
if (configuration().outcomeNotifications && lastOutcome)
|
|
2605
|
-
context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite};
|
|
2755
|
+
context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite}; estimated cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
|
|
2606
2756
|
} else
|
|
2607
2757
|
lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
|
|
2608
2758
|
if (fallbackReason)
|
package/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { normalizeContext, registerSessionResourceCleanup, type Model, type Prov
|
|
|
4
4
|
import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
6
6
|
import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
|
|
7
|
-
import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
7
|
+
import { bindUsageHeaders, installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
8
|
+
import { serveUsageObservers } from "./usage-observer";
|
|
8
9
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
9
10
|
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
10
11
|
|
|
@@ -161,9 +162,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
161
162
|
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
162
163
|
let runtimeId = randomUUID();
|
|
163
164
|
let ctx: ExtensionContext | undefined;
|
|
165
|
+
let usageObservers = serveUsageObservers(pi, () => ctx);
|
|
164
166
|
let snapshot: Captured | undefined;
|
|
165
167
|
let cache: CachedRequest | undefined;
|
|
166
|
-
let
|
|
168
|
+
let compactionRequest = false;
|
|
167
169
|
let expiryAttempt: ExpiryAttempt | undefined;
|
|
168
170
|
let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
|
|
169
171
|
let active: Active | undefined;
|
|
@@ -183,11 +185,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
183
185
|
const state = cacheState(context);
|
|
184
186
|
const remaining = state.expiresAt === undefined ? "unknown lifetime" : `estimated ${Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000))}s remaining (model declaration; best effort)`;
|
|
185
187
|
const usage = lastOutcome?.usage;
|
|
186
|
-
context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nCache: ${state.reason}; ${remaining}\nLast attempt: ${compactionStatus}${lastOutcome ? `\nOutcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output};
|
|
188
|
+
context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nCache: ${state.reason}; ${remaining}\nLast attempt: ${compactionStatus}${lastOutcome ? `\nOutcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output}; estimated cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
|
|
187
189
|
},
|
|
188
190
|
});
|
|
189
191
|
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
190
192
|
const retire = () => {
|
|
193
|
+
usageObservers.clear();
|
|
191
194
|
invalidate();
|
|
192
195
|
const installed = observer;
|
|
193
196
|
observer = undefined; ctx = undefined;
|
|
@@ -195,6 +198,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
195
198
|
installed?.remove();
|
|
196
199
|
lastOutcome = undefined;
|
|
197
200
|
};
|
|
201
|
+
pi.on("before_provider_headers", (event, context) => {
|
|
202
|
+
bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
|
|
203
|
+
// Lazy API loading or catalog refresh can recompose effective providers
|
|
204
|
+
// between auxiliary calls that have no before_agent_start boundary.
|
|
205
|
+
usageObservers.ready(context);
|
|
206
|
+
});
|
|
198
207
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
199
208
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
200
209
|
const unchanged = (branch: readonly SessionEntry[], length: number, hash: string) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
@@ -250,7 +259,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
250
259
|
return;
|
|
251
260
|
}
|
|
252
261
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
253
|
-
if (
|
|
262
|
+
if (compactionRequest) return;
|
|
254
263
|
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
|
|
255
264
|
if (!options.onPayload) {
|
|
256
265
|
// The portable-summary request re-enters this observer without a payload
|
|
@@ -346,27 +355,31 @@ export default function (pi: ExtensionAPI): void {
|
|
|
346
355
|
|
|
347
356
|
const install = (context: ExtensionContext) => {
|
|
348
357
|
ctx = context;
|
|
349
|
-
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
358
|
+
if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
|
|
350
359
|
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
351
360
|
if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
|
|
352
361
|
observer = installObserver(pi, context, observe);
|
|
353
362
|
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
354
363
|
};
|
|
355
364
|
pi.on("session_start", (_event, context) => {
|
|
356
|
-
retire();
|
|
365
|
+
retire();
|
|
366
|
+
usageObservers.dispose();
|
|
367
|
+
usageObservers = serveUsageObservers(pi, () => ctx);
|
|
368
|
+
install(context);
|
|
369
|
+
usageObservers.ready(context);
|
|
357
370
|
const sessionId = context.sessionManager.getSessionId();
|
|
358
371
|
// AgentSession.dispose() cleans session resources even without emitting
|
|
359
372
|
// session_shutdown. The captured registry keeps owned cleanup usable.
|
|
360
373
|
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
361
|
-
if (disposedId === undefined || disposedId === sessionId) retire();
|
|
374
|
+
if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
|
|
362
375
|
});
|
|
363
376
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
364
377
|
if (latest?.summary === NATIVE_COMPACTION_SUMMARY) context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
|
|
365
378
|
});
|
|
366
|
-
pi.on("before_agent_start", (_event, context) => { install(context); });
|
|
367
|
-
pi.on("model_select", (_event, context) => { invalidate(); install(context); });
|
|
379
|
+
pi.on("before_agent_start", (_event, context) => { install(context); usageObservers.ready(context); });
|
|
380
|
+
pi.on("model_select", (_event, context) => { invalidate(); install(context); usageObservers.ready(context); });
|
|
368
381
|
pi.on("session_tree", () => { invalidate(); });
|
|
369
|
-
pi.on("session_shutdown", retire);
|
|
382
|
+
pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
|
|
370
383
|
// Privacy-policy owners must invalidate before changing a policy that can affect
|
|
371
384
|
// hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
|
|
372
385
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
@@ -419,14 +432,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
419
432
|
let summaryReason: string | undefined;
|
|
420
433
|
if (cached) {
|
|
421
434
|
compactionStatus = "generating cached portable summary";
|
|
422
|
-
|
|
435
|
+
compactionRequest = true;
|
|
423
436
|
try {
|
|
424
437
|
const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
|
|
425
438
|
portable = result.result;
|
|
426
439
|
attemptedUsage = result.usage;
|
|
427
440
|
summaryReason = result.reason;
|
|
428
441
|
if (portable) summaryPath = "cached";
|
|
429
|
-
} finally {
|
|
442
|
+
} finally { compactionRequest = false; }
|
|
430
443
|
if (!current()) return cancelled(attemptedUsage);
|
|
431
444
|
}
|
|
432
445
|
if (!portable) {
|
|
@@ -458,17 +471,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
458
471
|
if (!current()) return cancelled(portable.usage);
|
|
459
472
|
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
|
|
460
473
|
compactionStatus = "requesting native checkpoint";
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
474
|
+
let native: Awaited<ReturnType<typeof requestProviderCompaction>>;
|
|
475
|
+
compactionRequest = true;
|
|
476
|
+
try {
|
|
477
|
+
native = await requestProviderCompaction({
|
|
478
|
+
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
479
|
+
input, preparedPayload: capture.prepared.payload, protocol,
|
|
480
|
+
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
481
|
+
signal: event.signal, isCurrent: current, fetch: capture.options.fetch, sessionId: source.sessionId,
|
|
482
|
+
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
483
|
+
});
|
|
484
|
+
} finally { compactionRequest = false; }
|
|
468
485
|
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
469
486
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
470
487
|
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
471
|
-
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
488
|
+
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
489
|
+
// The checkpoint is no longer safe to commit, but both requests
|
|
490
|
+
// completed and their known usage still belongs in the session.
|
|
491
|
+
portable.usage = combineUsage(native.usage, portable.usage);
|
|
492
|
+
fallback("auth changed after native generation");
|
|
493
|
+
return outcome(summaryPath, portable, "native auth changed");
|
|
494
|
+
}
|
|
472
495
|
const details: NativeCompactionDetails = {
|
|
473
496
|
...(isObject(portable.details) ? portable.details : {}),
|
|
474
497
|
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
@@ -494,7 +517,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
494
517
|
else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
|
|
495
518
|
else fallback("another handler supplied the committed result");
|
|
496
519
|
if (owned) {
|
|
497
|
-
if (configuration().outcomeNotifications && lastOutcome) context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite};
|
|
520
|
+
if (configuration().outcomeNotifications && lastOutcome) context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite}; estimated cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
|
|
498
521
|
} else lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
|
|
499
522
|
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
500
523
|
if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-provider-compaction",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build": "bun run build.ts",
|
|
30
30
|
"prepack": "bun run build",
|
|
31
31
|
"test": "bun test",
|
|
32
|
-
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts cached-summary.ts tests/",
|
|
32
|
+
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts usage-observer.ts replay.ts cached-summary.ts tests/",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"check": "bun run build && bun test && bun run lint && tsc --noEmit"
|
|
35
35
|
},
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"protocol.ts",
|
|
40
40
|
"policy.ts",
|
|
41
41
|
"provider.ts",
|
|
42
|
+
"usage-observer.ts",
|
|
42
43
|
"replay.ts",
|
|
43
44
|
"cached-summary.ts",
|
|
44
45
|
"README.md",
|
package/policy.ts
CHANGED
|
@@ -20,7 +20,6 @@ export const COMPACTION_MODEL_FLAG = "compaction-model";
|
|
|
20
20
|
const PROJECT_POLICY = "compaction-policy.json";
|
|
21
21
|
const AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
|
|
22
22
|
const MAX_POLICY_BYTES = 64 * 1024;
|
|
23
|
-
const MAX_MODEL_SELECTORS = 8;
|
|
24
23
|
const MAX_SELECTOR_LENGTH = 512;
|
|
25
24
|
|
|
26
25
|
type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
|
|
@@ -43,21 +42,15 @@ function normalizeSelector(value: unknown): string | undefined {
|
|
|
43
42
|
return selector;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
function
|
|
45
|
+
function policyHasSelectors(value: unknown): boolean | undefined {
|
|
47
46
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
48
47
|
if (!("models" in value) || !Array.isArray(value.models)) return undefined;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const selector = normalizeSelector(model);
|
|
53
|
-
if (selector === undefined || selectors.includes(selector)) continue;
|
|
54
|
-
selectors.push(selector);
|
|
55
|
-
if (selectors.length === MAX_MODEL_SELECTORS) break;
|
|
56
|
-
}
|
|
57
|
-
return selectors;
|
|
48
|
+
// This extension only decides precedence. The companion owns the actual
|
|
49
|
+
// selector list, including deduplication and its model-attempt limit.
|
|
50
|
+
return value.models.some(model => normalizeSelector(model) !== undefined);
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
function
|
|
53
|
+
function readPolicySelection(configPath: string): boolean | undefined {
|
|
61
54
|
let fd: number | undefined;
|
|
62
55
|
try {
|
|
63
56
|
fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
@@ -74,7 +67,7 @@ function readPolicySelectors(configPath: string): string[] | undefined {
|
|
|
74
67
|
bytesRead += count;
|
|
75
68
|
}
|
|
76
69
|
if (bytesRead > MAX_POLICY_BYTES) return undefined;
|
|
77
|
-
return
|
|
70
|
+
return policyHasSelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")) as unknown);
|
|
78
71
|
} catch {
|
|
79
72
|
// A malformed or unreadable policy must not prevent native compaction.
|
|
80
73
|
return undefined;
|
|
@@ -177,11 +170,11 @@ export function resolveCompactionModelPolicy(
|
|
|
177
170
|
candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
|
|
178
171
|
|
|
179
172
|
for (const candidate of candidates) {
|
|
180
|
-
const
|
|
173
|
+
const hasSelectors = readPolicySelection(candidate.path);
|
|
181
174
|
// A readable policy stops the search: an empty models list is an
|
|
182
175
|
// explicit choice to use no generic compaction model.
|
|
183
|
-
if (
|
|
184
|
-
return
|
|
176
|
+
if (hasSelectors !== undefined) {
|
|
177
|
+
return hasSelectors ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
|
|
185
178
|
}
|
|
186
179
|
}
|
|
187
180
|
return { hasSelectors: false };
|
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,6 +227,7 @@ 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;
|
|
231
232
|
/** Revalidate request ownership after asynchronous provider preparation. */
|
|
232
233
|
isCurrent?: () => boolean;
|
|
@@ -252,7 +253,7 @@ export interface TransportResponse {
|
|
|
252
253
|
|
|
253
254
|
async function requestResponsesCompact(request: TransportRequest): Promise<TransportResponse> {
|
|
254
255
|
let preparedPayload: JsonObject | undefined;
|
|
255
|
-
let compacted:
|
|
256
|
+
let compacted: ResponseItem[] | undefined;
|
|
256
257
|
let bridgeError: unknown;
|
|
257
258
|
let dispatches = 0;
|
|
258
259
|
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
@@ -274,13 +275,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
274
275
|
});
|
|
275
276
|
if (!response.ok) return response;
|
|
276
277
|
const parsed = await readJsonBounded(response, request.signal);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
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;
|
|
284
285
|
} catch (error) {
|
|
285
286
|
bridgeError = error;
|
|
286
287
|
return errorResponse(error);
|
|
@@ -296,6 +297,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
296
297
|
transport: "sse",
|
|
297
298
|
cacheRetention: "none",
|
|
298
299
|
maxRetries: 0,
|
|
300
|
+
sessionId: request.sessionId,
|
|
299
301
|
fetch: bridgeFetch,
|
|
300
302
|
onPayload: (payload: unknown) => {
|
|
301
303
|
assertCurrent(request);
|
|
@@ -307,13 +309,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
307
309
|
});
|
|
308
310
|
|
|
309
311
|
const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
|
|
310
|
-
if (bridgeError) throw
|
|
312
|
+
if (bridgeError) throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
|
|
311
313
|
throw error;
|
|
312
314
|
});
|
|
313
315
|
if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
|
|
314
316
|
if (bridgeError) throw bridgeError;
|
|
315
317
|
if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
|
|
316
|
-
return { output: compacted
|
|
318
|
+
return { output: compacted, usage };
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
interface CollectedSse {
|
|
@@ -438,6 +440,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
438
440
|
transport: "sse",
|
|
439
441
|
cacheRetention: "none",
|
|
440
442
|
maxRetries: 0,
|
|
443
|
+
sessionId: request.sessionId,
|
|
441
444
|
fetch: inspectedFetch,
|
|
442
445
|
onPayload: (payload: unknown) => {
|
|
443
446
|
assertCurrent(request);
|
|
@@ -449,9 +452,9 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
449
452
|
});
|
|
450
453
|
try {
|
|
451
454
|
const usage = await collectProviderUsage(stream, request.signal);
|
|
452
|
-
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);
|
|
453
456
|
const inspection = await inspections[0]!;
|
|
454
|
-
if ("error" in inspection) throw
|
|
457
|
+
if ("error" in inspection) throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
|
|
455
458
|
return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
|
|
456
459
|
} finally {
|
|
457
460
|
controller.abort();
|
package/provider.ts
CHANGED
|
@@ -1,14 +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>();
|
|
7
|
+
|
|
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
|
+
}
|
|
3
12
|
|
|
4
13
|
export type PayloadObserver = ((payload: unknown) => unknown | Promise<unknown>) & {
|
|
5
14
|
complete?: (message: AssistantMessage) => void;
|
|
15
|
+
/** Adjust only usage before any completion observer or consumer sees it. */
|
|
16
|
+
priceUsage?: (message: AssistantMessage) => void;
|
|
6
17
|
cacheRetention?: CacheRetention;
|
|
7
18
|
};
|
|
8
|
-
export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => PayloadObserver | undefined;
|
|
19
|
+
export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions, scope: UsageScope) => PayloadObserver | undefined;
|
|
9
20
|
|
|
10
21
|
export interface InstalledObserver {
|
|
11
22
|
provider: string;
|
|
23
|
+
/** installObserver leases one dispatch API; lower-level leases can be unscoped. */
|
|
24
|
+
api?: string;
|
|
12
25
|
isInstalled(): boolean;
|
|
13
26
|
remove(): void;
|
|
14
27
|
}
|
|
@@ -18,11 +31,13 @@ export interface InstalledObserver {
|
|
|
18
31
|
export const STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
|
|
19
32
|
|
|
20
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 };
|
|
21
36
|
let handler: ReturnType<ObserveRequest>;
|
|
22
|
-
try { handler = observe(model, context, options ?? {}); }
|
|
37
|
+
try { handler = observe(model, context, options ?? {}, scope); }
|
|
23
38
|
catch { return dispatch(options); } // An expired optional observer must not veto dispatch.
|
|
24
39
|
if (!handler) return dispatch(options);
|
|
25
|
-
const
|
|
40
|
+
const run = () => dispatch({
|
|
26
41
|
...options,
|
|
27
42
|
...(handler.cacheRetention === undefined ? {} : { cacheRetention: handler.cacheRetention }),
|
|
28
43
|
onPayload: async (payload: unknown, requestModel: Model<any>) => {
|
|
@@ -32,16 +47,52 @@ function observedStream<T extends StreamOptions>(model: Model<any>, context: Tra
|
|
|
32
47
|
try { return await handler(prepared); } catch { return prepared; }
|
|
33
48
|
},
|
|
34
49
|
} as T);
|
|
35
|
-
|
|
50
|
+
const stream = run();
|
|
51
|
+
if (handler.priceUsage) return pricedStream(stream, model, handler);
|
|
52
|
+
// Pure capture remains transparent, including the original stream identity.
|
|
36
53
|
if (handler.complete) void stream.result().then(handler.complete).catch(() => {});
|
|
37
54
|
return stream;
|
|
38
55
|
}
|
|
39
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
|
+
|
|
40
90
|
/** One dispatch layer, with session-local selection and no released lease history. */
|
|
41
91
|
function observerLeases(provider: string, owned: () => boolean, restore: () => void) {
|
|
42
92
|
const leases = new Map<symbol, { observe: ObserveRequest; current: () => boolean }>();
|
|
43
93
|
let closed = false;
|
|
44
|
-
const
|
|
94
|
+
const live = (token: symbol) => !closed && leases.has(token);
|
|
95
|
+
const active = (token: symbol) => live(token) && leases.get(token)?.current() === true && owned();
|
|
45
96
|
const release = () => {
|
|
46
97
|
if (closed) return;
|
|
47
98
|
closed = true;
|
|
@@ -57,6 +108,10 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
|
|
|
57
108
|
if (!handler) continue;
|
|
58
109
|
handlers.push(Object.assign((payload: unknown) => active(token) ? handler(payload) : payload, {
|
|
59
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,
|
|
60
115
|
complete: handler.complete ? (message: AssistantMessage) => { if (active(token)) handler.complete!(message); } : undefined,
|
|
61
116
|
}));
|
|
62
117
|
} catch { /* A failed optional observer must not disable the other sessions. */ }
|
|
@@ -72,6 +127,11 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
|
|
|
72
127
|
return payload;
|
|
73
128
|
}, {
|
|
74
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,
|
|
75
135
|
complete: handlers.some((handler) => handler.complete) ? (message: AssistantMessage) => {
|
|
76
136
|
for (const handler of handlers) {
|
|
77
137
|
try { handler.complete?.(message); } catch { /* Isolate optional completion observers too. */ }
|
|
@@ -95,7 +155,7 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
|
|
|
95
155
|
type ObserverLeases = ReturnType<typeof observerLeases>;
|
|
96
156
|
// Pi's uncached module loader can instantiate this extension separately for
|
|
97
157
|
// sessions sharing one model runtime. Weak ownership must span those instances.
|
|
98
|
-
const LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-
|
|
158
|
+
const LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v2");
|
|
99
159
|
interface LeaseTables {
|
|
100
160
|
effectiveLeases: WeakMap<Provider, { owned(): boolean; leases: ObserverLeases }>;
|
|
101
161
|
effectiveRestores: WeakMap<Function, { base: Provider; restore(): void }>;
|
|
@@ -168,20 +228,27 @@ export function leaseEffectiveProvider(base: Provider | undefined, current: () =
|
|
|
168
228
|
* delegate their original callback (including /fast). Never register an effective
|
|
169
229
|
* composed provider as a native base: that loses model headers and freezes config.
|
|
170
230
|
*/
|
|
171
|
-
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 {
|
|
172
232
|
const model = ctx.model;
|
|
173
233
|
if (!model) return;
|
|
174
234
|
const registry = ctx.modelRegistry;
|
|
175
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
|
+
};
|
|
176
243
|
const native = registry.getRegisteredNativeProvider(id);
|
|
177
244
|
if (native) {
|
|
178
245
|
// Pi composes a native base only when its catalog serves the active API.
|
|
179
246
|
// Otherwise the request goes to the global API provider and bypasses this
|
|
180
247
|
// wrapper; claiming an observer there would report native compaction as
|
|
181
248
|
// installed while never capturing anything.
|
|
182
|
-
if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return;
|
|
249
|
+
if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return effective();
|
|
183
250
|
const existing = nativeLeases.get(native);
|
|
184
|
-
if (existing) return existing.add(
|
|
251
|
+
if (existing) return tagged(existing.add(observeApi));
|
|
185
252
|
const leases = observerLeases(id, () => registry.getRegisteredNativeProvider(id) === wrapper, () => {
|
|
186
253
|
// Keep cleanup usable after bare SDK disposal invalidates the extension API.
|
|
187
254
|
if (registry.getRegisteredNativeProvider(id) === wrapper) registry.registerProvider(native);
|
|
@@ -190,7 +257,7 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
|
|
|
190
257
|
const wrapper = decorateProvider(native, leases.observe);
|
|
191
258
|
nativeLeases.set(wrapper, leases);
|
|
192
259
|
pi.registerProvider(wrapper);
|
|
193
|
-
return leases.add(
|
|
260
|
+
return tagged(leases.add(observeApi));
|
|
194
261
|
}
|
|
195
262
|
const original = registry.getRegisteredProviderConfig(id);
|
|
196
263
|
// Refresh callbacks own mutable catalog state inside the composer. Replacing
|
|
@@ -198,12 +265,12 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
|
|
|
198
265
|
if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels || original.api !== model.api) {
|
|
199
266
|
// Lease the effective object's methods, NOT its registry slot. Re-registering
|
|
200
267
|
// a composed provider freezes catalogs and loses overlay/header semantics.
|
|
201
|
-
return
|
|
268
|
+
return effective();
|
|
202
269
|
}
|
|
203
270
|
// An injected legacy streamSimple overlay would also intercept full stream()
|
|
204
271
|
// calls and change reasoning semantics. Only decorate an existing stream owner.
|
|
205
272
|
const existing = legacyLeases.get(original.streamSimple);
|
|
206
|
-
if (existing) return existing.add(
|
|
273
|
+
if (existing) return tagged(existing.add(observeApi));
|
|
207
274
|
const delegate = original.streamSimple.bind(original);
|
|
208
275
|
const owned = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
|
|
209
276
|
const leases = observerLeases(id, owned, () => {
|
|
@@ -219,5 +286,5 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
|
|
|
219
286
|
Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: leases.release } });
|
|
220
287
|
legacyLeases.set(wrapper, leases);
|
|
221
288
|
pi.registerProvider(id, { ...original, streamSimple: wrapper });
|
|
222
|
-
return leases.add(
|
|
289
|
+
return tagged(leases.add(observeApi));
|
|
223
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
|
+
}
|