@cortexkit/common-auth 0.4.5 → 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/dist/opencode2/index.d.ts +2 -2
- package/dist/opencode2/index.js +1 -1
- package/dist/opencode2/install.d.ts +28 -18
- package/dist/opencode2/install.js +236 -84
- package/dist/opencode2/types.d.ts +62 -14
- package/package.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export type { OpenCode2AuthFailureKind } from './errors.js';
|
|
2
2
|
export { OpenCode2AuthError } from './errors.js';
|
|
3
|
-
export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
3
|
+
export { ATTEMPT_HEADER, applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
4
4
|
export type { FormAnswer, PoolAuthorization, PoolLoginMethod, RegisterOpenCode2AuthMethodsOptions, } from './integration.js';
|
|
5
5
|
export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
|
|
6
6
|
export type { ServerSentEvent } from './sse.js';
|
|
7
7
|
export { watchServerSentEvents } from './sse.js';
|
|
8
|
-
export type { AccountHeadersResult, AccountRequest, Attempt, AttemptEndReason, AttemptOutcome, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, RetryReason, SelectingHook, Transport, } from './types.js';
|
|
8
|
+
export type { AccountHeadersResult, AccountRequest, Attempt, AttemptEndReason, AttemptOutcome, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, ResponseAccount, RetryReason, SelectingHook, Transport, } from './types.js';
|
package/dist/opencode2/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { OpenCode2AuthError } from './errors.js';
|
|
2
|
-
export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
2
|
+
export { ATTEMPT_HEADER, applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
|
|
3
3
|
export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
|
|
4
4
|
export { watchServerSentEvents } from './sse.js';
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import type { HeaderEdits, InstallOpenCode2AuthOptions, OpenCode2AuthAdapter, OpenCode2AuthInstallation, OpenCode2HookContext } from './types.js';
|
|
2
2
|
export declare const DEFAULT_MAX_RECORDS = 512;
|
|
3
|
+
/**
|
|
4
|
+
* The request header `model.request` sets to the attempt it started. The
|
|
5
|
+
* host builds the HTTP request and the WebSocket handshake from the headers
|
|
6
|
+
* `model.request` leaves, so `http.request` and `experimental.ws.handshake`
|
|
7
|
+
* read it to find their own attempt among several of one session and kind,
|
|
8
|
+
* and remove it: it never reaches the wire.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ATTEMPT_HEADER = "x-common-auth-attempt";
|
|
3
11
|
/** Applies edits to a plain header record, replacing every spelling of each name. */
|
|
4
12
|
export declare function applyHeaderEdits(target: Record<string, string>, edits: HeaderEdits): void;
|
|
5
13
|
/**
|
|
6
14
|
* Installs multi-account auth on OpenCode 2's own provider drivers. Every
|
|
7
15
|
* hook is scoped to `adapter.providerID`:
|
|
8
16
|
*
|
|
9
|
-
* - `model.request` picks the account for
|
|
10
|
-
* which starts a new attempt,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* - `model.request` picks the account for one model call of a session and
|
|
18
|
+
* request kind, which starts a new attempt, sets its headers and marks the
|
|
19
|
+
* request with the attempt (`ATTEMPT_HEADER`);
|
|
20
|
+
* - `http.request` and `experimental.ws.handshake` find the attempt by that
|
|
21
|
+
* mark, remove it and set the account headers again, because the host
|
|
22
|
+
* applies its own credential after `model.request`;
|
|
13
23
|
* - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
|
|
14
24
|
* each outgoing frame;
|
|
15
25
|
* - `http.response` and `experimental.ws.receive` read quota, refusals,
|
|
@@ -19,19 +29,19 @@ export declare function applyHeaderEdits(target: Record<string, string>, edits:
|
|
|
19
29
|
* before any output, so `model.request` runs again and can pick another
|
|
20
30
|
* account, and refuses to retry once output has started.
|
|
21
31
|
*
|
|
22
|
-
* Attribution.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* attempt
|
|
33
|
-
* exchange at a time
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* Attribution. A send belongs to the attempt named by the mark
|
|
33
|
+
* `model.request` left on it, so several attempts of one session and kind
|
|
34
|
+
* can be in flight at once, each on its own account. An HTTP response
|
|
35
|
+
* belongs to the attempt whose `http.request` produced its request object,
|
|
36
|
+
* and to nothing else. The host hands `http.response` the request object the
|
|
37
|
+
* `http.request` hooks left, so a different object means a later hook
|
|
38
|
+
* replaced it; nothing then proves which send the response answers, and its
|
|
39
|
+
* feedback is dropped rather than guessed by recency. A WebSocket frame
|
|
40
|
+
* names no attempt, so frames go to the newest open attempt of their session
|
|
41
|
+
* and kind that went out over WebSocket, and an older one is abandoned as
|
|
42
|
+
* soon as a newer attempt of that session and kind begins or goes out over
|
|
43
|
+
* WebSocket: the host runs one exchange at a time on a session's socket.
|
|
44
|
+
* Anything else is attributed to no attempt and reaches no adapter callback
|
|
45
|
+
* or listener.
|
|
36
46
|
*/
|
|
37
47
|
export declare function installOpenCode2Auth<Q, A = unknown>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q, A>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q, A>>;
|
|
@@ -2,6 +2,14 @@ import { OpenCode2AuthError } from './errors.js';
|
|
|
2
2
|
import { placeholderSecret } from './integration.js';
|
|
3
3
|
import { watchServerSentEvents } from './sse.js';
|
|
4
4
|
export const DEFAULT_MAX_RECORDS = 512;
|
|
5
|
+
/**
|
|
6
|
+
* The request header `model.request` sets to the attempt it started. The
|
|
7
|
+
* host builds the HTTP request and the WebSocket handshake from the headers
|
|
8
|
+
* `model.request` leaves, so `http.request` and `experimental.ws.handshake`
|
|
9
|
+
* read it to find their own attempt among several of one session and kind,
|
|
10
|
+
* and remove it: it never reaches the wire.
|
|
11
|
+
*/
|
|
12
|
+
export const ATTEMPT_HEADER = 'x-common-auth-attempt';
|
|
5
13
|
const keyOf = (sessionID, kind) => JSON.stringify([sessionID, kind]);
|
|
6
14
|
const scopeOf = (draft) => ({
|
|
7
15
|
providerID: draft.model.providerID,
|
|
@@ -77,10 +85,12 @@ function applyHeaderEditsTo(target, edits) {
|
|
|
77
85
|
* Installs multi-account auth on OpenCode 2's own provider drivers. Every
|
|
78
86
|
* hook is scoped to `adapter.providerID`:
|
|
79
87
|
*
|
|
80
|
-
* - `model.request` picks the account for
|
|
81
|
-
* which starts a new attempt,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
88
|
+
* - `model.request` picks the account for one model call of a session and
|
|
89
|
+
* request kind, which starts a new attempt, sets its headers and marks the
|
|
90
|
+
* request with the attempt (`ATTEMPT_HEADER`);
|
|
91
|
+
* - `http.request` and `experimental.ws.handshake` find the attempt by that
|
|
92
|
+
* mark, remove it and set the account headers again, because the host
|
|
93
|
+
* applies its own credential after `model.request`;
|
|
84
94
|
* - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
|
|
85
95
|
* each outgoing frame;
|
|
86
96
|
* - `http.response` and `experimental.ws.receive` read quota, refusals,
|
|
@@ -90,29 +100,33 @@ function applyHeaderEditsTo(target, edits) {
|
|
|
90
100
|
* before any output, so `model.request` runs again and can pick another
|
|
91
101
|
* account, and refuses to retry once output has started.
|
|
92
102
|
*
|
|
93
|
-
* Attribution.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* attempt
|
|
104
|
-
* exchange at a time
|
|
105
|
-
*
|
|
106
|
-
*
|
|
103
|
+
* Attribution. A send belongs to the attempt named by the mark
|
|
104
|
+
* `model.request` left on it, so several attempts of one session and kind
|
|
105
|
+
* can be in flight at once, each on its own account. An HTTP response
|
|
106
|
+
* belongs to the attempt whose `http.request` produced its request object,
|
|
107
|
+
* and to nothing else. The host hands `http.response` the request object the
|
|
108
|
+
* `http.request` hooks left, so a different object means a later hook
|
|
109
|
+
* replaced it; nothing then proves which send the response answers, and its
|
|
110
|
+
* feedback is dropped rather than guessed by recency. A WebSocket frame
|
|
111
|
+
* names no attempt, so frames go to the newest open attempt of their session
|
|
112
|
+
* and kind that went out over WebSocket, and an older one is abandoned as
|
|
113
|
+
* soon as a newer attempt of that session and kind begins or goes out over
|
|
114
|
+
* WebSocket: the host runs one exchange at a time on a session's socket.
|
|
115
|
+
* Anything else is attributed to no attempt and reaches no adapter callback
|
|
116
|
+
* or listener.
|
|
107
117
|
*/
|
|
108
118
|
export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
109
119
|
const { providerID } = adapter;
|
|
110
120
|
const logger = options.logger;
|
|
111
121
|
const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
112
122
|
const forbidden = (options.hostCredentials ?? [placeholderSecret(providerID)]).filter((value) => value !== '');
|
|
113
|
-
|
|
123
|
+
/** Every attempt still held, oldest first. */
|
|
124
|
+
const attempts = new Map();
|
|
114
125
|
const byRequest = new WeakMap();
|
|
126
|
+
/** Retries the retry hook asked for, by `sessionID:kind`. */
|
|
127
|
+
const pendingRetries = new Map();
|
|
115
128
|
let warnedUnprovenResponse = false;
|
|
129
|
+
let warnedUnmarkedSend = false;
|
|
116
130
|
const listeners = new Map();
|
|
117
131
|
let seq = 0;
|
|
118
132
|
const warn = (message, data) => {
|
|
@@ -159,19 +173,28 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
159
173
|
...(rec.limit ? { limit: rec.limit.signal } : {}),
|
|
160
174
|
...(error ? { error } : {}),
|
|
161
175
|
};
|
|
176
|
+
if (error)
|
|
177
|
+
rec.endError = error;
|
|
162
178
|
rec.ended = deliverEnd(rec.attempt, outcome);
|
|
163
179
|
};
|
|
164
180
|
const abandon = (rec, message) => finish(rec, { reason: 'abandoned', message });
|
|
181
|
+
/** The attempts of one session and kind, oldest first. */
|
|
182
|
+
const attemptsOf = (key) => [...attempts.values()].filter((rec) => rec.key === key);
|
|
183
|
+
const newestOf = (key) => attemptsOf(key).at(-1);
|
|
184
|
+
/** Forgets an attempt, and its key's pending retry once nothing else holds the key. */
|
|
185
|
+
const drop = (rec) => {
|
|
186
|
+
attempts.delete(rec.id);
|
|
187
|
+
if (!attemptsOf(rec.key).length)
|
|
188
|
+
pendingRetries.delete(rec.key);
|
|
189
|
+
};
|
|
165
190
|
const remember = (rec) => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
while (records.size > maxRecords) {
|
|
170
|
-
const oldest = records.entries().next().value;
|
|
191
|
+
attempts.set(rec.id, rec);
|
|
192
|
+
while (attempts.size > maxRecords) {
|
|
193
|
+
const oldest = attempts.values().next().value;
|
|
171
194
|
if (oldest === undefined)
|
|
172
195
|
break;
|
|
173
|
-
abandon(oldest
|
|
174
|
-
|
|
196
|
+
abandon(oldest, 'dropped to keep the record bound');
|
|
197
|
+
drop(oldest);
|
|
175
198
|
}
|
|
176
199
|
};
|
|
177
200
|
const accountOf = (rec) => rec.accountId === undefined
|
|
@@ -196,18 +219,39 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
196
219
|
}
|
|
197
220
|
}
|
|
198
221
|
};
|
|
222
|
+
/**
|
|
223
|
+
* Retires the attempts a new attempt of the same session and kind
|
|
224
|
+
* supersedes. One that has ended is dropped. One still open stays when it
|
|
225
|
+
* may yet be sent or answered (waiting for its transport, or an HTTP send
|
|
226
|
+
* whose response will name it), since another call of the same session and
|
|
227
|
+
* kind may run beside this one. One on WebSocket is abandoned, because
|
|
228
|
+
* frames name no attempt and the host runs one exchange at a time on a
|
|
229
|
+
* session's socket; one the retry hook already judged is abandoned too,
|
|
230
|
+
* because the host has finished with that call.
|
|
231
|
+
*/
|
|
232
|
+
const supersede = (key) => {
|
|
233
|
+
for (const rec of attemptsOf(key)) {
|
|
234
|
+
if (rec.ended || !rec.attempt) {
|
|
235
|
+
drop(rec);
|
|
236
|
+
}
|
|
237
|
+
else if (rec.transport === 'ws' || rec.judged) {
|
|
238
|
+
abandon(rec, 'a newer attempt of its session and kind began');
|
|
239
|
+
drop(rec);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
199
243
|
const select = async (scope, hook, transport) => {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
const key = keyOf(scope.sessionID, scope.kind);
|
|
245
|
+
const retried = pendingRetries.get(key);
|
|
246
|
+
pendingRetries.delete(key);
|
|
247
|
+
const previousAccountId = retried?.accountId ?? newestOf(key)?.accountId;
|
|
248
|
+
supersede(key);
|
|
203
249
|
const input = {
|
|
204
250
|
...scope,
|
|
205
|
-
...(
|
|
206
|
-
|
|
207
|
-
: { previousAccountId: prior.accountId }),
|
|
208
|
-
...(prior?.rerouteFrom === undefined
|
|
251
|
+
...(previousAccountId === undefined ? {} : { previousAccountId }),
|
|
252
|
+
...(retried?.rerouteFrom === undefined
|
|
209
253
|
? {}
|
|
210
|
-
: { rerouteFrom:
|
|
254
|
+
: { rerouteFrom: retried.rerouteFrom }),
|
|
211
255
|
};
|
|
212
256
|
const accountId = await adapter.chooseAccount(input);
|
|
213
257
|
let headers = {};
|
|
@@ -222,18 +266,19 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
222
266
|
headers = result;
|
|
223
267
|
}
|
|
224
268
|
}
|
|
225
|
-
const id = ++seq
|
|
269
|
+
const id = `attempt-${++seq}`;
|
|
226
270
|
const rec = {
|
|
271
|
+
id,
|
|
272
|
+
key,
|
|
227
273
|
scope,
|
|
228
274
|
accountId,
|
|
229
275
|
headers,
|
|
230
|
-
seq: id,
|
|
231
276
|
attempt: accountId === undefined
|
|
232
277
|
? undefined
|
|
233
278
|
: {
|
|
234
279
|
...scope,
|
|
235
280
|
accountId,
|
|
236
|
-
attemptId:
|
|
281
|
+
attemptId: id,
|
|
237
282
|
transport,
|
|
238
283
|
data,
|
|
239
284
|
},
|
|
@@ -257,17 +302,38 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
257
302
|
}
|
|
258
303
|
return rec;
|
|
259
304
|
};
|
|
260
|
-
// The transport hooks normally follow `model.request` and carry the
|
|
261
|
-
// attempt it started. Choosing here too keeps a request that skipped
|
|
262
|
-
// from going out under the host credential, and a second send
|
|
263
|
-
//
|
|
264
|
-
const bind = async (scope, hook, transport) => {
|
|
265
|
-
const
|
|
305
|
+
// The transport hooks normally follow `model.request` and carry the mark
|
|
306
|
+
// of the attempt it started. Choosing here too keeps a request that skipped
|
|
307
|
+
// it from going out under the host credential, and a second send of the
|
|
308
|
+
// same attempt from reusing the first send's attempt.
|
|
309
|
+
const bind = async (scope, hook, transport, mark) => {
|
|
310
|
+
const key = keyOf(scope.sessionID, scope.kind);
|
|
311
|
+
let rec = mark === undefined ? undefined : attempts.get(mark);
|
|
312
|
+
if (rec?.key !== key)
|
|
313
|
+
rec = undefined;
|
|
314
|
+
if (!rec) {
|
|
315
|
+
// No mark: the newest attempt of the session and kind is the only
|
|
316
|
+
// candidate the hook can name. Say so once when that is a guess.
|
|
317
|
+
rec = newestOf(key);
|
|
318
|
+
const waiting = attemptsOf(key).filter((other) => other.transport === undefined && !other.ended);
|
|
319
|
+
if (waiting.length > 1 && !warnedUnmarkedSend) {
|
|
320
|
+
warnedUnmarkedSend = true;
|
|
321
|
+
warn('opencode2 auth tied a send without its attempt mark to the newest of several waiting attempts', { sessionID: scope.sessionID, kind: scope.kind });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
266
324
|
if (!rec || rec.transport !== undefined || rec.ended)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
rec.attempt
|
|
325
|
+
rec = await select(scope, hook, transport);
|
|
326
|
+
else {
|
|
327
|
+
rec.transport = transport;
|
|
328
|
+
if (rec.attempt)
|
|
329
|
+
rec.attempt.transport = transport;
|
|
330
|
+
}
|
|
331
|
+
if (transport === 'ws') {
|
|
332
|
+
for (const other of attemptsOf(key)) {
|
|
333
|
+
if (other !== rec && other.transport === 'ws')
|
|
334
|
+
abandon(other, 'a newer attempt of its session and kind went out over WebSocket');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
271
337
|
return rec;
|
|
272
338
|
};
|
|
273
339
|
// A transport hook that throws stops the send, so its attempt is over.
|
|
@@ -280,9 +346,13 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
280
346
|
throw error;
|
|
281
347
|
}
|
|
282
348
|
};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
349
|
+
/** The attempt a frame of this session and kind belongs to, if any. */
|
|
350
|
+
const liveOnSocket = (sessionID, kind) => {
|
|
351
|
+
const rec = attemptsOf(keyOf(sessionID, kind))
|
|
352
|
+
.filter((each) => each.transport === 'ws')
|
|
353
|
+
.at(-1);
|
|
354
|
+
return rec?.attempt !== undefined && !rec.ended ? rec : undefined;
|
|
355
|
+
};
|
|
286
356
|
const noteLimit = (rec, signal, via) => {
|
|
287
357
|
const account = accountOf(rec);
|
|
288
358
|
if (!account || !rec.attempt || rec.limit)
|
|
@@ -330,21 +400,46 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
330
400
|
else if (verdict.done)
|
|
331
401
|
finish(rec);
|
|
332
402
|
};
|
|
403
|
+
/** Moves an attempt to the account that answered its response. */
|
|
404
|
+
const rebind = (rec, answered) => {
|
|
405
|
+
const attempt = rec.attempt;
|
|
406
|
+
if (!attempt || answered.accountId === rec.accountId)
|
|
407
|
+
return;
|
|
408
|
+
attempt.reboundFrom ??= attempt.accountId;
|
|
409
|
+
attempt.accountId = answered.accountId;
|
|
410
|
+
if ('data' in answered)
|
|
411
|
+
attempt.data = answered.data;
|
|
412
|
+
rec.accountId = answered.accountId;
|
|
413
|
+
};
|
|
414
|
+
const failedOutcome = (rec) => !rec.attempt ||
|
|
415
|
+
rec.endError !== undefined ||
|
|
416
|
+
(rec.status !== undefined && rec.status >= 400);
|
|
417
|
+
/**
|
|
418
|
+
* The attempt a retry is about. The retry hook names only the session, so:
|
|
419
|
+
* the newest refused attempt not yet rerouted; else, among the session's
|
|
420
|
+
* primary attempts (or all of them when it has none), the only one, the
|
|
421
|
+
* newest that failed and was not yet judged, the only one still open, or
|
|
422
|
+
* the newest. Two or more open with nothing else to tell them apart is
|
|
423
|
+
* `ambiguous`: deciding for one could reroute or end the other.
|
|
424
|
+
*/
|
|
333
425
|
const pickForRetry = (sessionID) => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
426
|
+
const held = [...attempts.values()].filter((rec) => rec.scope.sessionID === sessionID);
|
|
427
|
+
const refused = held.filter((rec) => rec.limit && !rec.rerouted).at(-1);
|
|
428
|
+
if (refused)
|
|
429
|
+
return refused;
|
|
430
|
+
const primary = held.filter((rec) => rec.scope.kind === 'primary');
|
|
431
|
+
const pool = primary.length > 0 ? primary : held;
|
|
432
|
+
if (pool.length <= 1)
|
|
433
|
+
return pool[0];
|
|
434
|
+
const failed = pool
|
|
435
|
+
.filter((rec) => !rec.judged && failedOutcome(rec))
|
|
436
|
+
.at(-1);
|
|
437
|
+
if (failed)
|
|
438
|
+
return failed;
|
|
439
|
+
const open = pool.filter((rec) => rec.attempt && !rec.ended);
|
|
440
|
+
if (open.length > 1)
|
|
441
|
+
return 'ambiguous';
|
|
442
|
+
return open[0] ?? pool.at(-1);
|
|
348
443
|
};
|
|
349
444
|
const scoped = { providerID };
|
|
350
445
|
const registrations = [];
|
|
@@ -352,10 +447,14 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
352
447
|
const rec = await select(scopeOf(draft), 'model.request');
|
|
353
448
|
if (rec.accountId === undefined)
|
|
354
449
|
throw noAccount(rec.scope);
|
|
355
|
-
applyHeaderEdits(draft.headers,
|
|
450
|
+
applyHeaderEdits(draft.headers, {
|
|
451
|
+
...rec.headers,
|
|
452
|
+
[ATTEMPT_HEADER]: rec.id,
|
|
453
|
+
});
|
|
356
454
|
}, scoped));
|
|
357
455
|
registrations.push(await ctx.session.hook('http.request', async (draft) => {
|
|
358
|
-
const
|
|
456
|
+
const mark = draft.request.headers.get(ATTEMPT_HEADER) ?? undefined;
|
|
457
|
+
const rec = await bind(scopeOf(draft), 'http.request', 'http', mark);
|
|
359
458
|
const account = accountOf(rec);
|
|
360
459
|
const attempt = rec.attempt;
|
|
361
460
|
if (!account || !attempt)
|
|
@@ -363,6 +462,13 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
363
462
|
await failing(rec, async () => {
|
|
364
463
|
let request = draft.request;
|
|
365
464
|
if (adapter.rewriteRequest) {
|
|
465
|
+
if (mark !== undefined) {
|
|
466
|
+
// The mark is the installer's own; the adapter's rewrite never
|
|
467
|
+
// sees it.
|
|
468
|
+
const unmarked = new Headers(request.headers);
|
|
469
|
+
unmarked.delete(ATTEMPT_HEADER);
|
|
470
|
+
request = new Request(request, { headers: unmarked });
|
|
471
|
+
}
|
|
366
472
|
request =
|
|
367
473
|
(await adapter.rewriteRequest({
|
|
368
474
|
...account,
|
|
@@ -371,6 +477,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
371
477
|
})) ?? request;
|
|
372
478
|
}
|
|
373
479
|
const headers = new Headers(request.headers);
|
|
480
|
+
headers.delete(ATTEMPT_HEADER);
|
|
374
481
|
applyHeaderEditsTo(headers, rec.headers);
|
|
375
482
|
guard(rec.scope, headers.entries());
|
|
376
483
|
const final = new Request(request, { headers });
|
|
@@ -387,11 +494,21 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
387
494
|
}
|
|
388
495
|
return;
|
|
389
496
|
}
|
|
497
|
+
const original = draft.response;
|
|
498
|
+
if (adapter.answeredBy && rec.attempt) {
|
|
499
|
+
const attempt = rec.attempt;
|
|
500
|
+
const answered = await failing(rec, async () => adapter.answeredBy?.({
|
|
501
|
+
request: draft.request,
|
|
502
|
+
response: original,
|
|
503
|
+
attempt,
|
|
504
|
+
}));
|
|
505
|
+
if (answered)
|
|
506
|
+
rebind(rec, answered);
|
|
507
|
+
}
|
|
390
508
|
const account = accountOf(rec);
|
|
391
|
-
const attempt = rec
|
|
392
|
-
if (!
|
|
509
|
+
const attempt = rec.attempt;
|
|
510
|
+
if (!account || !attempt)
|
|
393
511
|
return;
|
|
394
|
-
const original = draft.response;
|
|
395
512
|
rec.status = original.status;
|
|
396
513
|
const quota = adapter.quotaFromHeaders?.(original.headers, original.status, attempt);
|
|
397
514
|
if (quota !== undefined)
|
|
@@ -438,7 +555,14 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
438
555
|
draft.response = response;
|
|
439
556
|
}, scoped));
|
|
440
557
|
registrations.push(await ctx.session.hook('experimental.ws.handshake', async (draft) => {
|
|
441
|
-
|
|
558
|
+
let mark;
|
|
559
|
+
for (const name of Object.keys(draft.headers)) {
|
|
560
|
+
if (name.toLowerCase() !== ATTEMPT_HEADER)
|
|
561
|
+
continue;
|
|
562
|
+
mark ??= draft.headers[name];
|
|
563
|
+
delete draft.headers[name];
|
|
564
|
+
}
|
|
565
|
+
const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws', mark);
|
|
442
566
|
const account = accountOf(rec);
|
|
443
567
|
const attempt = rec.attempt;
|
|
444
568
|
if (!account || !attempt)
|
|
@@ -459,7 +583,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
459
583
|
if (rewriteFrame) {
|
|
460
584
|
registrations.push(await ctx.session.hook('experimental.ws.send', async (draft) => {
|
|
461
585
|
const scope = scopeOf(draft);
|
|
462
|
-
const rec =
|
|
586
|
+
const rec = liveOnSocket(scope.sessionID, scope.kind);
|
|
463
587
|
const frame = await rewriteFrame({
|
|
464
588
|
...scope,
|
|
465
589
|
attempt: rec?.attempt,
|
|
@@ -470,7 +594,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
470
594
|
}, scoped));
|
|
471
595
|
}
|
|
472
596
|
registrations.push(await ctx.session.hook('experimental.ws.receive', (draft) => {
|
|
473
|
-
const rec =
|
|
597
|
+
const rec = liveOnSocket(draft.sessionID, draft.kind);
|
|
474
598
|
if (!rec)
|
|
475
599
|
return;
|
|
476
600
|
try {
|
|
@@ -483,12 +607,27 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
483
607
|
}
|
|
484
608
|
}, scoped));
|
|
485
609
|
registrations.push(await ctx.session.hook('retry', async (draft) => {
|
|
486
|
-
const
|
|
487
|
-
if (!
|
|
610
|
+
const picked = pickForRetry(draft.sessionID);
|
|
611
|
+
if (!picked)
|
|
488
612
|
return;
|
|
489
613
|
const hostDecision = draft.decision;
|
|
614
|
+
if (picked === 'ambiguous') {
|
|
615
|
+
// Nothing tells which open attempt failed: keep the host's
|
|
616
|
+
// decision and leave every attempt as it is.
|
|
617
|
+
await emit('retry', {
|
|
618
|
+
sessionID: draft.sessionID,
|
|
619
|
+
attempt: draft.attempt,
|
|
620
|
+
reason: 'host-decides',
|
|
621
|
+
hostDecision,
|
|
622
|
+
decision: hostDecision,
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const rec = picked;
|
|
627
|
+
rec.judged = true;
|
|
490
628
|
let decision = hostDecision;
|
|
491
629
|
let reason;
|
|
630
|
+
let rerouteFrom;
|
|
492
631
|
const attempt = rec.attempt;
|
|
493
632
|
if (rec.accountId === undefined || !attempt) {
|
|
494
633
|
decision = { retry: false };
|
|
@@ -512,10 +651,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
512
651
|
await rec.ended;
|
|
513
652
|
if (rec.limit) {
|
|
514
653
|
await rec.limit.delivered;
|
|
515
|
-
rec.
|
|
516
|
-
|
|
517
|
-
limit: rec.limit.signal,
|
|
518
|
-
};
|
|
654
|
+
rec.rerouted = true;
|
|
655
|
+
rerouteFrom = { accountId: rec.accountId, limit: rec.limit.signal };
|
|
519
656
|
// No delay: the next attempt goes to another account, and the
|
|
520
657
|
// host would otherwise wait out the refused account's backoff,
|
|
521
658
|
// or not retry at all for errors it deems final.
|
|
@@ -527,6 +664,16 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
527
664
|
}
|
|
528
665
|
}
|
|
529
666
|
draft.decision = decision;
|
|
667
|
+
// The host's retry runs `model.request` again for this attempt's
|
|
668
|
+
// session and kind; that next attempt, and no other attempt of the
|
|
669
|
+
// session, is told what this one ended with.
|
|
670
|
+
if (decision.retry && rec.accountId !== undefined) {
|
|
671
|
+
pendingRetries.set(rec.key, {
|
|
672
|
+
sessionID: rec.scope.sessionID,
|
|
673
|
+
accountId: rec.accountId,
|
|
674
|
+
...(rerouteFrom === undefined ? {} : { rerouteFrom }),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
530
677
|
await emit('retry', {
|
|
531
678
|
sessionID: draft.sessionID,
|
|
532
679
|
...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
|
|
@@ -539,11 +686,15 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
539
686
|
});
|
|
540
687
|
}, scoped));
|
|
541
688
|
const forgetSession = (sessionID) => {
|
|
542
|
-
for (const
|
|
689
|
+
for (const rec of [...attempts.values()]) {
|
|
543
690
|
if (rec.scope.sessionID !== sessionID)
|
|
544
691
|
continue;
|
|
545
692
|
abandon(rec, 'its session was forgotten');
|
|
546
|
-
|
|
693
|
+
attempts.delete(rec.id);
|
|
694
|
+
}
|
|
695
|
+
for (const [key, pending] of pendingRetries) {
|
|
696
|
+
if (pending.sessionID === sessionID)
|
|
697
|
+
pendingRetries.delete(key);
|
|
547
698
|
}
|
|
548
699
|
};
|
|
549
700
|
const abort = new AbortController();
|
|
@@ -580,20 +731,21 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
580
731
|
};
|
|
581
732
|
},
|
|
582
733
|
accountFor(sessionID, kind) {
|
|
583
|
-
return
|
|
734
|
+
return newestOf(keyOf(sessionID, kind))?.accountId;
|
|
584
735
|
},
|
|
585
736
|
forgetSession,
|
|
586
737
|
get size() {
|
|
587
|
-
return
|
|
738
|
+
return attempts.size;
|
|
588
739
|
},
|
|
589
740
|
async dispose() {
|
|
590
741
|
if (disposed)
|
|
591
742
|
return;
|
|
592
743
|
disposed = true;
|
|
593
744
|
abort.abort();
|
|
594
|
-
for (const rec of
|
|
745
|
+
for (const rec of attempts.values())
|
|
595
746
|
abandon(rec, 'the installation was disposed');
|
|
596
|
-
|
|
747
|
+
attempts.clear();
|
|
748
|
+
pendingRetries.clear();
|
|
597
749
|
listeners.clear();
|
|
598
750
|
await Promise.all(registrations.map(async (registration) => {
|
|
599
751
|
try {
|
|
@@ -53,12 +53,16 @@ export interface EventVerdict<Q> {
|
|
|
53
53
|
*/
|
|
54
54
|
export type HeaderEdits = Readonly<Record<string, string | null>>;
|
|
55
55
|
export interface ChooseAccountInput extends RequestScope {
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The account of the attempt this one retries, when the retry hook just
|
|
58
|
+
* asked the host to retry an attempt of this session and kind; otherwise
|
|
59
|
+
* the account of the newest attempt of this session and kind.
|
|
60
|
+
*/
|
|
57
61
|
readonly previousAccountId?: string;
|
|
58
62
|
/**
|
|
59
|
-
* Set when the
|
|
60
|
-
* and the retry hook asked the host to try again. The adapter
|
|
61
|
-
* pick `accountId` again unless it has nothing else.
|
|
63
|
+
* Set when the attempt this one retries was refused for a limit before any
|
|
64
|
+
* output and the retry hook asked the host to try again. The adapter
|
|
65
|
+
* should not pick `accountId` again unless it has nothing else.
|
|
62
66
|
*/
|
|
63
67
|
readonly rerouteFrom?: {
|
|
64
68
|
readonly accountId: string;
|
|
@@ -87,8 +91,27 @@ export interface Attempt<A = unknown> extends AccountRequest {
|
|
|
87
91
|
* `model.request` is chosen before the host has picked the transport.
|
|
88
92
|
*/
|
|
89
93
|
readonly transport: Transport | undefined;
|
|
90
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* What `accountHeaders` returned as `attempt`, if anything, or what
|
|
96
|
+
* `answeredBy` replaced it with.
|
|
97
|
+
*/
|
|
91
98
|
readonly data: A | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Set when `answeredBy` said another account answered this attempt's HTTP
|
|
101
|
+
* response: the account `chooseAccount` picked. `accountId` is then the
|
|
102
|
+
* account that answered, and everything from the response on is attributed
|
|
103
|
+
* to it.
|
|
104
|
+
*/
|
|
105
|
+
readonly reboundFrom?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The account that actually answered an HTTP response, as `answeredBy`
|
|
109
|
+
* reports it. `data`, when the key is present, replaces the attempt's `data`
|
|
110
|
+
* (the answering account's own credential receipt, say).
|
|
111
|
+
*/
|
|
112
|
+
export interface ResponseAccount<A = unknown> {
|
|
113
|
+
readonly accountId: string;
|
|
114
|
+
readonly data?: A;
|
|
92
115
|
}
|
|
93
116
|
/**
|
|
94
117
|
* Why an attempt ended without completing.
|
|
@@ -97,11 +120,12 @@ export interface Attempt<A = unknown> extends AccountRequest {
|
|
|
97
120
|
* reported the response as failed;
|
|
98
121
|
* - `cancelled`: the host cancelled the HTTP response body (the user stopped
|
|
99
122
|
* the turn, say);
|
|
100
|
-
* - `abandoned`: the attempt
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
123
|
+
* - `abandoned`: the attempt can no longer be observed: it was on WebSocket
|
|
124
|
+
* (or the retry hook had already judged it) when a newer attempt of the
|
|
125
|
+
* same session and kind began, another attempt of its session and kind
|
|
126
|
+
* went out over WebSocket, its session was forgotten, it was dropped for
|
|
127
|
+
* space, or the installation was disposed. A WebSocket closed or cancelled
|
|
128
|
+
* mid-response ends this way, since the host has no hook for either.
|
|
105
129
|
*/
|
|
106
130
|
export type AttemptEndReason = 'failed' | 'cancelled' | 'abandoned';
|
|
107
131
|
/** How an attempt ended, as `onAttemptEnd` reports it. */
|
|
@@ -176,6 +200,25 @@ export interface OpenCode2AuthAdapter<Q = unknown, A = unknown> {
|
|
|
176
200
|
readonly response: Response;
|
|
177
201
|
readonly attempt: Attempt<A>;
|
|
178
202
|
}): Promise<Response | undefined> | Response | undefined;
|
|
203
|
+
/**
|
|
204
|
+
* Optional. For an adapter whose own sender can move a request to another
|
|
205
|
+
* account after `rewriteRequest` (a bridge that answers a 401 or 403 by
|
|
206
|
+
* retrying on the next account, say): runs first in `http.response`,
|
|
207
|
+
* before quota, refusal, output or end detection, and returns the account
|
|
208
|
+
* that actually answered, or `undefined` when the attempt's own account
|
|
209
|
+
* did. A different account rebinds the attempt to it (`Attempt.accountId`
|
|
210
|
+
* becomes that account and `reboundFrom` the one chosen), so the response's
|
|
211
|
+
* quota, refusal, stream events, end and any retry reroute are attributed
|
|
212
|
+
* to the answering account, once, and never to the chosen one. What the
|
|
213
|
+
* sender saw from the chosen account before it moved on (the 401 itself)
|
|
214
|
+
* never reaches the installer: the adapter accounts for it itself.
|
|
215
|
+
* Throwing ends the attempt as `failed` and fails the response hook.
|
|
216
|
+
*/
|
|
217
|
+
answeredBy?(input: {
|
|
218
|
+
readonly request: Request;
|
|
219
|
+
readonly response: Response;
|
|
220
|
+
readonly attempt: Attempt<A>;
|
|
221
|
+
}): Promise<ResponseAccount<A> | undefined> | ResponseAccount<A> | undefined;
|
|
179
222
|
/** Optional WebSocket URL rewrite. Return `undefined` to keep the URL. */
|
|
180
223
|
rewriteHandshakeURL?(input: AccountRequest & {
|
|
181
224
|
readonly url: string;
|
|
@@ -270,7 +313,7 @@ export interface InstallOpenCode2AuthOptions {
|
|
|
270
313
|
* adapter's provider.
|
|
271
314
|
*/
|
|
272
315
|
readonly hostCredentials?: readonly string[];
|
|
273
|
-
/** Most
|
|
316
|
+
/** Most attempts kept before the oldest is abandoned and dropped. */
|
|
274
317
|
readonly maxRecords?: number;
|
|
275
318
|
readonly logger?: OpenCode2AuthLogger;
|
|
276
319
|
}
|
|
@@ -331,11 +374,16 @@ export interface OpenCode2AuthInstallation<Q, A = unknown> {
|
|
|
331
374
|
* host. Returns a function that removes the listener.
|
|
332
375
|
*/
|
|
333
376
|
on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q, A>[E]) => void | Promise<void>): () => void;
|
|
334
|
-
/**
|
|
377
|
+
/**
|
|
378
|
+
* The account of the newest attempt of a session and request kind (after
|
|
379
|
+
* any `answeredBy` rebind). With several attempts of one session and kind
|
|
380
|
+
* in flight it names only the newest; each attempt's own account is on its
|
|
381
|
+
* handle.
|
|
382
|
+
*/
|
|
335
383
|
accountFor(sessionID: string, kind: RequestKind): string | undefined;
|
|
336
|
-
/** Drops every
|
|
384
|
+
/** Drops every attempt of a session. Session deletion does this itself. */
|
|
337
385
|
forgetSession(sessionID: string): void;
|
|
338
|
-
/** Number of
|
|
386
|
+
/** Number of attempts held. */
|
|
339
387
|
readonly size: number;
|
|
340
388
|
/** Removes every hook and stops listening for session deletion. */
|
|
341
389
|
dispose(): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"types": "bun run typecheck",
|
|
88
88
|
"format": "biome check --write --unsafe .",
|
|
89
89
|
"format:check": "biome format .",
|
|
90
|
-
"lint": "biome
|
|
90
|
+
"lint": "biome check .",
|
|
91
91
|
"prepublishOnly": "bun run build",
|
|
92
92
|
"check:ranges": "bun scripts/check-installed-ranges.mjs"
|
|
93
93
|
},
|