@cortexkit/common-auth 0.2.9 → 0.3.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.
@@ -5,4 +5,4 @@ export type { FormAnswer, PoolAuthorization, PoolLoginMethod, RegisterOpenCode2A
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 { AccountRequest, 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, RetryReason, SelectingHook, Transport, } from './types.js';
@@ -6,14 +6,27 @@ export declare function applyHeaderEdits(target: Record<string, string>, edits:
6
6
  * Installs multi-account auth on OpenCode 2's own provider drivers. Every
7
7
  * hook is scoped to `adapter.providerID`:
8
8
  *
9
- * - `model.request` picks the account for the request's `sessionID:kind` and
10
- * sets its headers;
9
+ * - `model.request` picks the account for the request's `sessionID:kind`,
10
+ * which starts a new attempt, and sets its headers;
11
11
  * - `http.request` and `experimental.ws.handshake` set them again, because
12
12
  * the host applies its own credential after `model.request`;
13
- * - `http.response` and `experimental.ws.receive` read quota, refusals and
14
- * whether output has started, attributed through the record above;
13
+ * - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
14
+ * each outgoing frame;
15
+ * - `http.response` and `experimental.ws.receive` read quota, refusals,
16
+ * whether output has started and when the response ended, attributed to
17
+ * an attempt by the rules below;
15
18
  * - `retry` asks the host to retry at once when an account was refused
16
19
  * before any output, so `model.request` runs again and can pick another
17
20
  * account, and refuses to retry once output has started.
21
+ *
22
+ * Attribution. An HTTP response belongs to the attempt whose `http.request`
23
+ * produced its request; if the host hands back a different request object,
24
+ * it belongs to the newest attempt of its session and kind only while that
25
+ * attempt went out over HTTP and has no response yet. A WebSocket frame
26
+ * belongs to the newest attempt of its session and kind only while that
27
+ * attempt went out over WebSocket and has not ended: the host runs one
28
+ * exchange at a time per session socket, so frames between one handshake
29
+ * and the next belong to the earlier attempt. Anything else is attributed to
30
+ * no attempt and reaches no adapter callback or listener.
18
31
  */
19
- export declare function installOpenCode2Auth<Q>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q>>;
32
+ export declare function installOpenCode2Auth<Q, A = unknown>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q, A>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q, A>>;
@@ -10,6 +10,49 @@ const scopeOf = (draft) => ({
10
10
  agent: draft.agent,
11
11
  kind: draft.kind,
12
12
  });
13
+ // A header value is a string or null, so an object under `headers` can only
14
+ // be the `{headers, attempt}` form.
15
+ function isHeadersResult(value) {
16
+ const headers = value.headers;
17
+ return typeof headers === 'object' && headers !== null;
18
+ }
19
+ /**
20
+ * Forwards a response body and reports how it ended: read to the end
21
+ * (`undefined`), errored, or cancelled by whoever was reading it.
22
+ */
23
+ function trackBody(source, onEnd) {
24
+ const reader = source.getReader();
25
+ return new ReadableStream({
26
+ async pull(controller) {
27
+ let chunk;
28
+ try {
29
+ chunk = await reader.read();
30
+ }
31
+ catch (error) {
32
+ onEnd({
33
+ reason: 'failed',
34
+ message: error instanceof Error ? error.message : String(error),
35
+ });
36
+ controller.error(error);
37
+ return;
38
+ }
39
+ if (chunk.done) {
40
+ onEnd();
41
+ controller.close();
42
+ }
43
+ else {
44
+ controller.enqueue(chunk.value);
45
+ }
46
+ },
47
+ async cancel(reason) {
48
+ onEnd({
49
+ reason: 'cancelled',
50
+ ...(reason === undefined ? {} : { message: String(reason) }),
51
+ });
52
+ await reader.cancel(reason);
53
+ },
54
+ });
55
+ }
13
56
  /** Applies edits to a plain header record, replacing every spelling of each name. */
14
57
  export function applyHeaderEdits(target, edits) {
15
58
  for (const [name, value] of Object.entries(edits)) {
@@ -34,15 +77,28 @@ function applyHeaderEditsTo(target, edits) {
34
77
  * Installs multi-account auth on OpenCode 2's own provider drivers. Every
35
78
  * hook is scoped to `adapter.providerID`:
36
79
  *
37
- * - `model.request` picks the account for the request's `sessionID:kind` and
38
- * sets its headers;
80
+ * - `model.request` picks the account for the request's `sessionID:kind`,
81
+ * which starts a new attempt, and sets its headers;
39
82
  * - `http.request` and `experimental.ws.handshake` set them again, because
40
83
  * the host applies its own credential after `model.request`;
41
- * - `http.response` and `experimental.ws.receive` read quota, refusals and
42
- * whether output has started, attributed through the record above;
84
+ * - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
85
+ * each outgoing frame;
86
+ * - `http.response` and `experimental.ws.receive` read quota, refusals,
87
+ * whether output has started and when the response ended, attributed to
88
+ * an attempt by the rules below;
43
89
  * - `retry` asks the host to retry at once when an account was refused
44
90
  * before any output, so `model.request` runs again and can pick another
45
91
  * account, and refuses to retry once output has started.
92
+ *
93
+ * Attribution. An HTTP response belongs to the attempt whose `http.request`
94
+ * produced its request; if the host hands back a different request object,
95
+ * it belongs to the newest attempt of its session and kind only while that
96
+ * attempt went out over HTTP and has no response yet. A WebSocket frame
97
+ * belongs to the newest attempt of its session and kind only while that
98
+ * attempt went out over WebSocket and has not ended: the host runs one
99
+ * exchange at a time per session socket, so frames between one handshake
100
+ * and the next belong to the earlier attempt. Anything else is attributed to
101
+ * no attempt and reaches no adapter callback or listener.
46
102
  */
47
103
  export async function installOpenCode2Auth(ctx, adapter, options = {}) {
48
104
  const { providerID } = adapter;
@@ -76,15 +132,40 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
76
132
  }
77
133
  }));
78
134
  };
135
+ const deliverEnd = async (attempt, outcome) => {
136
+ try {
137
+ await adapter.onAttemptEnd?.(attempt, outcome);
138
+ }
139
+ catch (error) {
140
+ warn('opencode2 auth onAttemptEnd threw; the request is unaffected', {
141
+ attemptId: attempt.attemptId,
142
+ error: describe(error),
143
+ });
144
+ }
145
+ };
146
+ /** Ends an attempt once; later calls for the same attempt do nothing. */
147
+ const finish = (rec, error) => {
148
+ if (rec.ended || !rec.attempt)
149
+ return;
150
+ const outcome = {
151
+ ...(rec.status === undefined ? {} : { status: rec.status }),
152
+ outputStarted: rec.outputStarted,
153
+ ...(rec.limit ? { limit: rec.limit.signal } : {}),
154
+ ...(error ? { error } : {}),
155
+ };
156
+ rec.ended = deliverEnd(rec.attempt, outcome);
157
+ };
158
+ const abandon = (rec, message) => finish(rec, { reason: 'abandoned', message });
79
159
  const remember = (rec) => {
80
160
  const key = keyOf(rec.scope.sessionID, rec.scope.kind);
81
161
  records.delete(key);
82
162
  records.set(key, rec);
83
163
  while (records.size > maxRecords) {
84
- const oldest = records.keys().next().value;
164
+ const oldest = records.entries().next().value;
85
165
  if (oldest === undefined)
86
166
  break;
87
- records.delete(oldest);
167
+ abandon(oldest[1], 'dropped to keep the record bound');
168
+ records.delete(oldest[0]);
88
169
  }
89
170
  };
90
171
  const accountOf = (rec) => rec.accountId === undefined
@@ -109,8 +190,10 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
109
190
  }
110
191
  }
111
192
  };
112
- const select = async (scope, hook) => {
193
+ const select = async (scope, hook, transport) => {
113
194
  const prior = records.get(keyOf(scope.sessionID, scope.kind));
195
+ if (prior)
196
+ abandon(prior, 'a newer attempt of its session and kind began');
114
197
  const input = {
115
198
  ...scope,
116
199
  ...(prior?.accountId === undefined
@@ -121,21 +204,42 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
121
204
  : { rerouteFrom: prior.rerouteFrom }),
122
205
  };
123
206
  const accountId = await adapter.chooseAccount(input);
124
- const headers = accountId === undefined
125
- ? {}
126
- : await adapter.accountHeaders({ ...scope, accountId });
207
+ let headers = {};
208
+ let data;
209
+ if (accountId !== undefined) {
210
+ const result = await adapter.accountHeaders({ ...scope, accountId });
211
+ if (isHeadersResult(result)) {
212
+ headers = result.headers;
213
+ data = result.attempt;
214
+ }
215
+ else {
216
+ headers = result;
217
+ }
218
+ }
219
+ const id = ++seq;
127
220
  const rec = {
128
221
  scope,
129
222
  accountId,
130
223
  headers,
131
- seq: ++seq,
224
+ seq: id,
225
+ attempt: accountId === undefined
226
+ ? undefined
227
+ : {
228
+ ...scope,
229
+ accountId,
230
+ attemptId: `attempt-${id}`,
231
+ transport,
232
+ data,
233
+ },
234
+ ...(transport === undefined ? {} : { transport }),
235
+ responded: false,
132
236
  outputStarted: false,
133
237
  };
134
238
  remember(rec);
135
- if (accountId !== undefined) {
239
+ if (rec.attempt) {
136
240
  void emit('select', {
137
241
  ...scope,
138
- accountId,
242
+ accountId: rec.attempt.accountId,
139
243
  hook,
140
244
  ...(input.previousAccountId === undefined
141
245
  ? {}
@@ -143,17 +247,40 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
143
247
  ...(input.rerouteFrom === undefined
144
248
  ? {}
145
249
  : { rerouteFrom: input.rerouteFrom }),
250
+ handle: rec.attempt,
146
251
  });
147
252
  }
148
253
  return rec;
149
254
  };
150
- // The transport hooks normally follow `model.request`; choosing here too
151
- // keeps a request that skipped it from going out under the host credential.
152
- const ensure = async (scope, hook) => records.get(keyOf(scope.sessionID, scope.kind)) ??
153
- (await select(scope, hook));
255
+ // The transport hooks normally follow `model.request` and carry the
256
+ // attempt it started. Choosing here too keeps a request that skipped it
257
+ // from going out under the host credential, and a second send without a
258
+ // `model.request` in between from reusing the first send's attempt.
259
+ const bind = async (scope, hook, transport) => {
260
+ const rec = records.get(keyOf(scope.sessionID, scope.kind));
261
+ if (!rec || rec.transport !== undefined || rec.ended)
262
+ return select(scope, hook, transport);
263
+ rec.transport = transport;
264
+ if (rec.attempt)
265
+ rec.attempt.transport = transport;
266
+ return rec;
267
+ };
268
+ // A transport hook that throws stops the send, so its attempt is over.
269
+ const failing = async (rec, run) => {
270
+ try {
271
+ return await run();
272
+ }
273
+ catch (error) {
274
+ finish(rec, { reason: 'failed', message: describe(error) });
275
+ throw error;
276
+ }
277
+ };
278
+ const liveOn = (rec, transport) => rec?.attempt !== undefined && rec.transport === transport && !rec.ended
279
+ ? rec
280
+ : undefined;
154
281
  const noteLimit = (rec, signal, via) => {
155
282
  const account = accountOf(rec);
156
- if (!account || rec.limit)
283
+ if (!account || !rec.attempt || rec.limit)
157
284
  return;
158
285
  rec.limit = {
159
286
  signal,
@@ -162,22 +289,29 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
162
289
  via,
163
290
  limit: signal,
164
291
  outputStarted: rec.outputStarted,
292
+ handle: rec.attempt,
165
293
  }),
166
294
  };
167
295
  };
168
296
  const noteQuota = (rec, transport, quota, status) => {
169
297
  const account = accountOf(rec);
170
- if (!account)
298
+ if (!account || !rec.attempt)
171
299
  return;
172
300
  void emit('quota', {
173
301
  ...account,
174
302
  transport,
175
303
  ...(status === undefined ? {} : { status }),
176
304
  quota,
305
+ handle: rec.attempt,
177
306
  });
178
307
  };
179
308
  const inspect = (rec, transport, data, event) => {
180
- const verdict = adapter.inspectEvent?.(event === undefined ? { transport, data } : { transport, data, event });
309
+ const attempt = rec.attempt;
310
+ if (!attempt)
311
+ return;
312
+ const verdict = adapter.inspectEvent?.(event === undefined
313
+ ? { transport, data, attempt }
314
+ : { transport, data, event, attempt });
181
315
  if (!verdict)
182
316
  return;
183
317
  if (verdict.quota !== undefined)
@@ -186,6 +320,10 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
186
320
  rec.outputStarted = true;
187
321
  if (verdict.limit)
188
322
  noteLimit(rec, verdict.limit, transport);
323
+ if (verdict.error !== undefined)
324
+ finish(rec, { reason: 'failed', message: verdict.error });
325
+ else if (verdict.done)
326
+ finish(rec);
189
327
  };
190
328
  const pickForRetry = (sessionID) => {
191
329
  let refused;
@@ -204,22 +342,28 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
204
342
  return refused ?? primary ?? latest;
205
343
  };
206
344
  const scoped = { providerID };
207
- const registrations = [
208
- await ctx.session.hook('model.request', async (draft) => {
209
- const rec = await select(scopeOf(draft), 'model.request');
210
- if (rec.accountId === undefined)
211
- throw noAccount(rec.scope);
212
- applyHeaderEdits(draft.headers, rec.headers);
213
- }, scoped),
214
- await ctx.session.hook('http.request', async (draft) => {
215
- const rec = await ensure(scopeOf(draft), 'http.request');
216
- const account = accountOf(rec);
217
- if (!account)
218
- throw noAccount(rec.scope);
345
+ const registrations = [];
346
+ registrations.push(await ctx.session.hook('model.request', async (draft) => {
347
+ const rec = await select(scopeOf(draft), 'model.request');
348
+ if (rec.accountId === undefined)
349
+ throw noAccount(rec.scope);
350
+ applyHeaderEdits(draft.headers, rec.headers);
351
+ }, scoped));
352
+ registrations.push(await ctx.session.hook('http.request', async (draft) => {
353
+ const rec = await bind(scopeOf(draft), 'http.request', 'http');
354
+ const account = accountOf(rec);
355
+ const attempt = rec.attempt;
356
+ if (!account || !attempt)
357
+ throw noAccount(rec.scope);
358
+ await failing(rec, async () => {
219
359
  let request = draft.request;
220
360
  if (adapter.rewriteRequest) {
221
361
  request =
222
- (await adapter.rewriteRequest({ ...account, request })) ?? request;
362
+ (await adapter.rewriteRequest({
363
+ ...account,
364
+ request,
365
+ attempt,
366
+ })) ?? request;
223
367
  }
224
368
  const headers = new Headers(request.headers);
225
369
  applyHeaderEditsTo(headers, rec.headers);
@@ -227,130 +371,170 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
227
371
  const final = new Request(request, { headers });
228
372
  byRequest.set(final, rec);
229
373
  draft.request = final;
230
- }, scoped),
231
- await ctx.session.hook('http.response', async (draft) => {
232
- const rec = byRequest.get(draft.request) ??
233
- records.get(keyOf(draft.sessionID, draft.kind));
234
- const account = rec && accountOf(rec);
235
- if (!rec || !account)
236
- return;
237
- const original = draft.response;
238
- const quota = adapter.quotaFromHeaders?.(original.headers, original.status);
239
- if (quota !== undefined)
240
- noteQuota(rec, 'http', quota, original.status);
241
- if (!original.ok && adapter.limitFromResponse) {
242
- const signal = await adapter.limitFromResponse({
243
- status: original.status,
244
- headers: original.headers,
245
- body: () => original.clone().text(),
246
- });
247
- if (signal)
248
- noteLimit(rec, signal, 'http');
249
- }
250
- let response = original;
251
- if (original.body && adapter.inspectEvent) {
252
- const watch = watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
374
+ });
375
+ }, scoped));
376
+ registrations.push(await ctx.session.hook('http.response', async (draft) => {
377
+ const latest = records.get(keyOf(draft.sessionID, draft.kind));
378
+ const rec = byRequest.get(draft.request) ??
379
+ (latest && !latest.responded ? liveOn(latest, 'http') : undefined);
380
+ const account = rec && accountOf(rec);
381
+ const attempt = rec?.attempt;
382
+ if (!rec || !account || !attempt)
383
+ return;
384
+ const original = draft.response;
385
+ rec.responded = true;
386
+ rec.status = original.status;
387
+ const quota = adapter.quotaFromHeaders?.(original.headers, original.status, attempt);
388
+ if (quota !== undefined)
389
+ noteQuota(rec, 'http', quota, original.status);
390
+ if (!original.ok && adapter.limitFromResponse) {
391
+ const signal = await adapter.limitFromResponse({
392
+ status: original.status,
393
+ headers: original.headers,
394
+ body: () => original.clone().text(),
395
+ attempt,
396
+ });
397
+ if (signal)
398
+ noteLimit(rec, signal, 'http');
399
+ }
400
+ // An error response carries no output, so its status is the outcome;
401
+ // ending here keeps the end from depending on whether the host reads
402
+ // the error body to the end.
403
+ if (!original.ok || !original.body)
404
+ finish(rec);
405
+ let response = original;
406
+ if (original.body && (adapter.inspectEvent || adapter.onAttemptEnd)) {
407
+ let body = original.body;
408
+ if (adapter.inspectEvent) {
409
+ body = body.pipeThrough(watchServerSentEvents((event) => inspect(rec, 'http', event.data, event.event), (error) => warn('opencode2 auth event inspection threw', {
253
410
  error: describe(error),
254
- }));
255
- response = new Response(original.body.pipeThrough(watch), {
256
- status: original.status,
257
- statusText: original.statusText,
258
- headers: original.headers,
259
- });
411
+ })));
260
412
  }
261
- if (adapter.rewriteResponse) {
262
- response =
263
- (await adapter.rewriteResponse({
264
- ...account,
265
- request: draft.request,
266
- response,
267
- })) ?? response;
268
- }
269
- if (response !== original)
270
- draft.response = response;
271
- }, scoped),
272
- await ctx.session.hook('experimental.ws.handshake', async (draft) => {
273
- const rec = await ensure(scopeOf(draft), 'experimental.ws.handshake');
274
- const account = accountOf(rec);
275
- if (!account)
276
- throw noAccount(rec.scope);
413
+ response = new Response(trackBody(body, (error) => finish(rec, error)), {
414
+ status: original.status,
415
+ statusText: original.statusText,
416
+ headers: original.headers,
417
+ });
418
+ }
419
+ if (adapter.rewriteResponse) {
420
+ response =
421
+ (await adapter.rewriteResponse({
422
+ ...account,
423
+ request: draft.request,
424
+ response,
425
+ attempt,
426
+ })) ?? response;
427
+ }
428
+ if (response !== original)
429
+ draft.response = response;
430
+ }, scoped));
431
+ registrations.push(await ctx.session.hook('experimental.ws.handshake', async (draft) => {
432
+ const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws');
433
+ const account = accountOf(rec);
434
+ const attempt = rec.attempt;
435
+ if (!account || !attempt)
436
+ throw noAccount(rec.scope);
437
+ await failing(rec, async () => {
277
438
  applyHeaderEdits(draft.headers, rec.headers);
278
439
  const url = adapter.rewriteHandshakeURL?.({
279
440
  ...account,
280
441
  url: draft.url,
442
+ attempt,
281
443
  });
282
444
  if (url !== undefined)
283
445
  draft.url = url;
284
446
  guard(rec.scope, Object.entries(draft.headers));
285
- }, scoped),
286
- await ctx.session.hook('experimental.ws.receive', (draft) => {
287
- const rec = records.get(keyOf(draft.sessionID, draft.kind));
288
- if (!rec || rec.accountId === undefined)
289
- return;
290
- try {
291
- inspect(rec, 'ws', draft.frame);
292
- }
293
- catch (error) {
294
- warn('opencode2 auth frame inspection threw', {
295
- error: describe(error),
296
- });
297
- }
298
- }, scoped),
299
- await ctx.session.hook('retry', async (draft) => {
300
- const rec = pickForRetry(draft.sessionID);
301
- if (!rec)
302
- return;
303
- const hostDecision = draft.decision;
304
- let decision = hostDecision;
305
- let reason;
306
- if (rec.accountId === undefined) {
307
- decision = { retry: false };
308
- reason = 'no-account';
447
+ });
448
+ }, scoped));
449
+ const rewriteFrame = adapter.rewriteWebSocketFrame?.bind(adapter);
450
+ if (rewriteFrame) {
451
+ registrations.push(await ctx.session.hook('experimental.ws.send', async (draft) => {
452
+ const scope = scopeOf(draft);
453
+ const rec = liveOn(records.get(keyOf(scope.sessionID, scope.kind)), 'ws');
454
+ const frame = await rewriteFrame({
455
+ ...scope,
456
+ attempt: rec?.attempt,
457
+ frame: draft.frame,
458
+ });
459
+ if (frame !== undefined)
460
+ draft.frame = frame;
461
+ }, scoped));
462
+ }
463
+ registrations.push(await ctx.session.hook('experimental.ws.receive', (draft) => {
464
+ const rec = liveOn(records.get(keyOf(draft.sessionID, draft.kind)), 'ws');
465
+ if (!rec)
466
+ return;
467
+ try {
468
+ inspect(rec, 'ws', draft.frame);
469
+ }
470
+ catch (error) {
471
+ warn('opencode2 auth frame inspection threw', {
472
+ error: describe(error),
473
+ });
474
+ }
475
+ }, scoped));
476
+ registrations.push(await ctx.session.hook('retry', async (draft) => {
477
+ const rec = pickForRetry(draft.sessionID);
478
+ if (!rec)
479
+ return;
480
+ const hostDecision = draft.decision;
481
+ let decision = hostDecision;
482
+ let reason;
483
+ const attempt = rec.attempt;
484
+ if (rec.accountId === undefined || !attempt) {
485
+ decision = { retry: false };
486
+ reason = 'no-account';
487
+ }
488
+ else if (rec.outputStarted) {
489
+ // The user has already seen part of this answer; a retry would
490
+ // send it again.
491
+ decision = { retry: false };
492
+ reason = 'output-started';
493
+ }
494
+ else {
495
+ if (!rec.limit) {
496
+ const signal = adapter.limitFromError?.(draft.error, attempt);
497
+ if (signal)
498
+ noteLimit(rec, signal, 'error');
309
499
  }
310
- else if (rec.outputStarted) {
311
- // The user has already seen part of this answer; a retry would
312
- // send it again.
313
- decision = { retry: false };
314
- reason = 'output-started';
500
+ // The next `chooseAccount` should see whatever the plugin learnt
501
+ // from how this attempt ended.
502
+ if (rec.ended)
503
+ await rec.ended;
504
+ if (rec.limit) {
505
+ await rec.limit.delivered;
506
+ rec.rerouteFrom = {
507
+ accountId: rec.accountId,
508
+ limit: rec.limit.signal,
509
+ };
510
+ // No delay: the next attempt goes to another account, and the
511
+ // host would otherwise wait out the refused account's backoff,
512
+ // or not retry at all for errors it deems final.
513
+ decision = { retry: true, delay: 0 };
514
+ reason = 'reroute';
315
515
  }
316
516
  else {
317
- if (!rec.limit) {
318
- const signal = adapter.limitFromError?.(draft.error);
319
- if (signal)
320
- noteLimit(rec, signal, 'error');
321
- }
322
- if (rec.limit) {
323
- await rec.limit.delivered;
324
- rec.rerouteFrom = {
325
- accountId: rec.accountId,
326
- limit: rec.limit.signal,
327
- };
328
- // No delay: the next attempt goes to another account, and the
329
- // host would otherwise wait out the refused account's backoff,
330
- // or not retry at all for errors it deems final.
331
- decision = { retry: true, delay: 0 };
332
- reason = 'reroute';
333
- }
334
- else {
335
- reason = 'host-decides';
336
- }
517
+ reason = 'host-decides';
337
518
  }
338
- draft.decision = decision;
339
- await emit('retry', {
340
- sessionID: draft.sessionID,
341
- ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
342
- kind: rec.scope.kind,
343
- attempt: draft.attempt,
344
- reason,
345
- hostDecision,
346
- decision,
347
- });
348
- }, scoped),
349
- ];
519
+ }
520
+ draft.decision = decision;
521
+ await emit('retry', {
522
+ sessionID: draft.sessionID,
523
+ ...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
524
+ kind: rec.scope.kind,
525
+ attempt: draft.attempt,
526
+ reason,
527
+ hostDecision,
528
+ decision,
529
+ ...(attempt === undefined ? {} : { handle: attempt }),
530
+ });
531
+ }, scoped));
350
532
  const forgetSession = (sessionID) => {
351
533
  for (const [key, rec] of records) {
352
- if (rec.scope.sessionID === sessionID)
353
- records.delete(key);
534
+ if (rec.scope.sessionID !== sessionID)
535
+ continue;
536
+ abandon(rec, 'its session was forgotten');
537
+ records.delete(key);
354
538
  }
355
539
  };
356
540
  const abort = new AbortController();
@@ -398,6 +582,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
398
582
  return;
399
583
  disposed = true;
400
584
  abort.abort();
585
+ for (const rec of records.values())
586
+ abandon(rec, 'the installation was disposed');
401
587
  records.clear();
402
588
  listeners.clear();
403
589
  await Promise.all(registrations.map(async (registration) => {
@@ -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> | 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,35 @@ 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. Do not
193
+ * touch `input` or `previous_response_id`. Adding or changing settings
194
+ * fields uniformly keeps the follow-up turn incremental.
195
+ *
196
+ * It runs for every frame, including one no attempt can be tied to
197
+ * (`attempt` is then `undefined`), so the rewrite never depends on
198
+ * attribution.
199
+ */
200
+ rewriteWebSocketFrame?(input: RequestScope & {
201
+ readonly attempt: Attempt<A> | undefined;
202
+ readonly frame: string;
203
+ }): Promise<string | undefined> | string | undefined;
106
204
  /** Quota carried in HTTP response headers. */
107
- quotaFromHeaders?(headers: Headers, status: number): Q | undefined;
205
+ quotaFromHeaders?(headers: Headers, status: number, attempt: Attempt<A>): Q | undefined;
108
206
  /**
109
207
  * Recognises an account-level refusal from an HTTP response before its
110
208
  * body is streamed. `body()` reads a copy, so the host still gets the body.
@@ -113,21 +211,35 @@ export interface OpenCode2AuthAdapter<Q = unknown> {
113
211
  readonly status: number;
114
212
  readonly headers: Headers;
115
213
  readonly body: () => Promise<string>;
214
+ readonly attempt: Attempt<A>;
116
215
  }): Promise<LimitSignal | undefined> | LimitSignal | undefined;
117
216
  /**
118
217
  * Inspects one server-sent event (`data` payload) or one WebSocket frame.
119
- * Detects output, quota and refusals inside the stream.
218
+ * Detects output, quota, refusals and the end of the response inside the
219
+ * stream.
120
220
  */
121
221
  inspectEvent?(input: {
122
222
  readonly transport: Transport;
123
223
  readonly data: string;
124
224
  readonly event?: string;
225
+ readonly attempt: Attempt<A>;
125
226
  }): EventVerdict<Q> | undefined;
126
227
  /**
127
228
  * Recognises an account-level refusal from the error the host hands the
128
- * retry hook, for refusals no other hook saw.
229
+ * retry hook, for refusals no other hook saw. `attempt` is the attempt
230
+ * the retry hook judged.
231
+ */
232
+ limitFromError?(error: HostError, attempt: Attempt<A>): LimitSignal | undefined;
233
+ /**
234
+ * Called once per attempt when it ends: an HTTP response body finished,
235
+ * errored or was cancelled; an HTTP response with an error status or no
236
+ * body arrived; an event's verdict said `done` or `error`; or the attempt
237
+ * was abandoned (see `AttemptEndReason`). Errors are logged and never
238
+ * reach the host. The retry hook waits for this call to settle before it
239
+ * decides, so a plugin that records a refusal here has it in place when
240
+ * `chooseAccount` runs again.
129
241
  */
130
- limitFromError?(error: HostError): LimitSignal | undefined;
242
+ onAttemptEnd?(attempt: Attempt<A>, outcome: AttemptOutcome): Promise<void> | void;
131
243
  }
132
244
  export interface OpenCode2AuthLogger {
133
245
  warn(message: string, data?: unknown): void;
@@ -150,18 +262,24 @@ export type RetryReason = 'reroute' | 'output-started' | 'no-account' | 'host-de
150
262
  * plugin may want to log as a host change.
151
263
  */
152
264
  export type SelectingHook = 'model.request' | 'http.request' | 'experimental.ws.handshake';
153
- export interface OpenCode2AuthEvents<Q> {
265
+ /**
266
+ * Every event that belongs to one attempt carries it as `handle` (the
267
+ * `retry` event already uses `attempt` for the host's retry count).
268
+ */
269
+ export interface OpenCode2AuthEvents<Q, A = unknown> {
154
270
  /** An account was picked for a model request. */
155
271
  readonly select: AccountRequest & {
156
272
  readonly hook: SelectingHook;
157
273
  readonly previousAccountId?: string;
158
274
  readonly rerouteFrom?: ChooseAccountInput['rerouteFrom'];
275
+ readonly handle: Attempt<A>;
159
276
  };
160
277
  /** A quota reading, attributed through this installer's own record. */
161
278
  readonly quota: AccountRequest & {
162
279
  readonly transport: Transport;
163
280
  readonly status?: number;
164
281
  readonly quota: Q;
282
+ readonly handle: Attempt<A>;
165
283
  };
166
284
  /**
167
285
  * An account-level refusal. The retry hook waits for every listener of
@@ -172,6 +290,7 @@ export interface OpenCode2AuthEvents<Q> {
172
290
  readonly via: Transport | 'error';
173
291
  readonly limit: LimitSignal;
174
292
  readonly outputStarted: boolean;
293
+ readonly handle: Attempt<A>;
175
294
  };
176
295
  /** The retry hook ran for this provider. */
177
296
  readonly retry: {
@@ -182,15 +301,17 @@ export interface OpenCode2AuthEvents<Q> {
182
301
  readonly reason: RetryReason;
183
302
  readonly hostDecision: SessionRetryDecision;
184
303
  readonly decision: SessionRetryDecision;
304
+ /** The attempt the decision was about, when it had an account. */
305
+ readonly handle?: Attempt<A>;
185
306
  };
186
307
  }
187
308
  export type OpenCode2AuthEventName = keyof OpenCode2AuthEvents<unknown>;
188
- export interface OpenCode2AuthInstallation<Q> {
309
+ export interface OpenCode2AuthInstallation<Q, A = unknown> {
189
310
  /**
190
311
  * Listens to an event. Listener errors are logged and never reach the
191
312
  * host. Returns a function that removes the listener.
192
313
  */
193
- on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q>[E]) => void | Promise<void>): () => void;
314
+ on<E extends OpenCode2AuthEventName>(event: E, listener: (payload: OpenCode2AuthEvents<Q, A>[E]) => void | Promise<void>): () => void;
194
315
  /** The account last chosen for a session and request kind. */
195
316
  accountFor(sessionID: string, kind: RequestKind): string | undefined;
196
317
  /** Drops every record of a session. Session deletion does this itself. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.9",
3
+ "version": "0.3.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": {