@cortexkit/common-auth 0.4.6 → 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.
@@ -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';
@@ -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 the request's `sessionID:kind`,
10
- * which starts a new attempt, and sets its headers;
11
- * - `http.request` and `experimental.ws.handshake` set them again, because
12
- * the host applies its own credential after `model.request`;
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. An HTTP response belongs to the attempt whose `http.request`
23
- * produced its request object, and to nothing else. The host hands
24
- * `http.response` the request object the `http.request` hooks left, so a
25
- * different object means a later hook replaced it; nothing then proves which
26
- * send the response answers (an earlier attempt's send may still be in
27
- * flight, and only the newest attempt of each session and kind is kept), and
28
- * its feedback is dropped rather than guessed by recency. No marker can ride
29
- * on the request instead: the host builds the wire request from that same
30
- * object, so a marker would be sent to the provider. A WebSocket frame
31
- * belongs to the newest attempt of its session and kind only while that
32
- * attempt went out over WebSocket and has not ended: the host runs one
33
- * exchange at a time per session socket, so frames between one handshake
34
- * and the next belong to the earlier attempt. Anything else is attributed to
35
- * no attempt and reaches no adapter callback or listener.
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 the request's `sessionID:kind`,
81
- * which starts a new attempt, and sets its headers;
82
- * - `http.request` and `experimental.ws.handshake` set them again, because
83
- * the host applies its own credential after `model.request`;
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. An HTTP response belongs to the attempt whose `http.request`
94
- * produced its request object, and to nothing else. The host hands
95
- * `http.response` the request object the `http.request` hooks left, so a
96
- * different object means a later hook replaced it; nothing then proves which
97
- * send the response answers (an earlier attempt's send may still be in
98
- * flight, and only the newest attempt of each session and kind is kept), and
99
- * its feedback is dropped rather than guessed by recency. No marker can ride
100
- * on the request instead: the host builds the wire request from that same
101
- * object, so a marker would be sent to the provider. A WebSocket frame
102
- * belongs to the newest attempt of its session and kind only while that
103
- * attempt went out over WebSocket and has not ended: the host runs one
104
- * exchange at a time per session socket, so frames between one handshake
105
- * and the next belong to the earlier attempt. Anything else is attributed to
106
- * no attempt and reaches no adapter callback or listener.
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
- const records = new Map();
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
- const key = keyOf(rec.scope.sessionID, rec.scope.kind);
167
- records.delete(key);
168
- records.set(key, rec);
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[1], 'dropped to keep the record bound');
174
- records.delete(oldest[0]);
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 prior = records.get(keyOf(scope.sessionID, scope.kind));
201
- if (prior)
202
- abandon(prior, 'a newer attempt of its session and kind began');
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
- ...(prior?.accountId === undefined
206
- ? {}
207
- : { previousAccountId: prior.accountId }),
208
- ...(prior?.rerouteFrom === undefined
251
+ ...(previousAccountId === undefined ? {} : { previousAccountId }),
252
+ ...(retried?.rerouteFrom === undefined
209
253
  ? {}
210
- : { rerouteFrom: prior.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: `attempt-${id}`,
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 it
262
- // from going out under the host credential, and a second send without a
263
- // `model.request` in between from reusing the first send's attempt.
264
- const bind = async (scope, hook, transport) => {
265
- const rec = records.get(keyOf(scope.sessionID, scope.kind));
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
- return select(scope, hook, transport);
268
- rec.transport = transport;
269
- if (rec.attempt)
270
- rec.attempt.transport = transport;
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
- const liveOn = (rec, transport) => rec?.attempt !== undefined && rec.transport === transport && !rec.ended
284
- ? rec
285
- : undefined;
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
- let refused;
335
- let primary;
336
- let latest;
337
- for (const rec of records.values()) {
338
- if (rec.scope.sessionID !== sessionID)
339
- continue;
340
- if (!latest || rec.seq > latest.seq)
341
- latest = rec;
342
- if (rec.scope.kind === 'primary')
343
- primary = rec;
344
- if (rec.limit && !rec.rerouteFrom && (!refused || rec.seq > refused.seq))
345
- refused = rec;
346
- }
347
- return refused ?? primary ?? latest;
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, rec.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 rec = await bind(scopeOf(draft), 'http.request', 'http');
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?.attempt;
392
- if (!rec || !account || !attempt)
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
- const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws');
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 = liveOn(records.get(keyOf(scope.sessionID, scope.kind)), 'ws');
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 = liveOn(records.get(keyOf(draft.sessionID, draft.kind)), 'ws');
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 rec = pickForRetry(draft.sessionID);
487
- if (!rec)
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.rerouteFrom = {
516
- accountId: rec.accountId,
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 [key, rec] of records) {
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
- records.delete(key);
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 records.get(keyOf(sessionID, kind))?.accountId;
734
+ return newestOf(keyOf(sessionID, kind))?.accountId;
584
735
  },
585
736
  forgetSession,
586
737
  get size() {
587
- return records.size;
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 records.values())
745
+ for (const rec of attempts.values())
595
746
  abandon(rec, 'the installation was disposed');
596
- records.clear();
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
- /** The account the previous attempt of this session and kind used. */
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 previous attempt was refused for a limit before any output
60
- * and the retry hook asked the host to try again. The adapter should not
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
- /** What `accountHeaders` returned as `attempt`, if anything. */
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 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.
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 `sessionID:kind` records kept before the oldest is dropped. */
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
- /** The account last chosen for a session and request kind. */
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 record of a session. Session deletion does this itself. */
384
+ /** Drops every attempt of a session. Session deletion does this itself. */
337
385
  forgetSession(sessionID: string): void;
338
- /** Number of `sessionID:kind` records held. */
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.4.6",
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 lint .",
90
+ "lint": "biome check .",
91
91
  "prepublishOnly": "bun run build",
92
92
  "check:ranges": "bun scripts/check-installed-ranges.mjs"
93
93
  },