@spendguard/sdk 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/LICENSE_NOTICES.md +127 -0
- package/README.md +151 -0
- package/dist/adapter-D9T3yEEw.d.ts +3441 -0
- package/dist/cache-DOnw8QtJ.d.ts +1164 -0
- package/dist/cache.d.ts +6 -0
- package/dist/cache.js +74 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.js +4815 -0
- package/dist/errors.d.ts +269 -0
- package/dist/errors.js +148 -0
- package/dist/ids.d.ts +69 -0
- package/dist/ids.js +61 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +5295 -0
- package/dist/otel.d.ts +118 -0
- package/dist/otel.js +84 -0
- package/dist/pricing/demo.d.ts +26 -0
- package/dist/pricing/demo.js +138 -0
- package/dist/pricing.d.ts +70 -0
- package/dist/pricing.js +92 -0
- package/dist/promptHash.d.ts +23 -0
- package/dist/promptHash.js +25 -0
- package/dist/proto.d.ts +609 -0
- package/dist/proto.js +3055 -0
- package/dist/retry.d.ts +121 -0
- package/dist/retry.js +92 -0
- package/dist/runPlan.d.ts +69 -0
- package/dist/runPlan.js +35 -0
- package/fixtures/cross-language/v1.json +327 -0
- package/package.json +123 -0
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
import '@grpc/grpc-js';
|
|
2
|
+
import { Tracer } from '@opentelemetry/api';
|
|
3
|
+
import { E as Error$1, T as Timestamp, C as CommitSessionDeltaRequest$1, R as ReleaseSessionRequest$1, a as ReserveSessionRequest$1 } from './adapter-D9T3yEEw.js';
|
|
4
|
+
|
|
5
|
+
/** Default p99-ceiling for `requestDecision` round-trip; design §4.2. */
|
|
6
|
+
declare const DEFAULT_DECISION_TIMEOUT_MS: 250;
|
|
7
|
+
/** Default handshake deadline; design §4.2. */
|
|
8
|
+
declare const DEFAULT_HANDSHAKE_TIMEOUT_MS: 2000;
|
|
9
|
+
/** Default deadline for `confirmPublishOutcome` / `release`; design §4.2. */
|
|
10
|
+
declare const DEFAULT_PUBLISH_TIMEOUT_MS: 150;
|
|
11
|
+
/** Default deadline for `emitTraceEvents` ack loop; design §4.2. */
|
|
12
|
+
declare const DEFAULT_TRACE_TIMEOUT_MS: 500;
|
|
13
|
+
/** Default capability the adapter advertises (L3_POLICY_HOOK = 0x40); design §4.2. */
|
|
14
|
+
declare const DEFAULT_CAPABILITY_LEVEL: 64;
|
|
15
|
+
/** Default protocol version (design §4.2); only `1` is wire-supported in v0.1.x. */
|
|
16
|
+
declare const DEFAULT_PROTOCOL_VERSION: 1;
|
|
17
|
+
/**
|
|
18
|
+
* Lightweight span record passed to the `onSpan` observer hook. The hook
|
|
19
|
+
* receives one record per RPC the client makes. Used by adapters that want
|
|
20
|
+
* to integrate with their own tracing pipeline without pulling
|
|
21
|
+
* `@opentelemetry/api`.
|
|
22
|
+
*/
|
|
23
|
+
interface SpanRecord {
|
|
24
|
+
/** RPC span name, e.g. `spendguard.reserve`. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Span start time in milliseconds since epoch. */
|
|
27
|
+
startTimeMs: number;
|
|
28
|
+
/** Span wall-clock duration in milliseconds. */
|
|
29
|
+
durationMs: number;
|
|
30
|
+
/** Span attributes (snake_case keys per OTel semantic conventions). */
|
|
31
|
+
attributes: Readonly<Record<string, string | number | boolean | undefined>>;
|
|
32
|
+
/** Set when the span completed with an error. */
|
|
33
|
+
error?: Error;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Constructor options for `SpendGuardClient`. This shape is part of the
|
|
37
|
+
* LOCKED public surface — design.md §4.2. Adapters in D04 / D06 / D08 / D29
|
|
38
|
+
* build directly against these fields; renames / removals require a v0.minor
|
|
39
|
+
* bump and a coordinated update of every adapter spec.
|
|
40
|
+
*
|
|
41
|
+
* Per design.md §5.2:
|
|
42
|
+
* - Explicit fields override env fallback.
|
|
43
|
+
* - Required (no default + no env) fields throw `SpendGuardConfigError` at
|
|
44
|
+
* constructor time.
|
|
45
|
+
*
|
|
46
|
+
* Two SLICE 3 deviations from design.md §4.2 worth noting:
|
|
47
|
+
* - `socketPath` is *optional* on this interface even though design §4.2
|
|
48
|
+
* marks it `required in v0.1.x`. The reason: design §5.2 requires env
|
|
49
|
+
* fallback to fill it, which only the constructor can do. The validator
|
|
50
|
+
* enforces presence post-merge.
|
|
51
|
+
* - `tenantId` is similarly optional here, required post-merge.
|
|
52
|
+
*/
|
|
53
|
+
interface SpendGuardClientConfig {
|
|
54
|
+
/** UDS path the sidecar listens on. Env fallback: `SPENDGUARD_SOCKET_PATH` then `SPENDGUARD_SIDECAR_UDS`. */
|
|
55
|
+
socketPath?: string;
|
|
56
|
+
/** Tenant id assertion the sidecar verifies at handshake time. Env fallback: `SPENDGUARD_TENANT_ID`. */
|
|
57
|
+
tenantId?: string;
|
|
58
|
+
/** Runtime kind tag e.g. `"langchain-js"` / `"vercel-ai-sdk"`; default `""`. */
|
|
59
|
+
runtimeKind?: string;
|
|
60
|
+
/** Optional runtime version tag (caller's own SDK version). */
|
|
61
|
+
runtimeVersion?: string;
|
|
62
|
+
/** Override the SDK version reported on the wire. Defaults to the package's `VERSION`. */
|
|
63
|
+
sdkVersion?: string;
|
|
64
|
+
/** Adapter↔sidecar protocol version; only `1` is wire-supported in v0.1.x. */
|
|
65
|
+
protocolVersion?: number;
|
|
66
|
+
/** Capability the adapter advertises; default `0x40` (L3_POLICY_HOOK). */
|
|
67
|
+
capabilityLevel?: number;
|
|
68
|
+
/** Stable workload-instance id; env fallback: `SPENDGUARD_WORKLOAD_INSTANCE_ID`. */
|
|
69
|
+
workloadInstanceId?: string;
|
|
70
|
+
/** Per-decision RPC deadline in ms; default 250; env fallback: `SPENDGUARD_DECISION_TIMEOUT_MS`. */
|
|
71
|
+
decisionTimeoutMs?: number;
|
|
72
|
+
/** Handshake RPC deadline in ms; default 2_000; env fallback: `SPENDGUARD_HANDSHAKE_TIMEOUT_MS`. */
|
|
73
|
+
handshakeTimeoutMs?: number;
|
|
74
|
+
/** Publish-side RPC deadline in ms; default 150. */
|
|
75
|
+
publishTimeoutMs?: number;
|
|
76
|
+
/** Trace-event RPC deadline in ms; default 500. */
|
|
77
|
+
traceTimeoutMs?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Observer hook invoked once per RPC. Mutually exclusive with `otelTracer`
|
|
80
|
+
* (constructor throws `SpendGuardConfigError` when both are set).
|
|
81
|
+
* Wired in SLICE 8.
|
|
82
|
+
*/
|
|
83
|
+
onSpan?: (span: SpanRecord) => void;
|
|
84
|
+
/**
|
|
85
|
+
* OTel Tracer. When provided, the client emits spans through it instead of
|
|
86
|
+
* `onSpan`. Mutually exclusive with `onSpan`.
|
|
87
|
+
* `@opentelemetry/api` is a `peerDependenciesMeta.optional` dep — adapters
|
|
88
|
+
* that never enable OTel pay zero dep cost. Wired in SLICE 8.
|
|
89
|
+
*/
|
|
90
|
+
otelTracer?: Tracer;
|
|
91
|
+
/**
|
|
92
|
+
* Forward-reserved transport selector. v0.1.x supports only `"uds-grpc"`;
|
|
93
|
+
* the literal type carries the slot for a future ASP HTTP gateway transport
|
|
94
|
+
* (design §9 decision 10) without a v0.minor bump.
|
|
95
|
+
*/
|
|
96
|
+
runtime?: "uds-grpc";
|
|
97
|
+
/**
|
|
98
|
+
* Test-only short-circuit: when set, every RPC method returns a no-op
|
|
99
|
+
* outcome without contacting the sidecar. **For tests only** — production
|
|
100
|
+
* users who set this and forget have silently lost enforcement. See
|
|
101
|
+
* design.md §5.1 `SPENDGUARD_DISABLE` for the env fallback. Wired in SLICE 4.
|
|
102
|
+
*/
|
|
103
|
+
disabled?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Default `run_projection` policy when callers do not pass one. Slice-doc
|
|
106
|
+
* addition; env fallback: `SPENDGUARD_RUN_PROJECTION_DEFAULT`. SLICE 4
|
|
107
|
+
* wires consumption: when set to a non-empty value, the resolved policy
|
|
108
|
+
* is folded into the `DecisionRequest.inputs.runtime_metadata` under the
|
|
109
|
+
* `run_projection_policy` key so the sidecar's projector observes the
|
|
110
|
+
* default per design.md §4.2 R2 amendment.
|
|
111
|
+
*/
|
|
112
|
+
runProjectionDefault?: RunProjectionPolicy;
|
|
113
|
+
/**
|
|
114
|
+
* In-process idempotency cache. When set, `reserve()` consults this cache
|
|
115
|
+
* before issuing the sidecar RPC: a hit short-circuits to the cached
|
|
116
|
+
* `DecisionOutcome`. SLICE 8 wires the consumption; see
|
|
117
|
+
* `InMemoryIdempotencyCache` / `NoopIdempotencyCache` from
|
|
118
|
+
* `@spendguard/sdk/cache` for ready-made impls. When `undefined`,
|
|
119
|
+
* `reserve()` issues every RPC unconditionally (the sidecar is still the
|
|
120
|
+
* correctness gate via its own idempotency-key dedup).
|
|
121
|
+
*/
|
|
122
|
+
idempotencyCache?: IdempotencyCache;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Default `run_projection` policy. Forward-extensible string-literal union per
|
|
126
|
+
* design.md §4.2 R2 amendment + COV_S05_03 R2 commitment MJ-1.
|
|
127
|
+
*
|
|
128
|
+
* v0.1.x ships:
|
|
129
|
+
* - `""` — leave policy unset; rely on contract-bundle default.
|
|
130
|
+
* - `"STRICT_CEILING"` — ASP Draft-01 strict-ceiling policy.
|
|
131
|
+
* - `"ELASTIC"` — ASP Draft-01 elastic policy.
|
|
132
|
+
*
|
|
133
|
+
* The third member `(string & {})` is the standard TS literal-string escape
|
|
134
|
+
* hatch: adapters can pass policy names that land in a future contract DSL
|
|
135
|
+
* bump without forcing a v0.minor on the SDK, while preserving completion
|
|
136
|
+
* suggestions for the two named members above.
|
|
137
|
+
*/
|
|
138
|
+
type RunProjectionPolicy = "" | "STRICT_CEILING" | "ELASTIC" | (string & {});
|
|
139
|
+
/**
|
|
140
|
+
* **LOCKED §4.1 spec name** for the constructor options. Identical to
|
|
141
|
+
* `SpendGuardClientConfig` — this alias preserves the symbol that
|
|
142
|
+
* design.md §4.1 line 45 enumerates as part of the consumer-facing barrel
|
|
143
|
+
* import for D04 / D06 / D08 / D29. The slice doc named the file-internal
|
|
144
|
+
* shape `SpendGuardClientConfig`; the spec name `SpendGuardClientOptions`
|
|
145
|
+
* is the cross-deliverable contract. Both resolve to the same shape so
|
|
146
|
+
* that `import { type SpendGuardClientOptions } from "@spendguard/sdk"`
|
|
147
|
+
* (the form every adapter spec uses) compiles unchanged.
|
|
148
|
+
*
|
|
149
|
+
* Added in COV_S05_03 R2 to close the spec-vs-slice-doc rename drift.
|
|
150
|
+
*/
|
|
151
|
+
type SpendGuardClientOptions = SpendGuardClientConfig;
|
|
152
|
+
/**
|
|
153
|
+
* Resolved config: every field that the SLICE 3 constructor promises to fill
|
|
154
|
+
* is non-optional here. The runtime validator narrows `SpendGuardClientConfig`
|
|
155
|
+
* to this stricter shape after merging env fallback + defaults.
|
|
156
|
+
*/
|
|
157
|
+
interface ResolvedConfig {
|
|
158
|
+
socketPath: string;
|
|
159
|
+
tenantId: string;
|
|
160
|
+
runtimeKind: string;
|
|
161
|
+
runtimeVersion: string;
|
|
162
|
+
sdkVersion: string;
|
|
163
|
+
protocolVersion: number;
|
|
164
|
+
capabilityLevel: number;
|
|
165
|
+
workloadInstanceId: string;
|
|
166
|
+
decisionTimeoutMs: number;
|
|
167
|
+
handshakeTimeoutMs: number;
|
|
168
|
+
publishTimeoutMs: number;
|
|
169
|
+
traceTimeoutMs: number;
|
|
170
|
+
onSpan?: (span: SpanRecord) => void;
|
|
171
|
+
otelTracer?: Tracer;
|
|
172
|
+
idempotencyCache?: IdempotencyCache;
|
|
173
|
+
runtime: "uds-grpc";
|
|
174
|
+
disabled: boolean;
|
|
175
|
+
runProjectionDefault: RunProjectionPolicy;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type SessionCommitOutcome = "SUCCESS" | "PROVIDER_ERROR" | "CLIENT_TIMEOUT" | "RUN_ABORTED";
|
|
179
|
+
declare const DEFAULT_MAX_PENDING_SESSION_DELTAS = 64;
|
|
180
|
+
interface ReserveSessionRequest {
|
|
181
|
+
tenantId: string;
|
|
182
|
+
budgetId: string;
|
|
183
|
+
windowInstanceId: string;
|
|
184
|
+
unit: UnitRef;
|
|
185
|
+
pricing: PricingFreeze;
|
|
186
|
+
sessionId: string;
|
|
187
|
+
route: string;
|
|
188
|
+
estimatedAmountAtomic: string;
|
|
189
|
+
ttlSeconds: number;
|
|
190
|
+
idempotencyKey: string;
|
|
191
|
+
}
|
|
192
|
+
interface CommitSessionDeltaRequest {
|
|
193
|
+
sessionReservationId: string;
|
|
194
|
+
streamingCommitId: string;
|
|
195
|
+
amountAtomicDelta: string;
|
|
196
|
+
outcome: SessionCommitOutcome;
|
|
197
|
+
eventTime: Date | number | Timestamp;
|
|
198
|
+
idempotencyKey: string;
|
|
199
|
+
}
|
|
200
|
+
interface ReleaseSessionRequest {
|
|
201
|
+
sessionReservationId: string;
|
|
202
|
+
reasonCode: string;
|
|
203
|
+
eventTime: Date | number | Timestamp;
|
|
204
|
+
idempotencyKey: string;
|
|
205
|
+
}
|
|
206
|
+
type ReserveSessionOutcome = {
|
|
207
|
+
kind: "accepted";
|
|
208
|
+
sessionReservationId: string;
|
|
209
|
+
ledgerTransactionId: string;
|
|
210
|
+
auditSessionEventId: string;
|
|
211
|
+
ttlExpiresAt: Date | null;
|
|
212
|
+
reservedAmountAtomic: string;
|
|
213
|
+
remainingAmountAtomic: string;
|
|
214
|
+
} | {
|
|
215
|
+
kind: "denied";
|
|
216
|
+
auditSessionEventId: string;
|
|
217
|
+
reasonCodes: readonly string[];
|
|
218
|
+
matchedRuleIds: readonly string[];
|
|
219
|
+
error?: Error$1;
|
|
220
|
+
};
|
|
221
|
+
interface CommitSessionDeltaOutcome {
|
|
222
|
+
sessionReservationId: string;
|
|
223
|
+
streamingCommitId: string;
|
|
224
|
+
ledgerTransactionId: string;
|
|
225
|
+
auditSessionEventId: string;
|
|
226
|
+
committedDeltaAtomic: string;
|
|
227
|
+
cumulativeCommittedAtomic: string;
|
|
228
|
+
remainingAmountAtomic: string;
|
|
229
|
+
recordedAt: Date | null;
|
|
230
|
+
}
|
|
231
|
+
interface ReleaseSessionOutcome {
|
|
232
|
+
sessionReservationId: string;
|
|
233
|
+
ledgerTransactionId: string;
|
|
234
|
+
auditSessionEventId: string;
|
|
235
|
+
releasedAmountAtomic: string;
|
|
236
|
+
committedAmountAtomic: string;
|
|
237
|
+
recordedAt: Date | null;
|
|
238
|
+
}
|
|
239
|
+
interface SessionDeltaCommitInput {
|
|
240
|
+
amountAtomicDelta: string;
|
|
241
|
+
outcome: SessionCommitOutcome;
|
|
242
|
+
eventTime: Date | number | Timestamp;
|
|
243
|
+
idempotencyKey?: string;
|
|
244
|
+
}
|
|
245
|
+
interface SessionReleaseInput {
|
|
246
|
+
reasonCode: string;
|
|
247
|
+
eventTime: Date | number | Timestamp;
|
|
248
|
+
idempotencyKey: string;
|
|
249
|
+
}
|
|
250
|
+
interface PendingSessionDelta {
|
|
251
|
+
sequence: number;
|
|
252
|
+
request: CommitSessionDeltaRequest;
|
|
253
|
+
}
|
|
254
|
+
interface SessionReservationHandleOptions {
|
|
255
|
+
sessionReservationId: string;
|
|
256
|
+
nextStreamingCommitSequence?: number;
|
|
257
|
+
maxPendingDeltas?: number;
|
|
258
|
+
pendingDeltas?: readonly PendingSessionDelta[];
|
|
259
|
+
released?: boolean;
|
|
260
|
+
}
|
|
261
|
+
interface SessionReservationHandleSnapshot {
|
|
262
|
+
sessionReservationId: string;
|
|
263
|
+
nextStreamingCommitSequence: number;
|
|
264
|
+
maxPendingDeltas: number;
|
|
265
|
+
released: boolean;
|
|
266
|
+
pendingDeltas: readonly PendingSessionDelta[];
|
|
267
|
+
}
|
|
268
|
+
interface SessionDeltaCommitClient {
|
|
269
|
+
commitSessionDelta(req: CommitSessionDeltaRequest): Promise<CommitSessionDeltaOutcome>;
|
|
270
|
+
}
|
|
271
|
+
interface SessionReleaseClient {
|
|
272
|
+
releaseSession(req: ReleaseSessionRequest): Promise<ReleaseSessionOutcome>;
|
|
273
|
+
}
|
|
274
|
+
declare class SessionReservationHandleError extends Error {
|
|
275
|
+
constructor(message: string);
|
|
276
|
+
}
|
|
277
|
+
declare class SessionPendingDeltaLimitError extends SessionReservationHandleError {
|
|
278
|
+
constructor(maxPendingDeltas: number);
|
|
279
|
+
}
|
|
280
|
+
declare class SessionReservationReleasedError extends SessionReservationHandleError {
|
|
281
|
+
constructor(sessionReservationId: string);
|
|
282
|
+
}
|
|
283
|
+
declare class SessionReservationReplayMismatchError extends SessionReservationHandleError {
|
|
284
|
+
constructor(message: string);
|
|
285
|
+
}
|
|
286
|
+
declare class SessionReservationHandle {
|
|
287
|
+
readonly sessionReservationId: string;
|
|
288
|
+
readonly maxPendingDeltas: number;
|
|
289
|
+
private nextStreamingCommitSequenceValue;
|
|
290
|
+
private pendingDeltaBuffer;
|
|
291
|
+
private releasedValue;
|
|
292
|
+
constructor(options: SessionReservationHandleOptions);
|
|
293
|
+
static fromSnapshot(snapshot: SessionReservationHandleSnapshot): SessionReservationHandle;
|
|
294
|
+
get nextStreamingCommitSequence(): number;
|
|
295
|
+
get released(): boolean;
|
|
296
|
+
get pendingDeltas(): readonly PendingSessionDelta[];
|
|
297
|
+
snapshot(): SessionReservationHandleSnapshot;
|
|
298
|
+
enqueueDelta(input: SessionDeltaCommitInput): PendingSessionDelta;
|
|
299
|
+
commitDelta(client: SessionDeltaCommitClient, input: SessionDeltaCommitInput): Promise<CommitSessionDeltaOutcome>;
|
|
300
|
+
replayPending(client: SessionDeltaCommitClient): Promise<CommitSessionDeltaOutcome[]>;
|
|
301
|
+
release(client: SessionReleaseClient, input: SessionReleaseInput): Promise<ReleaseSessionOutcome>;
|
|
302
|
+
private ackOutcome;
|
|
303
|
+
private assertOpen;
|
|
304
|
+
}
|
|
305
|
+
declare function buildReserveSessionRequest(req: ReserveSessionRequest): ReserveSessionRequest$1;
|
|
306
|
+
declare function buildCommitSessionDeltaRequest(req: CommitSessionDeltaRequest): CommitSessionDeltaRequest$1;
|
|
307
|
+
declare function buildReleaseSessionRequest(req: ReleaseSessionRequest): ReleaseSessionRequest$1;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Outcome of a successful handshake. SLICE 4 populates this; SLICE 3 only
|
|
311
|
+
* locks the type so `sessionId` / `handshakeOutcome` getters compile.
|
|
312
|
+
*/
|
|
313
|
+
interface HandshakeOutcome {
|
|
314
|
+
sessionId: string;
|
|
315
|
+
sidecarVersion: string;
|
|
316
|
+
schemaBundleId: string;
|
|
317
|
+
schemaBundleHash: Uint8Array;
|
|
318
|
+
contractBundleId: string;
|
|
319
|
+
contractBundleHash: Uint8Array;
|
|
320
|
+
capabilityRequired: number;
|
|
321
|
+
/**
|
|
322
|
+
* Key id the sidecar reports for `announcementSignature`. **Pass-through
|
|
323
|
+
* only in v0.1.x — NOT verified by the SDK.** See `announcementSignature`.
|
|
324
|
+
*/
|
|
325
|
+
signingKeyId: string;
|
|
326
|
+
/**
|
|
327
|
+
* Sidecar-supplied announcement signature.
|
|
328
|
+
*
|
|
329
|
+
* **PASS-THROUGH ONLY (v0.1.x): these bytes are surfaced verbatim and are
|
|
330
|
+
* NOT cryptographically verified by the SDK.** Trust rests entirely on the
|
|
331
|
+
* local UDS transport: the sidecar enforces the kernel `SO_PEERCRED`
|
|
332
|
+
* peer-credential check, which is the trust anchor under the documented
|
|
333
|
+
* local-socket threat model. The SDK does not (yet) verify
|
|
334
|
+
* `announcementSignature` against a pinned `signingKeyId` / control-plane
|
|
335
|
+
* public key.
|
|
336
|
+
*
|
|
337
|
+
* Pinned-key verification is deferred to the future non-local transport
|
|
338
|
+
* (the forward-reserved `runtime: "fetch"` / TLS path), where the UDS peer-
|
|
339
|
+
* cred anchor no longer applies. Until then, callers MUST NOT treat a
|
|
340
|
+
* non-empty `announcementSignature` as an independent proof of sidecar
|
|
341
|
+
* authenticity. Empty when the sidecar omits it.
|
|
342
|
+
*/
|
|
343
|
+
announcementSignature: Uint8Array;
|
|
344
|
+
}
|
|
345
|
+
interface UnitRef {
|
|
346
|
+
unit: string;
|
|
347
|
+
denomination: number;
|
|
348
|
+
/** Canonical-truth UUID of the ledger unit row.
|
|
349
|
+
*
|
|
350
|
+
* When provided, the SDK threads it verbatim onto `BudgetClaim.unit.unit_id`
|
|
351
|
+
* on the wire. When omitted, the SDK sends "" and the ledger reject with
|
|
352
|
+
* `INVALID_REQUEST: claim[N].unit.unit_id empty`.
|
|
353
|
+
*
|
|
354
|
+
* Adapters that issue ledger-backed `client.reserve()` MUST provide
|
|
355
|
+
* `unitId`. Recipe-style integrations (where no ledger reserve happens) MAY
|
|
356
|
+
* omit. The most common operator path is to set this from the
|
|
357
|
+
* `SPENDGUARD_UNIT_ID` env var at adapter construction time.
|
|
358
|
+
*
|
|
359
|
+
* NB: this is the ledger UUID, distinct from the free-form `unit` slug —
|
|
360
|
+
* they are NOT interchangeable. Multiple unit slugs can resolve to the same
|
|
361
|
+
* unit_id when migration aliasing is configured.
|
|
362
|
+
*/
|
|
363
|
+
unitId?: string;
|
|
364
|
+
}
|
|
365
|
+
interface BudgetClaim {
|
|
366
|
+
scopeId: string;
|
|
367
|
+
amountAtomic: string;
|
|
368
|
+
unit: UnitRef;
|
|
369
|
+
/** Canonical-truth UUID of the ledger window-instance row.
|
|
370
|
+
*
|
|
371
|
+
* When provided, the SDK threads it verbatim onto
|
|
372
|
+
* `BudgetClaim.window_instance_id` on the wire. When omitted, the SDK sends
|
|
373
|
+
* "" and the ledger rejects with
|
|
374
|
+
* `INVALID_REQUEST: claim[N].window_instance_id empty`.
|
|
375
|
+
*
|
|
376
|
+
* Adapters that issue ledger-backed `client.reserve()` MUST provide
|
|
377
|
+
* `windowInstanceId`. Recipe-style integrations (where no ledger reserve
|
|
378
|
+
* happens) MAY omit. The most common operator path is to set this from the
|
|
379
|
+
* `SPENDGUARD_WINDOW_INSTANCE_ID` env var at adapter construction time.
|
|
380
|
+
*
|
|
381
|
+
* Mirrors the HARDEN_D05_UR `UnitRef.unitId` broadening — same disease,
|
|
382
|
+
* same additive backward-compatible cure (HARDEN_D05_WI).
|
|
383
|
+
*/
|
|
384
|
+
windowInstanceId?: string;
|
|
385
|
+
}
|
|
386
|
+
interface PricingFreeze {
|
|
387
|
+
pricingVersion: string;
|
|
388
|
+
pricingHash: Uint8Array;
|
|
389
|
+
/** HARDEN_D05_WI — optional FX rate version of the pricing freeze tuple.
|
|
390
|
+
* When omitted the SDK sends "" (pre-HARDEN wire shape). Ledger commit
|
|
391
|
+
* validation compares the FULL tuple against the reservation's freeze, so
|
|
392
|
+
* adapters whose reservations carry a non-empty fx version MUST thread it. */
|
|
393
|
+
fxRateVersion?: string;
|
|
394
|
+
/** HARDEN_D05_WI — optional unit-conversion version (see fxRateVersion). */
|
|
395
|
+
unitConversionVersion?: string;
|
|
396
|
+
}
|
|
397
|
+
interface ClaimEstimate {
|
|
398
|
+
tokenizerTier?: "T1" | "T2" | "T3" | "";
|
|
399
|
+
tokenizerVersionId?: string;
|
|
400
|
+
inputTokens?: number | bigint;
|
|
401
|
+
predictedATokens?: number | bigint;
|
|
402
|
+
predictedBTokens?: number | bigint;
|
|
403
|
+
predictedCTokens?: number | bigint;
|
|
404
|
+
reservedStrategy?: "A" | "B" | "C" | "";
|
|
405
|
+
predictionStrategyUsed?: "A" | "B" | "C" | "";
|
|
406
|
+
predictionPolicyUsed?: string;
|
|
407
|
+
predictionConfidence?: number;
|
|
408
|
+
predictionSampleSize?: number | bigint;
|
|
409
|
+
coldStartLayerUsed?: "L1" | "L2" | "L3" | "L4" | "";
|
|
410
|
+
classifierVersion?: string;
|
|
411
|
+
fingerprintVersion?: string;
|
|
412
|
+
promptClassFingerprint?: string;
|
|
413
|
+
runProjectionAtDecisionAtomic?: number | bigint;
|
|
414
|
+
runPredictedRemainingSteps?: number;
|
|
415
|
+
runStepsCompletedSoFar?: number | bigint;
|
|
416
|
+
runCodeTriggered?: string;
|
|
417
|
+
model?: string;
|
|
418
|
+
promptClass?: string;
|
|
419
|
+
}
|
|
420
|
+
interface ReserveRequest {
|
|
421
|
+
trigger: "RUN_PRE" | "AGENT_STEP_PRE" | "LLM_CALL_PRE" | "TOOL_CALL_PRE";
|
|
422
|
+
runId: string;
|
|
423
|
+
stepId: string;
|
|
424
|
+
llmCallId: string;
|
|
425
|
+
toolCallId?: string;
|
|
426
|
+
decisionId: string;
|
|
427
|
+
route: string;
|
|
428
|
+
projectedClaims: BudgetClaim[];
|
|
429
|
+
idempotencyKey: string;
|
|
430
|
+
traceparent?: string;
|
|
431
|
+
tracestate?: string;
|
|
432
|
+
parentRunId?: string;
|
|
433
|
+
budgetGrantJti?: string;
|
|
434
|
+
projectedP50Atomic?: string;
|
|
435
|
+
projectedP90Atomic?: string;
|
|
436
|
+
projectedP95Atomic?: string;
|
|
437
|
+
projectedP99Atomic?: string;
|
|
438
|
+
projectedUnit?: UnitRef;
|
|
439
|
+
promptText?: string;
|
|
440
|
+
decisionContextJson?: Record<string, unknown>;
|
|
441
|
+
claimEstimate?: ClaimEstimate;
|
|
442
|
+
}
|
|
443
|
+
interface DecisionOutcome {
|
|
444
|
+
decisionId: string;
|
|
445
|
+
auditDecisionEventId: string;
|
|
446
|
+
decision: "CONTINUE" | "DEGRADE";
|
|
447
|
+
mutationPatchJson: string;
|
|
448
|
+
effectHash: Uint8Array;
|
|
449
|
+
ledgerTransactionId: string;
|
|
450
|
+
reservationIds: readonly string[];
|
|
451
|
+
ttlExpiresAtSeconds: number;
|
|
452
|
+
reasonCodes: readonly string[];
|
|
453
|
+
matchedRuleIds: readonly string[];
|
|
454
|
+
}
|
|
455
|
+
interface CommitEstimatedRequest {
|
|
456
|
+
runId: string;
|
|
457
|
+
stepId: string;
|
|
458
|
+
llmCallId: string;
|
|
459
|
+
decisionId: string;
|
|
460
|
+
reservationId: string;
|
|
461
|
+
estimatedAmountAtomic: string;
|
|
462
|
+
unit: UnitRef;
|
|
463
|
+
pricing: PricingFreeze;
|
|
464
|
+
providerEventId: string;
|
|
465
|
+
outcome: "SUCCESS" | "PROVIDER_ERROR" | "CLIENT_TIMEOUT" | "RUN_ABORTED";
|
|
466
|
+
actualInputTokens?: number;
|
|
467
|
+
actualOutputTokens?: number;
|
|
468
|
+
deltaBRatio?: number;
|
|
469
|
+
deltaCRatio?: number;
|
|
470
|
+
traceparent?: string;
|
|
471
|
+
tracestate?: string;
|
|
472
|
+
providerResponseMetadata?: string;
|
|
473
|
+
outcomeKind?: "SUCCESS" | "FAILURE";
|
|
474
|
+
/**
|
|
475
|
+
* `int64`-as-string, mirroring SLICE 4 `actualInputTokens` semantics for
|
|
476
|
+
* the multi-event path. When `outcomeKind` is set and this is provided,
|
|
477
|
+
* the outcome event carries the value verbatim; when omitted on the
|
|
478
|
+
* outcome event, the SDK reuses `actualInputTokens` from this request
|
|
479
|
+
* (avoids a double-spec for callers that already populate the SLICE 4
|
|
480
|
+
* field).
|
|
481
|
+
*/
|
|
482
|
+
actualInputTokensWire?: string;
|
|
483
|
+
/** `int64`-as-string companion of `actualOutputTokens` (see above). */
|
|
484
|
+
actualOutputTokensWire?: string;
|
|
485
|
+
/**
|
|
486
|
+
* Free-form error message threaded onto the outcome event's
|
|
487
|
+
* `providerResponseMetadata` JSON envelope as `{"error_message": "..."}`.
|
|
488
|
+
* Only consulted when `outcomeKind === "FAILURE"`; ignored on SUCCESS.
|
|
489
|
+
*/
|
|
490
|
+
actualErrorMessage?: string;
|
|
491
|
+
}
|
|
492
|
+
interface ReleaseRequest {
|
|
493
|
+
reservationId: string;
|
|
494
|
+
idempotencyKey: string;
|
|
495
|
+
reasonCodes?: readonly string[];
|
|
496
|
+
workloadInstanceId?: string;
|
|
497
|
+
tenantId?: string;
|
|
498
|
+
}
|
|
499
|
+
interface ReleaseOutcome {
|
|
500
|
+
/**
|
|
501
|
+
* Audit-chain signature for the release event, as reported by the sidecar.
|
|
502
|
+
*
|
|
503
|
+
* **PASS-THROUGH ONLY (v0.1.x): NOT cryptographically verified by the SDK.**
|
|
504
|
+
* Like `HandshakeOutcome.announcementSignature`, trust rests on the local
|
|
505
|
+
* UDS `SO_PEERCRED` peer-credential anchor; these are unvalidated bytes the
|
|
506
|
+
* adapter may forward but MUST NOT treat as independent proof. Pinned-key
|
|
507
|
+
* verification is deferred to the future non-local (TLS) transport. Empty
|
|
508
|
+
* when the sidecar omits it.
|
|
509
|
+
*/
|
|
510
|
+
auditEventSignature: Uint8Array;
|
|
511
|
+
ledgerTransactionId: string;
|
|
512
|
+
releasedReservationIds: readonly string[];
|
|
513
|
+
}
|
|
514
|
+
interface QueryBudgetRequest {
|
|
515
|
+
scopeId: string;
|
|
516
|
+
asOfSeconds?: number;
|
|
517
|
+
}
|
|
518
|
+
interface QueryBudgetResult {
|
|
519
|
+
availableAtomic: string;
|
|
520
|
+
reservedAtomic: string;
|
|
521
|
+
committedAtomic: string;
|
|
522
|
+
unit: UnitRef;
|
|
523
|
+
asOfSeconds: number;
|
|
524
|
+
}
|
|
525
|
+
interface PublishOutcomeRequest {
|
|
526
|
+
decisionId: string;
|
|
527
|
+
effectHash: Uint8Array;
|
|
528
|
+
outcome: "APPLIED" | "APPLIED_NOOP" | "APPLY_FAILED" | "APPROVAL_GRANTED" | "APPROVAL_DENIED" | "APPROVAL_TIMED_OUT";
|
|
529
|
+
adapterError?: string;
|
|
530
|
+
}
|
|
531
|
+
interface ApplyFailedRequest {
|
|
532
|
+
decisionId: string;
|
|
533
|
+
effectHash: Uint8Array;
|
|
534
|
+
adapterError: string;
|
|
535
|
+
}
|
|
536
|
+
interface ResumeAfterApprovalRequest {
|
|
537
|
+
approvalId: string;
|
|
538
|
+
tenantId: string;
|
|
539
|
+
decisionId: string;
|
|
540
|
+
workloadInstanceId?: string;
|
|
541
|
+
}
|
|
542
|
+
/** Alias for `CommitEstimatedRequest` exposed for the lower-level entry point. */
|
|
543
|
+
type EmitLlmCallPostRequest = CommitEstimatedRequest;
|
|
544
|
+
/**
|
|
545
|
+
* Async gRPC client for the SpendGuard sidecar over a Unix Domain Socket.
|
|
546
|
+
*
|
|
547
|
+
* SLICE 3 wired lifecycle + UDS transport; SLICE 4 wires handshake / reserve /
|
|
548
|
+
* commitEstimated bodies; SLICE 5 wires release / queryBudget. Adapters (D04 /
|
|
549
|
+
* D06 / D08 / D29) build against the LOCKED §4.2 surface and treat this class
|
|
550
|
+
* as their primary integration point.
|
|
551
|
+
*
|
|
552
|
+
* Usage:
|
|
553
|
+
*
|
|
554
|
+
* await using client = SpendGuardClient.fromEnv();
|
|
555
|
+
* await client.connect();
|
|
556
|
+
* const handshake = await client.handshake();
|
|
557
|
+
* const decision = await client.reserve({ ... });
|
|
558
|
+
* await client.commitEstimated({ ... });
|
|
559
|
+
* // `await using` runs `[Symbol.asyncDispose]` here → graceful close
|
|
560
|
+
*
|
|
561
|
+
* @example Test-only short-circuit (per design.md §5.1)
|
|
562
|
+
*
|
|
563
|
+
* // SPENDGUARD_DISABLE=1 in env, or:
|
|
564
|
+
* const client = new SpendGuardClient({
|
|
565
|
+
* socketPath: "/dev/null",
|
|
566
|
+
* tenantId: "test",
|
|
567
|
+
* disabled: true,
|
|
568
|
+
* });
|
|
569
|
+
* // Every RPC returns a no-op outcome; no UDS contact. **TESTS ONLY** —
|
|
570
|
+
* // a forgotten production setting silently loses enforcement.
|
|
571
|
+
*/
|
|
572
|
+
declare class SpendGuardClient implements AsyncDisposable {
|
|
573
|
+
/** Frozen, merged + validated configuration. */
|
|
574
|
+
private readonly cfg;
|
|
575
|
+
/** Active gRPC transport, or `null` before `connect()` / after `close()`. */
|
|
576
|
+
private transport;
|
|
577
|
+
/** Active SidecarAdapter gRPC client; mirrors `transport` lifetime. */
|
|
578
|
+
private adapterClient;
|
|
579
|
+
/** Cached handshake outcome; first `handshake()` populates, subsequent reads reuse. */
|
|
580
|
+
private handshakeResult;
|
|
581
|
+
/**
|
|
582
|
+
* Coalesces concurrent `handshake()` callers into a single in-flight RPC.
|
|
583
|
+
* Mirrors Python `self._handshake_lock` in `client.py`. Stays non-null only
|
|
584
|
+
* while a handshake RPC is pending; cleared after success or failure so a
|
|
585
|
+
* post-failure retry can re-enter.
|
|
586
|
+
*/
|
|
587
|
+
private handshakeInFlight;
|
|
588
|
+
/**
|
|
589
|
+
* Construct a client. Per design.md §5.2: explicit options win over env
|
|
590
|
+
* fallback; required fields without either throw `SpendGuardConfigError`
|
|
591
|
+
* immediately.
|
|
592
|
+
*/
|
|
593
|
+
constructor(rawOpts?: SpendGuardClientConfig);
|
|
594
|
+
/**
|
|
595
|
+
* Wrap an RPC body in the configured observability hook(s). Threads BOTH
|
|
596
|
+
* `cfg.otelTracer` and `cfg.onSpan` into `withOtelSpan` so every RPC site
|
|
597
|
+
* gets span emission uniformly — the documented `onSpan` observer (the
|
|
598
|
+
* no-OTel-dep path) is invoked once per RPC, fixing the prior drift where
|
|
599
|
+
* `onSpan` was stored but never called. The two are mutually exclusive at
|
|
600
|
+
* config-validation time, so at most one fires.
|
|
601
|
+
*/
|
|
602
|
+
private withSpan;
|
|
603
|
+
/**
|
|
604
|
+
* Convenience factory that reads required config from env vars and falls
|
|
605
|
+
* back to `/var/run/spendguard/adapter.sock` when `SPENDGUARD_SOCKET_PATH`
|
|
606
|
+
* is unset.
|
|
607
|
+
*
|
|
608
|
+
* Env vars consumed:
|
|
609
|
+
* - `SPENDGUARD_SOCKET_PATH` (slice-doc alias) / `SPENDGUARD_SIDECAR_UDS`
|
|
610
|
+
* (design §5.1) — UDS path; defaults to `/var/run/spendguard/adapter.sock`.
|
|
611
|
+
* - `SPENDGUARD_TENANT_ID` — **required**; throws `SpendGuardConfigError`
|
|
612
|
+
* when unset.
|
|
613
|
+
* - `SPENDGUARD_RUN_PROJECTION_DEFAULT` — optional default
|
|
614
|
+
* `run_projection` policy name; SLICE 4 wires consumption.
|
|
615
|
+
* - `SPENDGUARD_WORKLOAD_INSTANCE_ID` / `SPENDGUARD_DECISION_TIMEOUT_MS`
|
|
616
|
+
* / `SPENDGUARD_HANDSHAKE_TIMEOUT_MS` / `SPENDGUARD_DISABLE` — optional
|
|
617
|
+
* per design §5.1.
|
|
618
|
+
*
|
|
619
|
+
* Extra options provided as the `overrides` argument win over env per
|
|
620
|
+
* design.md §5.2.
|
|
621
|
+
*
|
|
622
|
+
* @throws SpendGuardConfigError when `SPENDGUARD_TENANT_ID` is missing.
|
|
623
|
+
*/
|
|
624
|
+
static fromEnv(overrides?: SpendGuardClientConfig): SpendGuardClient;
|
|
625
|
+
/**
|
|
626
|
+
* Open the UDS gRPC channel. Idempotent — a second call when already
|
|
627
|
+
* connected is a no-op.
|
|
628
|
+
*
|
|
629
|
+
* Per design.md §6.3 / Python `client.py:240-251`, the `unix:` URI scheme
|
|
630
|
+
* is used and `grpc.default_authority=localhost` is set so the tonic-based
|
|
631
|
+
* sidecar accepts the HTTP/2 `:authority` pseudo-header. Without this
|
|
632
|
+
* channel option, tonic resets every stream with `PROTOCOL_ERROR`.
|
|
633
|
+
*
|
|
634
|
+
* In disabled mode (`SPENDGUARD_DISABLE=1` or `disabled: true`), no
|
|
635
|
+
* transport is opened — the call returns immediately. Subsequent RPCs
|
|
636
|
+
* short-circuit to no-op outcomes in SLICE 4.
|
|
637
|
+
*
|
|
638
|
+
* @throws SpendGuardConnectionError when the underlying transport could
|
|
639
|
+
* not be opened (e.g. malformed socket path).
|
|
640
|
+
*/
|
|
641
|
+
connect(): Promise<void>;
|
|
642
|
+
/**
|
|
643
|
+
* Graceful close. Idempotent — calling `close()` twice (or `close()` before
|
|
644
|
+
* `connect()`) does not throw.
|
|
645
|
+
*
|
|
646
|
+
* On success the transport is dropped; the next `connect()` allocates a new
|
|
647
|
+
* channel. In-flight RPCs may complete with a `CANCELLED` status; SLICE 8
|
|
648
|
+
* adds the grace-period drain semantics that mirror the Python SDK's
|
|
649
|
+
* `await ch.close(grace=0.5)` path.
|
|
650
|
+
*/
|
|
651
|
+
close(): Promise<void>;
|
|
652
|
+
/**
|
|
653
|
+
* ESM 2024 `await using` hook. Equivalent to `await this.close()`.
|
|
654
|
+
*
|
|
655
|
+
* Usage:
|
|
656
|
+
*
|
|
657
|
+
* await using client = new SpendGuardClient({ ... });
|
|
658
|
+
* // ... use client ...
|
|
659
|
+
* // [Symbol.asyncDispose] runs here automatically.
|
|
660
|
+
*/
|
|
661
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
662
|
+
/** The tenant id this client asserted at construction. Stable for the client's lifetime. */
|
|
663
|
+
get tenantId(): string;
|
|
664
|
+
/**
|
|
665
|
+
* The negotiated session id. Throws `HandshakeError` until `handshake()`
|
|
666
|
+
* has completed (SLICE 4 wires the handshake; until then the getter is
|
|
667
|
+
* effectively unusable, which is intentional — adapters should call
|
|
668
|
+
* `handshake()` before reading state).
|
|
669
|
+
*
|
|
670
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
671
|
+
*/
|
|
672
|
+
get sessionId(): string;
|
|
673
|
+
/** Full handshake outcome. Throws `HandshakeError` until `handshake()` completes. */
|
|
674
|
+
get handshakeOutcome(): HandshakeOutcome;
|
|
675
|
+
/** Whether the client is currently connected to the sidecar. */
|
|
676
|
+
get isConnected(): boolean;
|
|
677
|
+
/** Frozen view of the resolved configuration. Useful for tests + debugging. */
|
|
678
|
+
get config(): Readonly<ResolvedConfig>;
|
|
679
|
+
/**
|
|
680
|
+
* Mandatory initial handshake. Idempotent — a second call returns the cached
|
|
681
|
+
* outcome without re-issuing the RPC (design.md §4.5). Concurrent callers
|
|
682
|
+
* are coalesced into the same in-flight RPC via `handshakeInFlight`.
|
|
683
|
+
*
|
|
684
|
+
* Disabled mode (`SPENDGUARD_DISABLE=1` / `disabled: true`) short-circuits
|
|
685
|
+
* to a synthetic `HandshakeOutcome` so unit tests can run without a real
|
|
686
|
+
* sidecar (`makeDisabledHandshake`).
|
|
687
|
+
*
|
|
688
|
+
* @throws HandshakeError on protocol-version mismatch or insufficient
|
|
689
|
+
* capability advertisement.
|
|
690
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
691
|
+
* @throws SpendGuardError on any other gRPC failure surface.
|
|
692
|
+
*/
|
|
693
|
+
handshake(opts?: {
|
|
694
|
+
workloadInstanceId?: string;
|
|
695
|
+
}): Promise<HandshakeOutcome>;
|
|
696
|
+
/**
|
|
697
|
+
* Internal: issue the real Handshake RPC and map the response.
|
|
698
|
+
*
|
|
699
|
+
* Splits out from `handshake()` so the idempotency guard there stays
|
|
700
|
+
* obviously correct: `doHandshake` never reads `handshakeResult` itself;
|
|
701
|
+
* it only writes it on success.
|
|
702
|
+
*/
|
|
703
|
+
private doHandshake;
|
|
704
|
+
/**
|
|
705
|
+
* Run a `*.pre` decision boundary through the sidecar. Equivalent to the
|
|
706
|
+
* Python SDK's `request_decision` (design.md §4.7).
|
|
707
|
+
*
|
|
708
|
+
* The wire shape is built in `buildDecisionRequest()` and consumes:
|
|
709
|
+
* - the cached handshake `sessionId` (auto-handshakes on first use),
|
|
710
|
+
* - the caller-supplied `idempotencyKey` (REQUIRED — see design §6.5),
|
|
711
|
+
* - `runProjectionDefault` from config when the caller did not pass
|
|
712
|
+
* one in `decisionContextJson.run_projection_policy` (closes MJ-1).
|
|
713
|
+
*
|
|
714
|
+
* The response is mapped through `mapDecisionResponse()`: CONTINUE / DEGRADE
|
|
715
|
+
* return a `DecisionOutcome`; STOP / STOP_RUN_PROJECTION / SKIP /
|
|
716
|
+
* REQUIRE_APPROVAL raise the matching typed exception so adapters can route
|
|
717
|
+
* on `instanceof DecisionDenied` (and its subclasses) per review-standards §5.
|
|
718
|
+
*
|
|
719
|
+
* @throws DecisionStopped on STOP / STOP_RUN_PROJECTION.
|
|
720
|
+
* @throws DecisionSkipped on SKIP.
|
|
721
|
+
* @throws ApprovalRequired on REQUIRE_APPROVAL — `await err.resume(client)`
|
|
722
|
+
* surfaces the operator decision.
|
|
723
|
+
* @throws DecisionDenied on an unknown decision enum.
|
|
724
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
725
|
+
* @throws SpendGuardError on any other gRPC failure surface.
|
|
726
|
+
*/
|
|
727
|
+
reserve(req: ReserveRequest): Promise<DecisionOutcome>;
|
|
728
|
+
/**
|
|
729
|
+
* Alias for `reserve()` — identical function reference (review-standards §1.5
|
|
730
|
+
* P0 BLOCKER). The Python SDK exposes the symbol as `request_decision`; the
|
|
731
|
+
* TS surface keeps `reserve` as the canonical name AND exposes
|
|
732
|
+
* `requestDecision` so cross-language docs work without surprise.
|
|
733
|
+
*
|
|
734
|
+
* Implemented as an instance-field initializer that reads `this.reserve`
|
|
735
|
+
* during construction. The dot-lookup on `this.reserve` (inside the field
|
|
736
|
+
* initializer, before any instance shadow exists) resolves to the prototype
|
|
737
|
+
* method `SpendGuardClient.prototype.reserve`. Assigning it to the field
|
|
738
|
+
* makes `client.requestDecision === client.reserve` Boolean-true at runtime
|
|
739
|
+
* (both resolve to the same prototype function reference).
|
|
740
|
+
*
|
|
741
|
+
* NOTE on `.bind(this)`: implementation.md §4 line 581 sketches the field
|
|
742
|
+
* as `this.reserve.bind(this)`. The literal `bind` would produce a NEW
|
|
743
|
+
* function object and break the §1.5 identity gate; the constraint cited
|
|
744
|
+
* by the slice doc (review-standards §1.5 P0 BLOCKER) wins, so we drop
|
|
745
|
+
* `.bind(this)`. Callers always invoke as `client.requestDecision(req)`
|
|
746
|
+
* (method-call form) which preserves `this` via JS dispatch semantics —
|
|
747
|
+
* the bind was over-specification for the Pythonic detached-method
|
|
748
|
+
* pattern, which the TS SDK does not advertise.
|
|
749
|
+
*
|
|
750
|
+
* NOTE: do NOT add a JSDoc `@throws` block here — TypeScript erases JSDoc
|
|
751
|
+
* from runtime fields and the identity invariant is the primary contract
|
|
752
|
+
* this declaration enforces.
|
|
753
|
+
*/
|
|
754
|
+
readonly requestDecision: SpendGuardClient["reserve"];
|
|
755
|
+
/**
|
|
756
|
+
* Commit an estimated LLM-call outcome. Equivalent to the Python SDK's
|
|
757
|
+
* `emit_llm_call_post` with `estimated_amount_atomic` (design.md §4.8).
|
|
758
|
+
*
|
|
759
|
+
* Single-event LlmCallPostPayload over the EmitTraceEvents duplex stream:
|
|
760
|
+
* the client opens a fresh stream per commit, sends one event, awaits one
|
|
761
|
+
* ack, and closes (Python parity — `emit_llm_call_post` at client.py:818).
|
|
762
|
+
* SLICE 5+ may switch to a long-lived stream for production latency, but
|
|
763
|
+
* the per-event setup cost is acceptable in v0.1.x.
|
|
764
|
+
*
|
|
765
|
+
* Ack semantics: the sidecar emits exactly one `TraceEventAck` per inbound
|
|
766
|
+
* event in this POC. Status != ACCEPTED surfaces as `SpendGuardError`
|
|
767
|
+
* (Codex round-2 P1.1 from Python parity — silent failure here would mask
|
|
768
|
+
* a commit-lifecycle bug).
|
|
769
|
+
*
|
|
770
|
+
* Mutually exclusive with the deferred provider-report path: this method
|
|
771
|
+
* always sends `estimated_amount_atomic`; the `provider_reported_amount_atomic`
|
|
772
|
+
* wire field stays empty. Adapters needing the provider-report path use the
|
|
773
|
+
* lower-level `emitLlmCallPost` (SLICE 7+).
|
|
774
|
+
*
|
|
775
|
+
* **SLICE 5 multi-event extension.** When `req.outcomeKind` is set, the
|
|
776
|
+
* client emits TWO events on the same bidi stream — the original
|
|
777
|
+
* LLM_CALL_POST event first, then a second LLM_CALL_POST-kind event whose
|
|
778
|
+
* `outcome` field reflects `outcomeKind` (SUCCESS → SUCCESS,
|
|
779
|
+
* FAILURE → PROVIDER_ERROR) and whose `providerResponseMetadata` carries a
|
|
780
|
+
* `{"error_message": ...}` envelope when `actualErrorMessage` is supplied.
|
|
781
|
+
* Both events are acked individually; if either ack is non-ACCEPTED the
|
|
782
|
+
* method raises `SpendGuardError`. See the JSDoc on
|
|
783
|
+
* `CommitEstimatedRequest.outcomeKind` for the LLM_CALL_OUTCOME proto-kind
|
|
784
|
+
* deviation note (Declared Deviation #1 in SLICE 5).
|
|
785
|
+
*
|
|
786
|
+
* When `req.outcomeKind` is ABSENT, behaviour is identical to SLICE 4 —
|
|
787
|
+
* a single event is sent, a single ack is drained.
|
|
788
|
+
*
|
|
789
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
790
|
+
* @throws SpendGuardError on rejected ack or any other gRPC failure surface.
|
|
791
|
+
*/
|
|
792
|
+
commitEstimated(req: CommitEstimatedRequest): Promise<void>;
|
|
793
|
+
/**
|
|
794
|
+
* Explicit release of a held reservation. Matches Agent Spend Protocol
|
|
795
|
+
* Draft-01 §4 one-to-one (the proto wire's
|
|
796
|
+
* `ReleaseReservationRequest` carries the canonical ASP fields at tags 1-3
|
|
797
|
+
* and SpendGuard extensions at tag 100+).
|
|
798
|
+
*
|
|
799
|
+
* Behaviour:
|
|
800
|
+
* - Disabled-mode short-circuit returns a synthetic
|
|
801
|
+
* `makeDisabledReleaseOutcome(req)` — no UDS contact.
|
|
802
|
+
* - Pre-handshake call throws `HandshakeError` via the `sessionId` getter
|
|
803
|
+
* gate (the request envelope requires the negotiated session id).
|
|
804
|
+
* - Wire envelope built by `buildReleaseRequest(req, sessionId)`.
|
|
805
|
+
* - Response mapped by `mapReleaseResponse(res, decisionIdHint)`.
|
|
806
|
+
* - Errors mapped centrally through `mapGrpcStatusToError`, with the
|
|
807
|
+
* `release`-specific NOT_FOUND override (reservation lookup misses
|
|
808
|
+
* surface as a plain `SpendGuardError("reservation not found")` so
|
|
809
|
+
* adapters can distinguish "no such reservation" from the rich
|
|
810
|
+
* FAILED_PRECONDITION cluster).
|
|
811
|
+
*
|
|
812
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
813
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
814
|
+
* @throws MutationApplyFailed on FAILED_PRECONDITION + IDEMPOTENCY_CONFLICT
|
|
815
|
+
* or BUDGET_EXCEEDED — or an unknown FAILED_PRECONDITION reason (the
|
|
816
|
+
* conservative default; never bare `SpendGuardError` for this cluster).
|
|
817
|
+
* @throws ApprovalBundleHotReloadedError on FAILED_PRECONDITION +
|
|
818
|
+
* BUNDLE_HOT_RELOADED.
|
|
819
|
+
* @throws SpendGuardError on NOT_FOUND ("reservation not found") + any
|
|
820
|
+
* other unmapped gRPC failure.
|
|
821
|
+
*/
|
|
822
|
+
release(req: ReleaseRequest): Promise<ReleaseOutcome>;
|
|
823
|
+
/**
|
|
824
|
+
* Reserve a session-scoped hold for a realtime voice session (D41 SR-V3).
|
|
825
|
+
*
|
|
826
|
+
* The public request shape mirrors `buildReserveSessionRequest`. When
|
|
827
|
+
* `req.sessionId` is empty, the SDK fills it from the completed sidecar
|
|
828
|
+
* handshake so adapter code can bind the session reservation to the active
|
|
829
|
+
* UDS session without duplicating handshake plumbing.
|
|
830
|
+
*
|
|
831
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
832
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
833
|
+
* @throws SpendGuardError on proto error outcome or unmapped gRPC failure.
|
|
834
|
+
*/
|
|
835
|
+
reserveSession(req: ReserveSessionRequest): Promise<ReserveSessionOutcome>;
|
|
836
|
+
/**
|
|
837
|
+
* Commit one positive streaming spend delta against a session reservation.
|
|
838
|
+
*
|
|
839
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
840
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
841
|
+
* @throws SpendGuardError on proto error outcome or unmapped gRPC failure.
|
|
842
|
+
*/
|
|
843
|
+
commitSessionDelta(req: CommitSessionDeltaRequest): Promise<CommitSessionDeltaOutcome>;
|
|
844
|
+
/**
|
|
845
|
+
* Release the uncommitted remainder of a session reservation.
|
|
846
|
+
*
|
|
847
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
848
|
+
* @throws SidecarUnavailable on UNAVAILABLE / DEADLINE_EXCEEDED / CANCELLED.
|
|
849
|
+
* @throws SpendGuardError on proto error outcome or unmapped gRPC failure.
|
|
850
|
+
*/
|
|
851
|
+
releaseSession(req: ReleaseSessionRequest): Promise<ReleaseSessionOutcome>;
|
|
852
|
+
/**
|
|
853
|
+
* Read-only budget snapshot. Locked decision #4 of design.md §9: in v0.1.x
|
|
854
|
+
* the substrate ships the method signature but the sidecar wire is NOT yet
|
|
855
|
+
* implemented. SLICE 5 wires the §9.4 placeholder body — adapters call this
|
|
856
|
+
* method, catch the explicit `SpendGuardError`, and surface a clear "feature
|
|
857
|
+
* not yet available" upstream rather than a stray NOT_FOUND from a missing
|
|
858
|
+
* RPC route.
|
|
859
|
+
*
|
|
860
|
+
* Disabled-mode short-circuit returns a synthetic
|
|
861
|
+
* `makeDisabledQueryBudgetResult(req)` so unit tests can program against
|
|
862
|
+
* the method without a sidecar.
|
|
863
|
+
*
|
|
864
|
+
* @throws HandshakeError before `handshake()` completes.
|
|
865
|
+
* @throws SpendGuardError carrying the tracking-issue URL otherwise.
|
|
866
|
+
*/
|
|
867
|
+
queryBudget(req: QueryBudgetRequest): Promise<QueryBudgetResult>;
|
|
868
|
+
/**
|
|
869
|
+
* Confirm `publish_effect` outcome. SLICE 7 wires body.
|
|
870
|
+
* @throws SpendGuardError until SLICE 7 wires the body.
|
|
871
|
+
*/
|
|
872
|
+
confirmPublishOutcome(req: PublishOutcomeRequest): Promise<string>;
|
|
873
|
+
/**
|
|
874
|
+
* Resume after a human approver acted on a `REQUIRE_APPROVAL` decision.
|
|
875
|
+
* SLICE 7 wires the body; references this method from `ApprovalRequired.resume`.
|
|
876
|
+
* @throws SpendGuardError until SLICE 7 wires the body.
|
|
877
|
+
*/
|
|
878
|
+
resumeAfterApproval(req: ResumeAfterApprovalRequest): Promise<DecisionOutcome>;
|
|
879
|
+
/**
|
|
880
|
+
* Safe-ack the `APPLY_FAILED` publish outcome — swallows transport errors
|
|
881
|
+
* so the caller's original exception is never shadowed. SLICE 7 wires body.
|
|
882
|
+
* @throws SpendGuardError until SLICE 7 wires the body.
|
|
883
|
+
*/
|
|
884
|
+
safeConfirmApplyFailed(req: ApplyFailedRequest): Promise<void>;
|
|
885
|
+
/**
|
|
886
|
+
* Lower-level entry point that `commitEstimated()` wraps. Provided so
|
|
887
|
+
* adapters that need the raw trace-event surface have access. SLICE 7
|
|
888
|
+
* wires body (the provider-report path).
|
|
889
|
+
* @throws SpendGuardError until SLICE 7 wires the body.
|
|
890
|
+
*/
|
|
891
|
+
emitLlmCallPost(req: EmitLlmCallPostRequest): Promise<void>;
|
|
892
|
+
/**
|
|
893
|
+
* Build the gRPC channel credentials. Always insecure over UDS — the kernel
|
|
894
|
+
* `SO_PEERCRED` check on the sidecar side is the trust anchor (Sidecar
|
|
895
|
+
* Architecture §5). TLS over a Unix socket adds overhead with no security
|
|
896
|
+
* benefit when the connection is implicitly local.
|
|
897
|
+
*
|
|
898
|
+
* Carved into its own method so a future slice can override under a
|
|
899
|
+
* `runtime` flag once HTTP-gateway transport is added — `runtime: "fetch"`
|
|
900
|
+
* would override to use TLS credentials. v0.1.x only supports `"uds-grpc"`.
|
|
901
|
+
*/
|
|
902
|
+
private buildChannelCredentials;
|
|
903
|
+
/**
|
|
904
|
+
* Translate the public `ReserveRequest` (camelCase, TS-idiomatic) into the
|
|
905
|
+
* snake_case-on-wire `DecisionRequest` proto. Per implementation.md §4:
|
|
906
|
+
*
|
|
907
|
+
* 1. SessionId from the cached handshake (caller already gated above).
|
|
908
|
+
* 2. Trigger enum mapping via `triggerEnumOf()`.
|
|
909
|
+
* 3. W3C `traceparent` → `TraceContext` via `buildTraceContext()` (matches
|
|
910
|
+
* Python `_build_trace_context`).
|
|
911
|
+
* 4. `runtimeMetadata` carries the prompt hash (when caller supplied
|
|
912
|
+
* `promptText`) and any `decisionContextJson` keys. The
|
|
913
|
+
* `run_projection_policy` slot is filled from the caller's
|
|
914
|
+
* `decisionContextJson.run_projection_policy` if present, otherwise
|
|
915
|
+
* from `cfg.runProjectionDefault` when non-empty. **This is the
|
|
916
|
+
* SLICE 4 consumption of MJ-1** — SLICE 3 stored the field on the
|
|
917
|
+
* config; this method wires it onto the wire.
|
|
918
|
+
* 5. `plannedStepsHint` is `plan.plannedCalls + plan.plannedTools` when
|
|
919
|
+
* a `withRunPlan` scope is active (SLICE 7 R2), otherwise the proto3
|
|
920
|
+
* default `0`.
|
|
921
|
+
*
|
|
922
|
+
* `runtime_metadata` is encoded as a hand-built `google.protobuf.Struct`
|
|
923
|
+
* payload because the SDK does not yet ship `computePromptHash` (SLICE 6).
|
|
924
|
+
* Until then this method ALWAYS sends an empty Struct body when no caller
|
|
925
|
+
* decoration is requested — matching Python `runtime_metadata = None` which
|
|
926
|
+
* is wire-equivalent to "field absent" under proto3 message optionality.
|
|
927
|
+
*/
|
|
928
|
+
private buildDecisionRequest;
|
|
929
|
+
/**
|
|
930
|
+
* Build the `google.protobuf.Struct` payload that lands in
|
|
931
|
+
* `DecisionRequest.inputs.runtime_metadata`. Returns `undefined` when there
|
|
932
|
+
* is nothing to send (proto3 message optionality — wire equivalent to
|
|
933
|
+
* "field absent").
|
|
934
|
+
*
|
|
935
|
+
* Two slots are populated here:
|
|
936
|
+
* - `decision_context_json.*` keys from the caller (verbatim).
|
|
937
|
+
* - `run_projection_policy` from the caller (if present in
|
|
938
|
+
* `decisionContextJson`) OR `cfg.runProjectionDefault` (when set and
|
|
939
|
+
* non-empty). The caller's value wins; the default only fills in when
|
|
940
|
+
* the caller did not provide one — matches design.md §4.2 R2 semantics.
|
|
941
|
+
*
|
|
942
|
+
* SLICE 6 R1 closure of SLICE 4 M-3: when `req.promptText` is set,
|
|
943
|
+
* `computePromptHash(req.promptText, this.cfg.tenantId)` populates
|
|
944
|
+
* `runtime_metadata.prompt_hash` as a stringValue. Mirrors Python parity
|
|
945
|
+
* at `sdk/python/.../client.py` (the `prompt_hash` field is the rules
|
|
946
|
+
* dedup key per Cost Advisor P0.5 §5.1). The caller may pre-set
|
|
947
|
+
* `decisionContextJson.prompt_hash` to override (e.g. when the prompt is
|
|
948
|
+
* tokenised upstream and the hash is computed there); the caller-supplied
|
|
949
|
+
* value wins.
|
|
950
|
+
*/
|
|
951
|
+
private buildRuntimeMetadataStruct;
|
|
952
|
+
/**
|
|
953
|
+
* Build the single LLM_CALL_POST trace event for `commitEstimated()`.
|
|
954
|
+
* Mirrors Python `emit_llm_call_post` at client.py:818 with the difference
|
|
955
|
+
* that `provider_reported_amount_atomic` is always empty here (the
|
|
956
|
+
* provider-report path lives in SLICE 5+'s `emitLlmCallPost`).
|
|
957
|
+
*/
|
|
958
|
+
private buildLlmCallPostEvent;
|
|
959
|
+
/**
|
|
960
|
+
* Build the SLICE 5 multi-event "outcome" companion event for
|
|
961
|
+
* `commitEstimated()` when `req.outcomeKind` is set.
|
|
962
|
+
*
|
|
963
|
+
* The event reuses `TraceEvent_EventKind.LLM_CALL_POST` (per Declared
|
|
964
|
+
* Deviation #1 — `LLM_CALL_OUTCOME` does not exist as a proto enum value
|
|
965
|
+
* in `sidecar_adapter/v1/adapter.proto` yet).
|
|
966
|
+
*
|
|
967
|
+
* TODO(GH-issue-TBD): proto bump for `LLM_CALL_OUTCOME` +
|
|
968
|
+
* sidecar `x-spendguard-reason-code` trailer extension. Both deferred to
|
|
969
|
+
* the cross-component slice that touches `proto/` and
|
|
970
|
+
* `services/sidecar/`. Track at
|
|
971
|
+
* https://github.com/m24927605/agentic-spendguard/issues/TBD-proto-bump-llm-call-outcome
|
|
972
|
+
* (R2 follow-up; not in scope for D05 SLICE 5).
|
|
973
|
+
*
|
|
974
|
+
* The `outcome` field on the inner `LlmCallPostPayload` carries the
|
|
975
|
+
* semantic:
|
|
976
|
+
*
|
|
977
|
+
* - `outcomeKind === "SUCCESS"` → `LlmCallPostPayload_Outcome.SUCCESS`
|
|
978
|
+
* - `outcomeKind === "FAILURE"` → `LlmCallPostPayload_Outcome.PROVIDER_ERROR`
|
|
979
|
+
*
|
|
980
|
+
* Actuals (`actualInputTokens` / `actualOutputTokens`) prefer the SLICE 5
|
|
981
|
+
* `*Wire` fields when supplied (int64-as-string form), falling back to
|
|
982
|
+
* the SLICE 4 numeric `actualInputTokens` / `actualOutputTokens` shape so
|
|
983
|
+
* adapters do not need to double-specify. `actualErrorMessage` is threaded
|
|
984
|
+
* onto `TraceEvent.providerResponseMetadata` as a JSON envelope
|
|
985
|
+
* `{"error_message": "..."}` only when `outcomeKind === "FAILURE"`.
|
|
986
|
+
*
|
|
987
|
+
* `estimatedAmountAtomic` is intentionally set to an empty string on the
|
|
988
|
+
* outcome event — the first event already booked the commit; the outcome
|
|
989
|
+
* event only carries observation, not commit semantics. The sidecar will
|
|
990
|
+
* surface a rejection ack if it requires the field (`mock-sidecar`'s tests
|
|
991
|
+
* lock this in).
|
|
992
|
+
*
|
|
993
|
+
* @private
|
|
994
|
+
*/
|
|
995
|
+
private buildLlmCallOutcomeEvent;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Stable, in-process hash of a `ReserveRequest`'s identity for binding
|
|
999
|
+
* idempotency-cache entries to the request body.
|
|
1000
|
+
*
|
|
1001
|
+
* Why: `cfg.idempotencyCache` is keyed on the caller-supplied
|
|
1002
|
+
* `idempotencyKey`. A cache HIT short-circuits the UDS round trip entirely, so
|
|
1003
|
+
* the sidecar (the correctness gate) never re-evaluates. If an adapter reuses
|
|
1004
|
+
* an `idempotencyKey` for a logically DIFFERENT reserve (different claims /
|
|
1005
|
+
* amount / route / window / pricing), a key-only cache would return the prior
|
|
1006
|
+
* CONTINUE — allowing spend against a stale decision the sidecar never saw.
|
|
1007
|
+
* Binding the entry to this hash makes a key-hit-with-different-body fall
|
|
1008
|
+
* through to the sidecar (see `InMemoryIdempotencyCache.get`).
|
|
1009
|
+
*
|
|
1010
|
+
* This hash is a LOCAL latency-optimisation discriminator only — it is NOT a
|
|
1011
|
+
* wire value and needs NO cross-language byte-equivalence. It MUST, however,
|
|
1012
|
+
* be deterministic within a process and cover every field that changes the
|
|
1013
|
+
* ledger/sidecar decision, so the local fall-through decision provably agrees
|
|
1014
|
+
* with the sidecar's accept / IDEMPOTENCY_CONFLICT decision. We therefore hash
|
|
1015
|
+
* the FULL request identity (all decision-affecting fields) via a canonical
|
|
1016
|
+
* JSON encoding with sorted keys.
|
|
1017
|
+
*
|
|
1018
|
+
* `idempotencyKey` is intentionally EXCLUDED from the hashed body (it is the
|
|
1019
|
+
* cache key, not part of the identity being disambiguated under that key).
|
|
1020
|
+
*
|
|
1021
|
+
* Exported for the cache-collision regression test; not part of the LOCKED
|
|
1022
|
+
* public surface (no index.ts re-export).
|
|
1023
|
+
*/
|
|
1024
|
+
declare function computeReserveBodyHash(req: ReserveRequest): string;
|
|
1025
|
+
/**
|
|
1026
|
+
* Lockable contract: `DomainError::Display` prefix → canonical reason code.
|
|
1027
|
+
*
|
|
1028
|
+
* Sourced from `services/sidecar/src/domain/error.rs` `#[error(...)]`
|
|
1029
|
+
* attributes (lines 26-45) — these strings ARE the public Status-message
|
|
1030
|
+
* format the sidecar emits via `Status::failed_precondition(self.to_string())`.
|
|
1031
|
+
* Match is case-insensitive prefix on the bare `RpcError.message`
|
|
1032
|
+
* (anchored at the start; the `: <detail>` tail varies per call).
|
|
1033
|
+
*
|
|
1034
|
+
* Forward-compat: the bracket-tagged `[BUNDLE_HOT_RELOADED]` form comes from
|
|
1035
|
+
* `services/sidecar/src/server/adapter_uds.rs:1379` (resume path) and is
|
|
1036
|
+
* included so dispatch already works when a future cross-component slice
|
|
1037
|
+
* unifies the release/resume Status surface.
|
|
1038
|
+
*
|
|
1039
|
+
* Ordered ARRAY (not a map): longest / most-specific prefixes FIRST so a
|
|
1040
|
+
* shorter prefix doesn't accidentally swallow a longer one.
|
|
1041
|
+
*/
|
|
1042
|
+
declare const REASON_CODE_PREFIXES: ReadonlyArray<readonly [string, string]>;
|
|
1043
|
+
/**
|
|
1044
|
+
* Context passed to `mapGrpcStatusToError`. The `rpc` field is folded into
|
|
1045
|
+
* the typed-error message; `releaseNotFoundAsPlain` opt-in flips NOT_FOUND
|
|
1046
|
+
* into a plain `SpendGuardError("reservation not found")` for the
|
|
1047
|
+
* `release()` call path (the only RPC that needs the override — `reserve` /
|
|
1048
|
+
* `handshake` / `commitEstimated` never legitimately surface NOT_FOUND).
|
|
1049
|
+
*/
|
|
1050
|
+
interface MapGrpcStatusContext {
|
|
1051
|
+
rpc: string;
|
|
1052
|
+
releaseNotFoundAsPlain?: boolean;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Idempotency cache contract. Adapters consume via
|
|
1057
|
+
* `SpendGuardClientConfig.idempotencyCache?: IdempotencyCache`.
|
|
1058
|
+
*
|
|
1059
|
+
* - `get(key, bodyHash?)` MUST return the cached outcome for `key` when it is
|
|
1060
|
+
* fresh (TTL-window unexpired) AND — when both the caller and the stored
|
|
1061
|
+
* entry carry a `bodyHash` — the hashes match; else `undefined`.
|
|
1062
|
+
* Implementations MAY treat the read as a recency signal (LRU
|
|
1063
|
+
* move-to-front); MUST NOT bump the TTL.
|
|
1064
|
+
* - `set(key, outcome, ttlMs?, bodyHash?)` stores the outcome. Optional `ttlMs`
|
|
1065
|
+
* overrides the cache's default TTL for this entry. Optional `bodyHash` binds
|
|
1066
|
+
* the entry to the request identity that produced `outcome` so a later
|
|
1067
|
+
* `get(key, otherHash)` with a colliding key but a different body falls
|
|
1068
|
+
* through (returns `undefined`) instead of returning a stale decision.
|
|
1069
|
+
* Implementations MAY evict in amortised O(1) to honor a `maxEntries` cap.
|
|
1070
|
+
* - `clear()` resets the cache to empty.
|
|
1071
|
+
* - `size` is a snapshot getter; readers MAY observe a non-monotonic value
|
|
1072
|
+
* across concurrent sets (no atomicity guarantee).
|
|
1073
|
+
*
|
|
1074
|
+
* The `bodyHash` parameters are OPTIONAL and additive: callers that omit them
|
|
1075
|
+
* get key-only semantics (unchanged from v0.1.0). `SpendGuardClient.reserve()`
|
|
1076
|
+
* always supplies a `bodyHash` so a reused `idempotencyKey` for a logically
|
|
1077
|
+
* different request never short-circuits the authoritative sidecar gate.
|
|
1078
|
+
*/
|
|
1079
|
+
interface IdempotencyCache {
|
|
1080
|
+
get(key: string, bodyHash?: string): DecisionOutcome | undefined;
|
|
1081
|
+
set(key: string, outcome: DecisionOutcome, ttlMs?: number, bodyHash?: string): void;
|
|
1082
|
+
clear(): void;
|
|
1083
|
+
readonly size: number;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Default cache cap. design.md §3 line 35 LOCKS this at "default 1024
|
|
1087
|
+
* entries". The number is a balance:
|
|
1088
|
+
* - 1024 entries × ~512 B/entry (DecisionOutcome with reservation IDs +
|
|
1089
|
+
* reason codes) → ~512 KiB ceiling. Fits comfortably in adapter
|
|
1090
|
+
* working-set budgets.
|
|
1091
|
+
* - 1024 is large enough to absorb a steady-state burst of in-flight
|
|
1092
|
+
* decisions (each adapter request issues at most one `reserve`).
|
|
1093
|
+
* - Override via `new InMemoryIdempotencyCache({ maxEntries: ... })`
|
|
1094
|
+
* when adapters know their working set is larger.
|
|
1095
|
+
*/
|
|
1096
|
+
declare const DEFAULT_CACHE_MAX_ENTRIES = 1024;
|
|
1097
|
+
/**
|
|
1098
|
+
* Default TTL. design.md §3 line 35 + implementation.md §10 LOCK this at
|
|
1099
|
+
* "TTL 5 minutes" — long enough to cover a same-process retry storm
|
|
1100
|
+
* (the SLICE 8 retry helper has a 50 ms ceiling per attempt × 2 attempts =
|
|
1101
|
+
* ≤100 ms — the cache TTL must dominate by 3-4 orders of magnitude to be
|
|
1102
|
+
* useful) but short enough that a cache-bypass-via-eviction edge case
|
|
1103
|
+
* doesn't propagate stale outcomes hours after the original reservation
|
|
1104
|
+
* was already released by TTL sweep on the sidecar side.
|
|
1105
|
+
*/
|
|
1106
|
+
declare const DEFAULT_CACHE_TTL_MS: number;
|
|
1107
|
+
/** Options for `InMemoryIdempotencyCache`. */
|
|
1108
|
+
interface InMemoryIdempotencyCacheOptions {
|
|
1109
|
+
/** Max entries before LRU eviction. Default 1024. */
|
|
1110
|
+
maxEntries?: number;
|
|
1111
|
+
/** Default per-entry TTL in ms. Default 300_000. Per-call `ttlMs` overrides. */
|
|
1112
|
+
defaultTtlMs?: number;
|
|
1113
|
+
/** Override the clock for tests. Default `Date.now`. */
|
|
1114
|
+
now?: () => number;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* In-memory LRU + TTL cache. Backed by a `Map` whose insertion order doubles
|
|
1118
|
+
* as the LRU axis:
|
|
1119
|
+
* - `set` deletes-then-inserts so the entry moves to the most-recently-used
|
|
1120
|
+
* end of the iteration order.
|
|
1121
|
+
* - `get` on a fresh entry also delete-then-inserts (LRU move-to-front).
|
|
1122
|
+
* - Eviction pops the oldest entry via `keys().next().value`.
|
|
1123
|
+
*
|
|
1124
|
+
* TTL is checked on every `get`: an expired entry is treated as a miss AND
|
|
1125
|
+
* is evicted immediately (lazy expiry — no background sweeper).
|
|
1126
|
+
*
|
|
1127
|
+
* Thread-safety: Node.js is single-threaded for user code. Worker_threads
|
|
1128
|
+
* cannot share `Map` references via structured clone, so the cache is
|
|
1129
|
+
* inherently per-thread. Multi-process deployments need an external cache;
|
|
1130
|
+
* see `IdempotencyCache` interface JSDoc.
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* const cache = new InMemoryIdempotencyCache({ maxEntries: 2048 });
|
|
1134
|
+
* const client = new SpendGuardClient({ idempotencyCache: cache, ... });
|
|
1135
|
+
*/
|
|
1136
|
+
declare class InMemoryIdempotencyCache implements IdempotencyCache {
|
|
1137
|
+
private readonly entries;
|
|
1138
|
+
private readonly maxEntries;
|
|
1139
|
+
private readonly defaultTtlMs;
|
|
1140
|
+
private readonly now;
|
|
1141
|
+
constructor(opts?: InMemoryIdempotencyCacheOptions);
|
|
1142
|
+
get(key: string, bodyHash?: string): DecisionOutcome | undefined;
|
|
1143
|
+
set(key: string, outcome: DecisionOutcome, ttlMs?: number, bodyHash?: string): void;
|
|
1144
|
+
clear(): void;
|
|
1145
|
+
get size(): number;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* No-op cache impl. Always misses on `get`; `set` discards. Useful for:
|
|
1149
|
+
* - Tests that want to assert every reserved decision hits the sidecar.
|
|
1150
|
+
* - Disabled-mode short-circuit (when `SPENDGUARD_DISABLE=1`, the client
|
|
1151
|
+
* never reaches the cache path, but `NoopIdempotencyCache` is the
|
|
1152
|
+
* conservative-default if an adapter passes `idempotencyCache: undefined`
|
|
1153
|
+
* and wants explicit no-cache semantics).
|
|
1154
|
+
*
|
|
1155
|
+
* The `size` getter always returns 0; `clear()` is a no-op.
|
|
1156
|
+
*/
|
|
1157
|
+
declare class NoopIdempotencyCache implements IdempotencyCache {
|
|
1158
|
+
get(_key: string, _bodyHash?: string): DecisionOutcome | undefined;
|
|
1159
|
+
set(_key: string, _outcome: DecisionOutcome, _ttlMs?: number, _bodyHash?: string): void;
|
|
1160
|
+
clear(): void;
|
|
1161
|
+
get size(): number;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export { type UnitRef as $, type ApplyFailedRequest as A, type BudgetClaim as B, type ClaimEstimate as C, DEFAULT_CACHE_MAX_ENTRIES as D, type EmitLlmCallPostRequest as E, type SessionDeltaCommitClient as F, type SessionDeltaCommitInput as G, type HandshakeOutcome as H, type IdempotencyCache as I, SessionPendingDeltaLimitError as J, type SessionReleaseClient as K, type SessionReleaseInput as L, SessionReservationHandle as M, NoopIdempotencyCache as N, SessionReservationHandleError as O, type PendingSessionDelta as P, type QueryBudgetRequest as Q, type RunProjectionPolicy as R, type SessionCommitOutcome as S, type SessionReservationHandleOptions as T, type SessionReservationHandleSnapshot as U, SessionReservationReleasedError as V, SessionReservationReplayMismatchError as W, type SpanRecord as X, SpendGuardClient as Y, type SpendGuardClientConfig as Z, type SpendGuardClientOptions as _, type CommitEstimatedRequest as a, buildCommitSessionDeltaRequest as a0, buildReleaseSessionRequest as a1, buildReserveSessionRequest as a2, type MapGrpcStatusContext as a3, REASON_CODE_PREFIXES as a4, computeReserveBodyHash as a5, type CommitSessionDeltaOutcome as b, type CommitSessionDeltaRequest as c, DEFAULT_CACHE_TTL_MS as d, DEFAULT_CAPABILITY_LEVEL as e, DEFAULT_DECISION_TIMEOUT_MS as f, DEFAULT_HANDSHAKE_TIMEOUT_MS as g, DEFAULT_MAX_PENDING_SESSION_DELTAS as h, DEFAULT_PROTOCOL_VERSION as i, DEFAULT_PUBLISH_TIMEOUT_MS as j, DEFAULT_TRACE_TIMEOUT_MS as k, type DecisionOutcome as l, InMemoryIdempotencyCache as m, type InMemoryIdempotencyCacheOptions as n, type PricingFreeze as o, type PublishOutcomeRequest as p, type QueryBudgetResult as q, type ReleaseOutcome as r, type ReleaseRequest as s, type ReleaseSessionOutcome as t, type ReleaseSessionRequest as u, type ReserveRequest as v, type ReserveSessionOutcome as w, type ReserveSessionRequest as x, type ResolvedConfig as y, type ResumeAfterApprovalRequest as z };
|