@cortexkit/common-auth 0.2.9 → 0.4.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/dist/cachekeep/manager.d.ts +18 -6
- package/dist/cachekeep/manager.js +40 -10
- package/dist/claustrum/consumer.d.ts +13 -4
- package/dist/claustrum/consumer.js +11 -3
- package/dist/claustrum/custody.d.ts +47 -6
- package/dist/claustrum/custody.js +37 -7
- package/dist/claustrum/errors.d.ts +1 -1
- package/dist/claustrum/index.d.ts +3 -3
- package/dist/claustrum/index.js +2 -2
- package/dist/claustrum/interlock.d.ts +15 -17
- package/dist/claustrum/interlock.js +19 -26
- package/dist/claustrum/roster.d.ts +96 -6
- package/dist/claustrum/roster.js +209 -44
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +6 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/menu.d.ts +8 -0
- package/dist/commands/menu.js +34 -13
- package/dist/commands/model.d.ts +9 -0
- package/dist/commands/seam.d.ts +40 -4
- package/dist/commands/seam.js +132 -19
- package/dist/dump/index.d.ts +94 -0
- package/dist/dump/index.js +236 -9
- package/dist/logger/engine.d.ts +52 -17
- package/dist/logger/engine.js +178 -135
- package/dist/logger/index.d.ts +2 -2
- package/dist/logger/index.js +1 -1
- package/dist/opencode2/index.d.ts +1 -1
- package/dist/opencode2/install.d.ts +23 -5
- package/dist/opencode2/install.js +335 -140
- package/dist/opencode2/types.d.ts +149 -9
- package/dist/quota/projection.d.ts +11 -4
- package/dist/quota/projection.js +11 -4
- package/dist/routing/admission.js +3 -1
- package/dist/routing/index.d.ts +2 -2
- package/dist/routing/index.js +1 -1
- package/dist/routing/sticky.d.ts +19 -6
- package/dist/routing/sticky.js +34 -23
- package/dist/rpc/notifications.d.ts +20 -0
- package/dist/rpc/notifications.js +21 -0
- package/dist/rpc/rpc-server.d.ts +9 -1
- package/dist/rpc/rpc-server.js +8 -1
- package/dist/sidebar-file/index.d.ts +1 -1
- package/dist/sidebar-file/sidebar-file.d.ts +50 -2
- package/dist/sidebar-file/sidebar-file.js +92 -21
- package/dist/store/attribution.js +11 -2
- package/dist/store/errors.d.ts +6 -3
- package/dist/store/identity.d.ts +13 -4
- package/dist/store/mutate.d.ts +23 -3
- package/dist/store/mutate.js +43 -26
- package/dist/store/pool.d.ts +8 -1
- package/dist/store/pool.js +7 -2
- package/dist/store/rows.d.ts +17 -4
- package/dist/store/rows.js +82 -33
- package/dist/store/schema.d.ts +57 -4
- package/dist/store/schema.js +100 -7
- package/dist/store/torn.d.ts +29 -0
- package/dist/store/torn.js +113 -0
- package/package.json +1 -1
|
@@ -35,6 +35,17 @@ export interface EventVerdict<Q> {
|
|
|
35
35
|
readonly outputStarted?: boolean;
|
|
36
36
|
readonly quota?: Q;
|
|
37
37
|
readonly limit?: LimitSignal;
|
|
38
|
+
/**
|
|
39
|
+
* This is the last event of the response (completed or failed); the
|
|
40
|
+
* attempt ends here. On WebSocket this is the only way an attempt ends
|
|
41
|
+
* normally, because the socket stays open between responses.
|
|
42
|
+
*/
|
|
43
|
+
readonly done?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The response failed with this message. Ends the attempt with
|
|
46
|
+
* `error.reason` `failed`; implies `done`.
|
|
47
|
+
*/
|
|
48
|
+
readonly error?: string;
|
|
38
49
|
}
|
|
39
50
|
/**
|
|
40
51
|
* Header changes for one account. A string sets the header (replacing every
|
|
@@ -57,6 +68,65 @@ export interface ChooseAccountInput extends RequestScope {
|
|
|
57
68
|
export interface AccountRequest extends RequestScope {
|
|
58
69
|
readonly accountId: string;
|
|
59
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* One physical send: created when an account is chosen for a request, and
|
|
73
|
+
* handed back on every hook call and event that belongs to that send, so a
|
|
74
|
+
* plugin can tie a response, a frame, a limit, a retry decision and the end
|
|
75
|
+
* of the send to the credential it chose for it.
|
|
76
|
+
*
|
|
77
|
+
* `A` is the plugin's own per-attempt value, returned by `accountHeaders`
|
|
78
|
+
* next to the headers (a served credential receipt, say); the installer
|
|
79
|
+
* only carries it.
|
|
80
|
+
*/
|
|
81
|
+
export interface Attempt<A = unknown> extends AccountRequest {
|
|
82
|
+
/** Unique within one installation. */
|
|
83
|
+
readonly attemptId: string;
|
|
84
|
+
/**
|
|
85
|
+
* The transport that carried the send. `undefined` until `http.request` or
|
|
86
|
+
* `experimental.ws.handshake` has run for it: an account chosen in
|
|
87
|
+
* `model.request` is chosen before the host has picked the transport.
|
|
88
|
+
*/
|
|
89
|
+
readonly transport: Transport | undefined;
|
|
90
|
+
/** What `accountHeaders` returned as `attempt`, if anything. */
|
|
91
|
+
readonly data: A | undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Why an attempt ended without completing.
|
|
95
|
+
*
|
|
96
|
+
* - `failed`: the response stream errored, or the adapter's `inspectEvent`
|
|
97
|
+
* reported the response as failed;
|
|
98
|
+
* - `cancelled`: the host cancelled the HTTP response body (the user stopped
|
|
99
|
+
* the turn, say);
|
|
100
|
+
* - `abandoned`: the attempt was still open when a newer attempt of the same
|
|
101
|
+
* session and kind began, its session was forgotten, its record was
|
|
102
|
+
* dropped for space, or the installation was disposed. A WebSocket closed
|
|
103
|
+
* or cancelled mid-response ends this way, since the host has no hook for
|
|
104
|
+
* either.
|
|
105
|
+
*/
|
|
106
|
+
export type AttemptEndReason = 'failed' | 'cancelled' | 'abandoned';
|
|
107
|
+
/** How an attempt ended, as `onAttemptEnd` reports it. */
|
|
108
|
+
export interface AttemptOutcome {
|
|
109
|
+
/** The HTTP status of the response, when an HTTP response was seen. */
|
|
110
|
+
readonly status?: number;
|
|
111
|
+
/** Output from this attempt had reached the user. */
|
|
112
|
+
readonly outputStarted: boolean;
|
|
113
|
+
/** The account-level refusal recorded for this attempt, if any. */
|
|
114
|
+
readonly limit?: LimitSignal;
|
|
115
|
+
/** Absent when the response completed, whatever its status. */
|
|
116
|
+
readonly error?: {
|
|
117
|
+
readonly reason: AttemptEndReason;
|
|
118
|
+
readonly message?: string;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `accountHeaders` may return the header edits alone, or the edits together
|
|
123
|
+
* with a per-attempt value that the installer hands back in
|
|
124
|
+
* `Attempt.data`.
|
|
125
|
+
*/
|
|
126
|
+
export interface AccountHeadersResult<A = unknown> {
|
|
127
|
+
readonly headers: HeaderEdits;
|
|
128
|
+
readonly attempt?: A;
|
|
129
|
+
}
|
|
60
130
|
/** A host-supplied error, as the retry hook reports it. */
|
|
61
131
|
export interface HostError {
|
|
62
132
|
readonly type: string;
|
|
@@ -66,8 +136,9 @@ export interface HostError {
|
|
|
66
136
|
/**
|
|
67
137
|
* Everything provider-specific the installer needs. `Q` is the plugin's own
|
|
68
138
|
* quota reading type; the installer only carries it to the `quota` event.
|
|
139
|
+
* `A` is the plugin's per-attempt value (see `Attempt`).
|
|
69
140
|
*/
|
|
70
|
-
export interface OpenCode2AuthAdapter<Q = unknown> {
|
|
141
|
+
export interface OpenCode2AuthAdapter<Q = unknown, A = unknown> {
|
|
71
142
|
/** Every hook is scoped to this provider; other providers are untouched. */
|
|
72
143
|
readonly providerID: string;
|
|
73
144
|
/**
|
|
@@ -81,14 +152,19 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
|
|
|
81
152
|
* `model.request`, `http.request` and `experimental.ws.handshake`. The last
|
|
82
153
|
* two run after the host has applied its own credential, so these headers
|
|
83
154
|
* win on the wire.
|
|
155
|
+
*
|
|
156
|
+
* Called once per attempt, when the account is chosen. Returning
|
|
157
|
+
* `{headers, attempt}` instead of the bare edits stores `attempt` as the
|
|
158
|
+
* attempt's `data`, handed back on everything that belongs to the attempt.
|
|
84
159
|
*/
|
|
85
|
-
accountHeaders(input: AccountRequest): Promise<HeaderEdits
|
|
160
|
+
accountHeaders(input: AccountRequest): Promise<HeaderEdits | AccountHeadersResult<A>> | HeaderEdits | AccountHeadersResult<A>;
|
|
86
161
|
/**
|
|
87
162
|
* Optional request rewrite (URL, body) before the account headers are
|
|
88
163
|
* applied. Return `undefined` to keep the request.
|
|
89
164
|
*/
|
|
90
165
|
rewriteRequest?(input: AccountRequest & {
|
|
91
166
|
readonly request: Request;
|
|
167
|
+
readonly attempt: Attempt<A>;
|
|
92
168
|
}): Promise<Request | undefined> | Request | undefined;
|
|
93
169
|
/**
|
|
94
170
|
* Optional response rewrite (body stream, status). It receives the
|
|
@@ -98,13 +174,54 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
|
|
|
98
174
|
rewriteResponse?(input: AccountRequest & {
|
|
99
175
|
readonly request: Request;
|
|
100
176
|
readonly response: Response;
|
|
177
|
+
readonly attempt: Attempt<A>;
|
|
101
178
|
}): Promise<Response | undefined> | Response | undefined;
|
|
102
179
|
/** Optional WebSocket URL rewrite. Return `undefined` to keep the URL. */
|
|
103
180
|
rewriteHandshakeURL?(input: AccountRequest & {
|
|
104
181
|
readonly url: string;
|
|
182
|
+
readonly attempt: Attempt<A>;
|
|
105
183
|
}): string | undefined;
|
|
184
|
+
/**
|
|
185
|
+
* Optional rewrite of each outgoing WebSocket frame of this provider,
|
|
186
|
+
* from `experimental.ws.send`. Return the frame to send, or `undefined` to
|
|
187
|
+
* send it unchanged.
|
|
188
|
+
*
|
|
189
|
+
* The rewrite must be a deterministic function of the frame (stable for
|
|
190
|
+
* the session): the host chains turns with `previous_response_id` by
|
|
191
|
+
* diffing its own request as it was before this hook ran, so the server
|
|
192
|
+
* only stays in step when every frame is rewritten the same way. Never
|
|
193
|
+
* alter, drop or reorder the `input` items the host put in the frame, and
|
|
194
|
+
* never touch `previous_response_id`: the host's next frame assumes the
|
|
195
|
+
* server holds exactly what it sent. Adding items to `input`, or adding or
|
|
196
|
+
* changing settings fields, keeps the host's order and continuation when
|
|
197
|
+
* done the same way for every frame: the added items become part of the
|
|
198
|
+
* server's history and the follow-up turn stays incremental.
|
|
199
|
+
*
|
|
200
|
+
* What inserting `input` items into every frame does not keep is the
|
|
201
|
+
* equivalence of the two ways a history reaches the server (settings
|
|
202
|
+
* fields carry no history, so they are not affected). Incrementally, each
|
|
203
|
+
* frame carries only
|
|
204
|
+
* the new items, so an item the rewrite inserts into every frame lands at
|
|
205
|
+
* every network boundary of the accumulated history. After a reconnect the
|
|
206
|
+
* host replays the whole history in one frame, and the same rewrite
|
|
207
|
+
* inserts that item once. The two server-side histories, and their cache
|
|
208
|
+
* prefixes, then differ. A rewrite that only adds or changes settings
|
|
209
|
+
* fields is unaffected. An adapter that inserts `input` items must show,
|
|
210
|
+
* for its own insertion, that the accumulated incremental history equals
|
|
211
|
+
* the rewritten full replay (including across tool loops), or accept the
|
|
212
|
+
* divergence and name what it costs (a cache miss and a different prompt
|
|
213
|
+
* after every reconnect).
|
|
214
|
+
*
|
|
215
|
+
* It runs for every frame, including one no attempt can be tied to
|
|
216
|
+
* (`attempt` is then `undefined`), so the rewrite never depends on
|
|
217
|
+
* attribution.
|
|
218
|
+
*/
|
|
219
|
+
rewriteWebSocketFrame?(input: RequestScope & {
|
|
220
|
+
readonly attempt: Attempt<A> | undefined;
|
|
221
|
+
readonly frame: string;
|
|
222
|
+
}): Promise<string | undefined> | string | undefined;
|
|
106
223
|
/** Quota carried in HTTP response headers. */
|
|
107
|
-
quotaFromHeaders?(headers: Headers, status: number): Q | undefined;
|
|
224
|
+
quotaFromHeaders?(headers: Headers, status: number, attempt: Attempt<A>): Q | undefined;
|
|
108
225
|
/**
|
|
109
226
|
* Recognises an account-level refusal from an HTTP response before its
|
|
110
227
|
* body is streamed. `body()` reads a copy, so the host still gets the body.
|
|
@@ -113,21 +230,35 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
|
|
|
113
230
|
readonly status: number;
|
|
114
231
|
readonly headers: Headers;
|
|
115
232
|
readonly body: () => Promise<string>;
|
|
233
|
+
readonly attempt: Attempt<A>;
|
|
116
234
|
}): Promise<LimitSignal | undefined> | LimitSignal | undefined;
|
|
117
235
|
/**
|
|
118
236
|
* Inspects one server-sent event (`data` payload) or one WebSocket frame.
|
|
119
|
-
* Detects output, quota and
|
|
237
|
+
* Detects output, quota, refusals and the end of the response inside the
|
|
238
|
+
* stream.
|
|
120
239
|
*/
|
|
121
240
|
inspectEvent?(input: {
|
|
122
241
|
readonly transport: Transport;
|
|
123
242
|
readonly data: string;
|
|
124
243
|
readonly event?: string;
|
|
244
|
+
readonly attempt: Attempt<A>;
|
|
125
245
|
}): EventVerdict<Q> | undefined;
|
|
126
246
|
/**
|
|
127
247
|
* Recognises an account-level refusal from the error the host hands the
|
|
128
|
-
* retry hook, for refusals no other hook saw.
|
|
248
|
+
* retry hook, for refusals no other hook saw. `attempt` is the attempt
|
|
249
|
+
* the retry hook judged.
|
|
250
|
+
*/
|
|
251
|
+
limitFromError?(error: HostError, attempt: Attempt<A>): LimitSignal | undefined;
|
|
252
|
+
/**
|
|
253
|
+
* Called once per attempt when it ends: an HTTP response body finished,
|
|
254
|
+
* errored or was cancelled; an HTTP response with an error status or no
|
|
255
|
+
* body arrived; an event's verdict said `done` or `error`; or the attempt
|
|
256
|
+
* was abandoned (see `AttemptEndReason`). Errors are logged and never
|
|
257
|
+
* reach the host. The retry hook waits for this call to settle before it
|
|
258
|
+
* decides, so a plugin that records a refusal here has it in place when
|
|
259
|
+
* `chooseAccount` runs again.
|
|
129
260
|
*/
|
|
130
|
-
|
|
261
|
+
onAttemptEnd?(attempt: Attempt<A>, outcome: AttemptOutcome): Promise<void> | void;
|
|
131
262
|
}
|
|
132
263
|
export interface OpenCode2AuthLogger {
|
|
133
264
|
warn(message: string, data?: unknown): void;
|
|
@@ -150,18 +281,24 @@ export type RetryReason = 'reroute' | 'output-started' | 'no-account' | 'host-de
|
|
|
150
281
|
* plugin may want to log as a host change.
|
|
151
282
|
*/
|
|
152
283
|
export type SelectingHook = 'model.request' | 'http.request' | 'experimental.ws.handshake';
|
|
153
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Every event that belongs to one attempt carries it as `handle` (the
|
|
286
|
+
* `retry` event already uses `attempt` for the host's retry count).
|
|
287
|
+
*/
|
|
288
|
+
export interface OpenCode2AuthEvents<Q, A = unknown> {
|
|
154
289
|
/** An account was picked for a model request. */
|
|
155
290
|
readonly select: AccountRequest & {
|
|
156
291
|
readonly hook: SelectingHook;
|
|
157
292
|
readonly previousAccountId?: string;
|
|
158
293
|
readonly rerouteFrom?: ChooseAccountInput['rerouteFrom'];
|
|
294
|
+
readonly handle: Attempt<A>;
|
|
159
295
|
};
|
|
160
296
|
/** A quota reading, attributed through this installer's own record. */
|
|
161
297
|
readonly quota: AccountRequest & {
|
|
162
298
|
readonly transport: Transport;
|
|
163
299
|
readonly status?: number;
|
|
164
300
|
readonly quota: Q;
|
|
301
|
+
readonly handle: Attempt<A>;
|
|
165
302
|
};
|
|
166
303
|
/**
|
|
167
304
|
* An account-level refusal. The retry hook waits for every listener of
|
|
@@ -172,6 +309,7 @@ export interface OpenCode2AuthEvents<Q> {
|
|
|
172
309
|
readonly via: Transport | 'error';
|
|
173
310
|
readonly limit: LimitSignal;
|
|
174
311
|
readonly outputStarted: boolean;
|
|
312
|
+
readonly handle: Attempt<A>;
|
|
175
313
|
};
|
|
176
314
|
/** The retry hook ran for this provider. */
|
|
177
315
|
readonly retry: {
|
|
@@ -182,15 +320,17 @@ export interface OpenCode2AuthEvents<Q> {
|
|
|
182
320
|
readonly reason: RetryReason;
|
|
183
321
|
readonly hostDecision: SessionRetryDecision;
|
|
184
322
|
readonly decision: SessionRetryDecision;
|
|
323
|
+
/** The attempt the decision was about, when it had an account. */
|
|
324
|
+
readonly handle?: Attempt<A>;
|
|
185
325
|
};
|
|
186
326
|
}
|
|
187
327
|
export type OpenCode2AuthEventName = keyof OpenCode2AuthEvents<unknown>;
|
|
188
|
-
export interface OpenCode2AuthInstallation<Q> {
|
|
328
|
+
export interface OpenCode2AuthInstallation<Q, A = unknown> {
|
|
189
329
|
/**
|
|
190
330
|
* Listens to an event. Listener errors are logged and never reach the
|
|
191
331
|
* host. Returns a function that removes the listener.
|
|
192
332
|
*/
|
|
193
|
-
on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q>[E]) => void | Promise<void>): () => void;
|
|
333
|
+
on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q, A>[E]) => void | Promise<void>): () => void;
|
|
194
334
|
/** The account last chosen for a session and request kind. */
|
|
195
335
|
accountFor(sessionID: string, kind: RequestKind): string | undefined;
|
|
196
336
|
/** Drops every record of a session. Session deletion does this itself. */
|
|
@@ -33,10 +33,17 @@ export interface ProjectedQuota {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Resolves `map` for a request in `scope` (`all` or a model family). A family
|
|
36
|
-
* request sees only its own family's keys and the `all` keys.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* request sees only its own family's keys and the `all` keys.
|
|
37
|
+
*
|
|
38
|
+
* The label decides how a family limit relates to a general one. Under the
|
|
39
|
+
* same label, the family reading replaces the `all` reading: use this only
|
|
40
|
+
* when the provider's family figure is a more specific view of the same cap.
|
|
41
|
+
* Under different labels both limits are projected and every consumer judges
|
|
42
|
+
* both, so a family cap that applies on top of a general cap must be stored
|
|
43
|
+
* under its own label; otherwise the general cap is hidden from a family
|
|
44
|
+
* request. A family tombstone or absence record says only that no
|
|
45
|
+
* family-specific limit exists, so it does not hide an `all` entry for the
|
|
46
|
+
* same label and is used only when there is none.
|
|
40
47
|
*/
|
|
41
48
|
export declare function projectQuota(map: QuotaMap | undefined, scope?: string): ProjectedQuota;
|
|
42
49
|
export interface ExhaustionReset {
|
package/dist/quota/projection.js
CHANGED
|
@@ -61,10 +61,17 @@ function projectBudget(budget) {
|
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
63
|
* Resolves `map` for a request in `scope` (`all` or a model family). A family
|
|
64
|
-
* request sees only its own family's keys and the `all` keys.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* request sees only its own family's keys and the `all` keys.
|
|
65
|
+
*
|
|
66
|
+
* The label decides how a family limit relates to a general one. Under the
|
|
67
|
+
* same label, the family reading replaces the `all` reading: use this only
|
|
68
|
+
* when the provider's family figure is a more specific view of the same cap.
|
|
69
|
+
* Under different labels both limits are projected and every consumer judges
|
|
70
|
+
* both, so a family cap that applies on top of a general cap must be stored
|
|
71
|
+
* under its own label; otherwise the general cap is hidden from a family
|
|
72
|
+
* request. A family tombstone or absence record says only that no
|
|
73
|
+
* family-specific limit exists, so it does not hide an `all` entry for the
|
|
74
|
+
* same label and is used only when there is none.
|
|
68
75
|
*/
|
|
69
76
|
export function projectQuota(map, scope = ALL_SCOPE) {
|
|
70
77
|
const byLabel = new Map();
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// A marked or backed-off row is excluded before the gates. Stage 1 then
|
|
8
8
|
// judges each remaining row alone, in gate order:
|
|
9
|
-
// 1. an API-key row is admitted without consulting quota
|
|
9
|
+
// 1. an API-key row is admitted without consulting quota (a provider that
|
|
10
|
+
// offers paid rows only after its OAuth rows are spent leaves them out
|
|
11
|
+
// of `rows` until then);
|
|
10
12
|
// 2. an OAuth row whose projection resolves no entry for the scope needs a
|
|
11
13
|
// first reading: refused, pull requested;
|
|
12
14
|
// 3. a required label with no entry is unknown: refused, pull requested;
|
package/dist/routing/index.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export type { OrderedAttempt, OrderedPlacement, OrderedRoute, OrderedRouteInput,
|
|
|
4
4
|
export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
|
|
5
5
|
export type { StickyPin } from './pins.js';
|
|
6
6
|
export { isPinValid, pendingBytesForPins } from './pins.js';
|
|
7
|
-
export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, } from './sticky.js';
|
|
8
|
-
export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky,
|
|
7
|
+
export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, StickyStatusClass, StickyStatusClassifier, } from './sticky.js';
|
|
8
|
+
export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
|
package/dist/routing/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { admit, exclusionFor } from './admission.js';
|
|
2
2
|
export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
|
|
3
3
|
export { isPinValid, pendingBytesForPins } from './pins.js';
|
|
4
|
-
export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky,
|
|
4
|
+
export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
|
package/dist/routing/sticky.d.ts
CHANGED
|
@@ -4,12 +4,6 @@ import { type StickyPin } from './pins.js';
|
|
|
4
4
|
export declare const QUOTA_STALENESS_MS: number;
|
|
5
5
|
export declare const MIN_RESET_HOURS: number;
|
|
6
6
|
export declare const MIN_WEIGHT = 0.000001;
|
|
7
|
-
/**
|
|
8
|
-
* How many window readings the selection primitives judge, as openai-auth's
|
|
9
|
-
* primary and secondary slots did. The projection's first readings in its
|
|
10
|
-
* order fill the slots; any further window is judged by admission only.
|
|
11
|
-
*/
|
|
12
|
-
export declare const STICKY_WINDOW_SLOTS = 2;
|
|
13
7
|
/** The projection's time, else the caller's cache-entry time. */
|
|
14
8
|
export declare function snapshotCheckedAt(quota: ProjectedQuota | null | undefined, entryCheckedAt?: number): number | undefined;
|
|
15
9
|
export type StickyBreakDecision = {
|
|
@@ -22,6 +16,23 @@ export type StickyBreakDecision = {
|
|
|
22
16
|
window?: WindowRef;
|
|
23
17
|
resetsAt?: string;
|
|
24
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* How the adapter reads a failed response's HTTP status for the break
|
|
21
|
+
* decision. `permanent` moves the session off its row before any quota is
|
|
22
|
+
* consulted; `transient` and `healthy` keep it unless the quota says the row
|
|
23
|
+
* is spent. A missing status means the request failed without a response.
|
|
24
|
+
*/
|
|
25
|
+
export type StickyStatusClass = 'permanent' | 'transient' | 'healthy';
|
|
26
|
+
export type StickyStatusClassifier = (status: number | undefined) => StickyStatusClass;
|
|
27
|
+
/**
|
|
28
|
+
* The status rule used when the adapter supplies none, carried from
|
|
29
|
+
* openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
|
|
30
|
+
* status, 429 and 5xx are transient; anything else leaves the row healthy.
|
|
31
|
+
* A provider whose 403 can mean an organisation or model policy rather than
|
|
32
|
+
* a dead account supplies its own classifier and can fall back to this one
|
|
33
|
+
* for the statuses it does not single out.
|
|
34
|
+
*/
|
|
35
|
+
export declare function defaultStickyStatusClass(status: number | undefined): StickyStatusClass;
|
|
25
36
|
/** Classifies whether a pinned session should leave its row after a failure. */
|
|
26
37
|
export declare function decideStickyBreak(input: {
|
|
27
38
|
quota: ProjectedQuota | null | undefined;
|
|
@@ -29,6 +40,8 @@ export declare function decideStickyBreak(input: {
|
|
|
29
40
|
status?: number;
|
|
30
41
|
now: number;
|
|
31
42
|
killswitchPasses?: boolean;
|
|
43
|
+
/** Defaults to `defaultStickyStatusClass`. */
|
|
44
|
+
classifyStatus?: StickyStatusClassifier;
|
|
32
45
|
}): StickyBreakDecision;
|
|
33
46
|
export declare function sustainableWindowWeight(window: {
|
|
34
47
|
remainingPercent: number;
|
package/dist/routing/sticky.js
CHANGED
|
@@ -7,12 +7,6 @@ import { isPinValid } from './pins.js';
|
|
|
7
7
|
export const QUOTA_STALENESS_MS = 15 * 60_000;
|
|
8
8
|
export const MIN_RESET_HOURS = 1 / 60;
|
|
9
9
|
export const MIN_WEIGHT = 1e-6;
|
|
10
|
-
/**
|
|
11
|
-
* How many window readings the selection primitives judge, as openai-auth's
|
|
12
|
-
* primary and secondary slots did. The projection's first readings in its
|
|
13
|
-
* order fill the slots; any further window is judged by admission only.
|
|
14
|
-
*/
|
|
15
|
-
export const STICKY_WINDOW_SLOTS = 2;
|
|
16
10
|
/** The projection's time, else the caller's cache-entry time. */
|
|
17
11
|
export function snapshotCheckedAt(quota, entryCheckedAt) {
|
|
18
12
|
for (const checkedAt of [quota?.checkedAt, entryCheckedAt]) {
|
|
@@ -22,10 +16,33 @@ export function snapshotCheckedAt(quota, entryCheckedAt) {
|
|
|
22
16
|
}
|
|
23
17
|
return undefined;
|
|
24
18
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The status rule used when the adapter supplies none, carried from
|
|
21
|
+
* openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
|
|
22
|
+
* status, 429 and 5xx are transient; anything else leaves the row healthy.
|
|
23
|
+
* A provider whose 403 can mean an organisation or model policy rather than
|
|
24
|
+
* a dead account supplies its own classifier and can fall back to this one
|
|
25
|
+
* for the statuses it does not single out.
|
|
26
|
+
*/
|
|
27
|
+
export function defaultStickyStatusClass(status) {
|
|
28
|
+
if (status === 401 || status === 403)
|
|
29
|
+
return 'permanent';
|
|
30
|
+
if (status === undefined ||
|
|
31
|
+
status === 0 ||
|
|
32
|
+
!Number.isFinite(status) ||
|
|
33
|
+
(status >= 500 && status <= 599) ||
|
|
34
|
+
status === 429) {
|
|
35
|
+
return 'transient';
|
|
36
|
+
}
|
|
37
|
+
return 'healthy';
|
|
38
|
+
}
|
|
39
|
+
function windowReadings(quota) {
|
|
40
|
+
// Every reading is an independent constraint, so all of them are judged:
|
|
41
|
+
// a third window (for example a short window beside two weekly ones) can
|
|
42
|
+
// be the one that is nearly spent. Longest known window first, unknown
|
|
43
|
+
// lengths last, as openai-auth sorts, so a break decision with several
|
|
44
|
+
// spent windows names the longest. Tombstones and absence records carry no
|
|
45
|
+
// capacity figure and are left out.
|
|
29
46
|
return quota.limits
|
|
30
47
|
.filter((limit) => limit.kind === 'reading')
|
|
31
48
|
.sort((left, right) => {
|
|
@@ -37,12 +54,12 @@ function slotReadings(quota) {
|
|
|
37
54
|
return (right.windowMinutes ?? 0) - (left.windowMinutes ?? 0);
|
|
38
55
|
}
|
|
39
56
|
return 0;
|
|
40
|
-
})
|
|
41
|
-
.slice(0, STICKY_WINDOW_SLOTS);
|
|
57
|
+
});
|
|
42
58
|
}
|
|
43
59
|
/** Classifies whether a pinned session should leave its row after a failure. */
|
|
44
60
|
export function decideStickyBreak(input) {
|
|
45
|
-
|
|
61
|
+
const statusClass = (input.classifyStatus ?? defaultStickyStatusClass)(input.status);
|
|
62
|
+
if (statusClass === 'permanent') {
|
|
46
63
|
return { action: 'migrate', reason: 'permanent' };
|
|
47
64
|
}
|
|
48
65
|
if (!input.quota)
|
|
@@ -58,7 +75,7 @@ export function decideStickyBreak(input) {
|
|
|
58
75
|
if (input.killswitchPasses === false) {
|
|
59
76
|
return { action: 'migrate', reason: 'killswitch' };
|
|
60
77
|
}
|
|
61
|
-
for (const limit of
|
|
78
|
+
for (const limit of windowReadings(input.quota)) {
|
|
62
79
|
const remaining = limit.remainingPercent;
|
|
63
80
|
if (typeof remaining === 'number' &&
|
|
64
81
|
Number.isFinite(remaining) &&
|
|
@@ -83,14 +100,7 @@ export function decideStickyBreak(input) {
|
|
|
83
100
|
resetsAt: budgetReset.resetsAt,
|
|
84
101
|
};
|
|
85
102
|
}
|
|
86
|
-
|
|
87
|
-
input.status === 0 ||
|
|
88
|
-
!Number.isFinite(input.status) ||
|
|
89
|
-
(input.status >= 500 && input.status <= 599) ||
|
|
90
|
-
input.status === 429) {
|
|
91
|
-
return { action: 'retain', reason: 'transient' };
|
|
92
|
-
}
|
|
93
|
-
return { action: 'retain', reason: 'healthy' };
|
|
103
|
+
return { action: 'retain', reason: statusClass };
|
|
94
104
|
}
|
|
95
105
|
export function sustainableWindowWeight(window, reservePercent, now) {
|
|
96
106
|
const spendable = Math.max(0, window.remainingPercent - reservePercent);
|
|
@@ -124,7 +134,8 @@ function candidateWeight(candidate, now) {
|
|
|
124
134
|
}
|
|
125
135
|
// Missing reserve data must leave a window usable rather than silently
|
|
126
136
|
// excluding its account.
|
|
127
|
-
|
|
137
|
+
// The account is as constrained as its tightest window, whichever it is.
|
|
138
|
+
const weights = windowReadings(candidate.quota).map((limit) => sustainableWindowWeight({
|
|
128
139
|
remainingPercent: limit.remainingPercent ?? Number.NaN,
|
|
129
140
|
...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
|
|
130
141
|
}, candidate.reservePercent[limit.label] ?? 0, now));
|
|
@@ -22,7 +22,27 @@ export interface NotificationScope {
|
|
|
22
22
|
rpcRoot: string;
|
|
23
23
|
directoryPrefix: string;
|
|
24
24
|
registrationSessionId: string;
|
|
25
|
+
/**
|
|
26
|
+
* Strict session isolation. When true, `pushNotification` and
|
|
27
|
+
* `drainNotifications` refuse an absent or empty session id (throwing
|
|
28
|
+
* `RpcSessionRequiredError`) and a drain returns only that session's own
|
|
29
|
+
* notifications: no notification reaches every session. A strict scope is
|
|
30
|
+
* a separate queue from the same scope without the flag, so a lenient
|
|
31
|
+
* caller cannot push into it or drain it.
|
|
32
|
+
*
|
|
33
|
+
* Off by default: without it, a drain with no session returns every
|
|
34
|
+
* session's notifications and a push with no session reaches every
|
|
35
|
+
* session, which plugins that drain from one process-wide TUI rely on.
|
|
36
|
+
*/
|
|
37
|
+
requireSession?: boolean;
|
|
25
38
|
}
|
|
39
|
+
/** A strict notification scope was used without a session id. */
|
|
40
|
+
export declare class RpcSessionRequiredError extends Error {
|
|
41
|
+
readonly operation: 'push' | 'drain';
|
|
42
|
+
constructor(operation: 'push' | 'drain');
|
|
43
|
+
}
|
|
44
|
+
/** True for a session id a strict scope accepts: a non-empty string. */
|
|
45
|
+
export declare function isSessionId(value: unknown): value is string;
|
|
26
46
|
export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
|
|
27
47
|
export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
|
|
28
48
|
export declare function isTuiConnected(scope: NotificationScope, sessionId: string): boolean;
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
/** A strict notification scope was used without a session id. */
|
|
2
|
+
export class RpcSessionRequiredError extends Error {
|
|
3
|
+
operation;
|
|
4
|
+
constructor(operation) {
|
|
5
|
+
super(`a ${operation} on a strict notification scope needs a session id`);
|
|
6
|
+
this.name = 'RpcSessionRequiredError';
|
|
7
|
+
this.operation = operation;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** True for a session id a strict scope accepts: a non-empty string. */
|
|
11
|
+
export function isSessionId(value) {
|
|
12
|
+
return typeof value === 'string' && value.length > 0;
|
|
13
|
+
}
|
|
1
14
|
const queues = new Map();
|
|
2
15
|
function state(scope) {
|
|
3
16
|
const key = JSON.stringify([
|
|
4
17
|
scope.rpcRoot,
|
|
5
18
|
scope.directoryPrefix,
|
|
6
19
|
scope.registrationSessionId,
|
|
20
|
+
...(scope.requireSession === true ? ['strict'] : []),
|
|
7
21
|
]);
|
|
8
22
|
let value = queues.get(key);
|
|
9
23
|
if (!value) {
|
|
@@ -15,6 +29,8 @@ function state(scope) {
|
|
|
15
29
|
const QUEUE_CAP = 100;
|
|
16
30
|
const TUI_CONNECTED_WINDOW_MS = 3_000;
|
|
17
31
|
export function pushNotification(scope, payload, sessionId) {
|
|
32
|
+
if (scope.requireSession === true && !isSessionId(sessionId))
|
|
33
|
+
throw new RpcSessionRequiredError('push');
|
|
18
34
|
const value = state(scope);
|
|
19
35
|
value.queue.push({
|
|
20
36
|
id: value.nextId++,
|
|
@@ -26,6 +42,11 @@ export function pushNotification(scope, payload, sessionId) {
|
|
|
26
42
|
value.queue = value.queue.slice(-QUEUE_CAP);
|
|
27
43
|
}
|
|
28
44
|
export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
|
|
45
|
+
// A strict queue holds only session-scoped notifications (its push refuses
|
|
46
|
+
// the rest), so the ordinary match below already gives a strict drain
|
|
47
|
+
// nothing but its own session's notifications.
|
|
48
|
+
if (scope.requireSession === true && !isSessionId(sessionId))
|
|
49
|
+
throw new RpcSessionRequiredError('drain');
|
|
29
50
|
const value = state(scope);
|
|
30
51
|
const now = Date.now();
|
|
31
52
|
if (sessionId !== undefined)
|
package/dist/rpc/rpc-server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RpcLogChannel } from './index.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type ApplyRequest, type ApplyResult, type RpcNotification } from './notifications.js';
|
|
3
3
|
export interface RpcServerHandle {
|
|
4
4
|
port: number;
|
|
5
5
|
token: string;
|
|
@@ -13,6 +13,14 @@ export interface RpcServerOptions {
|
|
|
13
13
|
sweepRoot?: string;
|
|
14
14
|
drain: (lastReceivedId: number, sessionId?: string) => RpcNotification[];
|
|
15
15
|
apply: (request: ApplyRequest) => Promise<ApplyResult>;
|
|
16
|
+
/**
|
|
17
|
+
* Refuse a `pending-notifications` drain whose `sessionId` is absent, not a
|
|
18
|
+
* string or empty, with 400 and without calling `drain`. Pair it with a
|
|
19
|
+
* strict notification scope (`requireSession`) so neither the wire nor
|
|
20
|
+
* the queue can hand one session's notifications to another. Off by
|
|
21
|
+
* default, when a session-less drain is passed to `drain` as undefined.
|
|
22
|
+
*/
|
|
23
|
+
requireSession?: boolean;
|
|
16
24
|
timeoutMs?: number;
|
|
17
25
|
receiptTimeoutMs?: number;
|
|
18
26
|
}
|
package/dist/rpc/rpc-server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { readFile, unlink } from 'node:fs/promises';
|
|
3
3
|
import { createServer, } from 'node:http';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { isSessionId, } from './notifications.js';
|
|
5
6
|
import { sweepRpcState, writePortFile } from './port-file.js';
|
|
6
7
|
function readBody(req) {
|
|
7
8
|
return new Promise((resolve, reject) => {
|
|
@@ -64,6 +65,8 @@ export async function startRpcServer(options) {
|
|
|
64
65
|
const body = await readBody(req);
|
|
65
66
|
const params = JSON.parse(body || '{}');
|
|
66
67
|
if (method === 'pending-notifications') {
|
|
68
|
+
if (options.requireSession === true && !isSessionId(params.sessionId))
|
|
69
|
+
return json(400, { error: 'session required' });
|
|
67
70
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
|
|
68
71
|
if (sessionId === undefined && !warnedMissingNotificationSession) {
|
|
69
72
|
warnedMissingNotificationSession = true;
|
|
@@ -81,9 +84,13 @@ export async function startRpcServer(options) {
|
|
|
81
84
|
return json(404, { error: 'unknown method' });
|
|
82
85
|
}
|
|
83
86
|
catch (error) {
|
|
84
|
-
|
|
87
|
+
// A handler's exception can quote a request or a credential, so its
|
|
88
|
+
// text goes to the plugin's log channel only; the wire gets a fixed code.
|
|
89
|
+
log.warn('rpc request failed', {
|
|
90
|
+
pid: process.pid,
|
|
85
91
|
error: error instanceof Error ? error.message : String(error),
|
|
86
92
|
});
|
|
93
|
+
json(500, { error: 'internal error' });
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
const port = await new Promise((resolve, reject) => {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, } from './sidebar-file.js';
|
|
1
|
+
export type { SidebarFile, SidebarFileHooks, SidebarFileOptions, SidebarRepair, SidebarWriteOptions, SidebarWriteResult, } from './sidebar-file.js';
|
|
2
2
|
export { createSidebarFile } from './sidebar-file.js';
|