@cortexkit/common-auth 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +209 -44
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/install.d.ts +8 -3
  30. package/dist/opencode2/install.js +18 -9
  31. package/dist/opencode2/types.d.ts +22 -3
  32. package/dist/quota/projection.d.ts +11 -4
  33. package/dist/quota/projection.js +11 -4
  34. package/dist/routing/admission.js +3 -1
  35. package/dist/routing/index.d.ts +2 -2
  36. package/dist/routing/index.js +1 -1
  37. package/dist/routing/sticky.d.ts +19 -6
  38. package/dist/routing/sticky.js +34 -23
  39. package/dist/rpc/notifications.d.ts +20 -0
  40. package/dist/rpc/notifications.js +21 -0
  41. package/dist/rpc/rpc-server.d.ts +9 -1
  42. package/dist/rpc/rpc-server.js +8 -1
  43. package/dist/sidebar-file/index.d.ts +1 -1
  44. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  45. package/dist/sidebar-file/sidebar-file.js +92 -21
  46. package/dist/store/attribution.js +11 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/mutate.d.ts +23 -3
  50. package/dist/store/mutate.js +43 -26
  51. package/dist/store/pool.d.ts +8 -1
  52. package/dist/store/pool.js +7 -2
  53. package/dist/store/rows.d.ts +17 -4
  54. package/dist/store/rows.js +82 -33
  55. package/dist/store/schema.d.ts +57 -4
  56. package/dist/store/schema.js +100 -7
  57. package/dist/store/torn.d.ts +29 -0
  58. package/dist/store/torn.js +113 -0
  59. package/package.json +1 -1
@@ -8,53 +8,6 @@ const ORDER = {
8
8
  trace: 4,
9
9
  };
10
10
  const MAX_BYTES = 5 * 1024 * 1024;
11
- /**
12
- * Where lines are written, and the level floor, as the host supplied them.
13
- *
14
- * Both start unset. A host decides where its log lives — that decision reads
15
- * host environment variables and host directories, neither of which belongs in
16
- * shared code — and calls `initLogger` before it runs any command. Until then
17
- * every `log.*` call is a silent no-op: throwing would turn the first command a
18
- * host forgot to wire into a crash, and buffering would hold credential-bearing
19
- * lines for an init that may never arrive.
20
- */
21
- let logFileSource;
22
- let initLevelSource;
23
- let runtimeLevel;
24
- let redactor = createRedactor();
25
- let captureSink;
26
- /**
27
- * Point the logger at a host's file and level. Idempotent: calling it again
28
- * replaces both. A runtime level installed by `setLogLevel` is deliberately
29
- * left alone, because it is the operator's explicit choice and outranks the
30
- * floor a host computed at start-up.
31
- */
32
- export function initLogger(options) {
33
- logFileSource = options.file;
34
- initLevelSource = options.level;
35
- redactor = createRedactor(options);
36
- captureSink = options.captureSink;
37
- }
38
- export function setLogLevel(l) {
39
- if (l === undefined || l in ORDER)
40
- runtimeLevel = l;
41
- }
42
- function logFilePath() {
43
- if (logFileSource === undefined)
44
- return undefined;
45
- const resolved = typeof logFileSource === 'function' ? logFileSource() : logFileSource;
46
- return resolved || undefined;
47
- }
48
- function configuredLevel() {
49
- if (runtimeLevel)
50
- return runtimeLevel;
51
- const floor = typeof initLevelSource === 'function' ? initLevelSource() : initLevelSource;
52
- if (floor && floor in ORDER)
53
- return floor;
54
- return 'info';
55
- }
56
- let buffer = [];
57
- let timer;
58
11
  const ROTATE_KEEP = 3;
59
12
  function chmodPrivate(path) {
60
13
  try {
@@ -83,46 +36,6 @@ function rotateIfNeeded(f) {
83
36
  /* never throw */
84
37
  }
85
38
  }
86
- /**
87
- * Write whatever is buffered. Safe to call synchronously from a process-exit
88
- * handler, which is how each host drains the buffer on shutdown.
89
- */
90
- export function flushLogs() {
91
- if (timer) {
92
- clearTimeout(timer);
93
- timer = undefined;
94
- }
95
- if (!buffer.length)
96
- return;
97
- let file;
98
- try {
99
- file = logFilePath();
100
- }
101
- catch {
102
- buffer = [];
103
- return;
104
- }
105
- const text = buffer.join('');
106
- buffer = [];
107
- if (!file)
108
- return;
109
- try {
110
- rotateIfNeeded(file);
111
- if (existsSync(file))
112
- chmodPrivate(file);
113
- appendFileSync(file, text, { encoding: 'utf8', mode: 0o600 });
114
- }
115
- catch {
116
- /* never throw */
117
- }
118
- }
119
- function schedule() {
120
- if (!timer)
121
- timer = setTimeout(() => {
122
- timer = undefined;
123
- flushLogs();
124
- }, 500);
125
- }
126
39
  function safeSerialize(data) {
127
40
  try {
128
41
  return ` ${JSON.stringify(data)}`;
@@ -131,69 +44,199 @@ function safeSerialize(data) {
131
44
  return ' [unserializable]';
132
45
  }
133
46
  }
134
- function emit(channel, level, message, data) {
135
- if (logFileSource === undefined)
136
- return;
137
- try {
138
- if (ORDER[level] > ORDER[configuredLevel()])
47
+ /**
48
+ * One logger's settings and buffer.
49
+ *
50
+ * Where lines are written and the level floor start unset unless `options`
51
+ * supplies them. A host decides where its log lives (that decision reads host
52
+ * environment variables and host directories, neither of which belongs in
53
+ * shared code) and configures the logger before it runs any command. Until
54
+ * then every `log.*` call is a silent no-op: throwing would turn the first
55
+ * command a host forgot to wire into a crash, and buffering would hold
56
+ * credential-bearing lines for a configuration that may never arrive.
57
+ */
58
+ function createEngine(options) {
59
+ let logFileSource;
60
+ let initLevelSource;
61
+ let runtimeLevel;
62
+ let redactor = createRedactor();
63
+ let captureSink;
64
+ let buffer = [];
65
+ let timer;
66
+ function configure(next) {
67
+ logFileSource = next.file;
68
+ initLevelSource = next.level;
69
+ redactor = createRedactor(next);
70
+ captureSink = next.captureSink;
71
+ }
72
+ function setLogLevel(l) {
73
+ if (l === undefined || l in ORDER)
74
+ runtimeLevel = l;
75
+ }
76
+ function logFilePath() {
77
+ if (logFileSource === undefined)
78
+ return undefined;
79
+ const resolved = typeof logFileSource === 'function' ? logFileSource() : logFileSource;
80
+ return resolved || undefined;
81
+ }
82
+ function configuredLevel() {
83
+ if (runtimeLevel)
84
+ return runtimeLevel;
85
+ const floor = typeof initLevelSource === 'function'
86
+ ? initLevelSource()
87
+ : initLevelSource;
88
+ if (floor && floor in ORDER)
89
+ return floor;
90
+ return 'info';
91
+ }
92
+ function flushLogs() {
93
+ if (timer) {
94
+ clearTimeout(timer);
95
+ timer = undefined;
96
+ }
97
+ if (!buffer.length)
139
98
  return;
140
- const scrubbedMessage = redactor.redactStrings(message);
141
- let scrubbedData;
99
+ let file;
142
100
  try {
143
- scrubbedData = redactor.redact(data);
101
+ file = logFilePath();
144
102
  }
145
103
  catch {
146
- scrubbedData = '[unserializable]';
104
+ buffer = [];
105
+ return;
147
106
  }
148
- const line = `[${new Date().toISOString()}] ${level.toUpperCase()} [${channel}] ${scrubbedMessage}` +
149
- (data === undefined ? '' : safeSerialize(scrubbedData)) +
150
- '\n';
151
- // A failing observer must not prevent file logging or escape into the host.
107
+ const text = buffer.join('');
108
+ buffer = [];
109
+ if (!file)
110
+ return;
152
111
  try {
153
- captureSink?.({
154
- channel,
155
- level,
156
- message: scrubbedMessage,
157
- data: scrubbedData,
158
- });
112
+ rotateIfNeeded(file);
113
+ if (existsSync(file))
114
+ chmodPrivate(file);
115
+ appendFileSync(file, text, { encoding: 'utf8', mode: 0o600 });
116
+ }
117
+ catch {
118
+ /* never throw */
159
119
  }
160
- catch { }
161
- buffer.push(line);
162
- if (buffer.length >= 50)
163
- flushLogs();
164
- else
165
- schedule();
166
120
  }
167
- catch {
168
- // Provider and redaction failures must never turn diagnostics into a host crash.
121
+ function schedule() {
122
+ if (!timer)
123
+ timer = setTimeout(() => {
124
+ timer = undefined;
125
+ flushLogs();
126
+ }, 500);
169
127
  }
128
+ function emit(channel, level, message, data) {
129
+ if (logFileSource === undefined)
130
+ return;
131
+ try {
132
+ if (ORDER[level] > ORDER[configuredLevel()])
133
+ return;
134
+ const scrubbedMessage = redactor.redactStrings(message);
135
+ let scrubbedData;
136
+ try {
137
+ scrubbedData = redactor.redact(data);
138
+ }
139
+ catch {
140
+ scrubbedData = '[unserializable]';
141
+ }
142
+ const line = `[${new Date().toISOString()}] ${level.toUpperCase()} [${channel}] ${scrubbedMessage}` +
143
+ (data === undefined ? '' : safeSerialize(scrubbedData)) +
144
+ '\n';
145
+ // A failing observer must not prevent file logging or escape into the host.
146
+ try {
147
+ captureSink?.({
148
+ channel,
149
+ level,
150
+ message: scrubbedMessage,
151
+ data: scrubbedData,
152
+ });
153
+ }
154
+ catch { }
155
+ buffer.push(line);
156
+ if (buffer.length >= 50)
157
+ flushLogs();
158
+ else
159
+ schedule();
160
+ }
161
+ catch {
162
+ // Provider and redaction failures must never turn diagnostics into a host crash.
163
+ }
164
+ }
165
+ function createLogger(channel) {
166
+ return {
167
+ error: (m, d) => emit(channel, 'error', m, d),
168
+ warn: (m, d) => emit(channel, 'warn', m, d),
169
+ info: (m, d) => emit(channel, 'info', m, d),
170
+ debug: (m, d) => emit(channel, 'debug', m, d),
171
+ trace: (m, d) => emit(channel, 'trace', m, d),
172
+ };
173
+ }
174
+ function reset() {
175
+ buffer = [];
176
+ if (timer) {
177
+ clearTimeout(timer);
178
+ timer = undefined;
179
+ }
180
+ logFileSource = undefined;
181
+ initLevelSource = undefined;
182
+ runtimeLevel = undefined;
183
+ redactor = createRedactor();
184
+ captureSink = undefined;
185
+ }
186
+ if (options)
187
+ configure(options);
188
+ return { configure, createLogger, setLogLevel, flushLogs, reset };
189
+ }
190
+ /**
191
+ * A logger instance of its own, configured with `options`. A plugin whose
192
+ * copy of this module may be shared with another plugin in the same process
193
+ * uses this instead of `initLogger`, so neither replaces the other's file,
194
+ * level, redaction or capture sink.
195
+ */
196
+ export function createLoggerInstance(options) {
197
+ const { configure, createLogger, setLogLevel, flushLogs } = createEngine(options);
198
+ return { configure, createLogger, setLogLevel, flushLogs };
199
+ }
200
+ /**
201
+ * The instance behind the module-level functions below, which keep the
202
+ * single-plugin API: one plugin per copy of this module calls `initLogger`
203
+ * and `createLogger` without holding an instance.
204
+ */
205
+ const defaultEngine = createEngine();
206
+ /**
207
+ * Point the module's default logger at a host's file and level, and return
208
+ * it. Idempotent: calling it again replaces both. A runtime level installed
209
+ * by `setLogLevel` is deliberately left alone, because it is the operator's
210
+ * explicit choice and outranks the floor a host computed at start-up.
211
+ */
212
+ export function initLogger(options) {
213
+ defaultEngine.configure(options);
214
+ const { configure, createLogger, setLogLevel, flushLogs } = defaultEngine;
215
+ return { configure, createLogger, setLogLevel, flushLogs };
216
+ }
217
+ export function setLogLevel(l) {
218
+ defaultEngine.setLogLevel(l);
219
+ }
220
+ /**
221
+ * Write whatever the default logger has buffered. Safe to call synchronously
222
+ * from a process-exit handler, which is how each host drains the buffer on
223
+ * shutdown.
224
+ */
225
+ export function flushLogs() {
226
+ defaultEngine.flushLogs();
170
227
  }
171
228
  export function createLogger(channel) {
172
- return {
173
- error: (m, d) => emit(channel, 'error', m, d),
174
- warn: (m, d) => emit(channel, 'warn', m, d),
175
- info: (m, d) => emit(channel, 'info', m, d),
176
- debug: (m, d) => emit(channel, 'debug', m, d),
177
- trace: (m, d) => emit(channel, 'trace', m, d),
178
- };
229
+ return defaultEngine.createLogger(channel);
179
230
  }
180
231
  export async function flushForTest() {
181
- flushLogs();
232
+ defaultEngine.flushLogs();
182
233
  }
183
234
  /**
184
- * Return the logger to its uninitialised state. Only a test needs this: a
185
- * single process runs every test file, so a file path left over from one test
186
- * would keep a later "logger was never initialised" case writing lines.
235
+ * Return the default logger to its uninitialised state. Only a test needs
236
+ * this: a single process runs every test file, so a file path left over from
237
+ * one test would keep a later "logger was never initialised" case writing
238
+ * lines.
187
239
  */
188
240
  export function resetLoggerForTest() {
189
- buffer = [];
190
- if (timer) {
191
- clearTimeout(timer);
192
- timer = undefined;
193
- }
194
- logFileSource = undefined;
195
- initLevelSource = undefined;
196
- runtimeLevel = undefined;
197
- redactor = createRedactor();
198
- captureSink = undefined;
241
+ defaultEngine.reset();
199
242
  }
@@ -1,6 +1,6 @@
1
1
  export type { CaptureSink, LogTestRecord } from './capture-sink.js';
2
2
  export { createCaptureSink } from './capture-sink.js';
3
- export type { InitLoggerOptions, Level } from './engine.js';
4
- export { createLogger, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
3
+ export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, } from './engine.js';
4
+ export { createLogger, createLoggerInstance, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
5
5
  export type { RedactionOptions, Redactor } from './redact.js';
6
6
  export { createRedactor, redact, redactStrings } from './redact.js';
@@ -1,3 +1,3 @@
1
1
  export { createCaptureSink } from './capture-sink.js';
2
- export { createLogger, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
2
+ export { createLogger, createLoggerInstance, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
3
3
  export { createRedactor, redact, redactStrings } from './redact.js';
@@ -20,9 +20,14 @@ export declare function applyHeaderEdits(target: Record<string, string>, edits:
20
20
  * account, and refuses to retry once output has started.
21
21
  *
22
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
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
26
31
  * belongs to the newest attempt of its session and kind only while that
27
32
  * attempt went out over WebSocket and has not ended: the host runs one
28
33
  * exchange at a time per session socket, so frames between one handshake
@@ -91,9 +91,14 @@ function applyHeaderEditsTo(target, edits) {
91
91
  * account, and refuses to retry once output has started.
92
92
  *
93
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
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
97
102
  * belongs to the newest attempt of its session and kind only while that
98
103
  * attempt went out over WebSocket and has not ended: the host runs one
99
104
  * exchange at a time per session socket, so frames between one handshake
@@ -107,6 +112,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
107
112
  const forbidden = (options.hostCredentials ?? [placeholderSecret(providerID)]).filter((value) => value !== '');
108
113
  const records = new Map();
109
114
  const byRequest = new WeakMap();
115
+ let warnedUnprovenResponse = false;
110
116
  const listeners = new Map();
111
117
  let seq = 0;
112
118
  const warn = (message, data) => {
@@ -232,7 +238,6 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
232
238
  data,
233
239
  },
234
240
  ...(transport === undefined ? {} : { transport }),
235
- responded: false,
236
241
  outputStarted: false,
237
242
  };
238
243
  remember(rec);
@@ -374,15 +379,19 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
374
379
  });
375
380
  }, scoped));
376
381
  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);
382
+ const rec = byRequest.get(draft.request);
383
+ if (!rec) {
384
+ if (!warnedUnprovenResponse) {
385
+ warnedUnprovenResponse = true;
386
+ warn('opencode2 auth dropped an http.response whose request it did not produce', { sessionID: draft.sessionID, kind: draft.kind });
387
+ }
388
+ return;
389
+ }
390
+ const account = accountOf(rec);
381
391
  const attempt = rec?.attempt;
382
392
  if (!rec || !account || !attempt)
383
393
  return;
384
394
  const original = draft.response;
385
- rec.responded = true;
386
395
  rec.status = original.status;
387
396
  const quota = adapter.quotaFromHeaders?.(original.headers, original.status, attempt);
388
397
  if (quota !== undefined)
@@ -189,9 +189,28 @@ export interface OpenCode2AuthAdapter<Q = unknown, A = unknown> {
189
189
  * The rewrite must be a deterministic function of the frame (stable for
190
190
  * the session): the host chains turns with `previous_response_id` by
191
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.
192
+ * only stays in step when every frame is rewritten the same way. Never
193
+ * alter, drop or reorder the `input` items the host put in the frame, and
194
+ * never touch `previous_response_id`: the host's next frame assumes the
195
+ * server holds exactly what it sent. Adding items to `input`, or adding or
196
+ * changing settings fields, keeps the host's order and continuation when
197
+ * done the same way for every frame: the added items become part of the
198
+ * server's history and the follow-up turn stays incremental.
199
+ *
200
+ * What inserting `input` items into every frame does not keep is the
201
+ * equivalence of the two ways a history reaches the server (settings
202
+ * fields carry no history, so they are not affected). Incrementally, each
203
+ * frame carries only
204
+ * the new items, so an item the rewrite inserts into every frame lands at
205
+ * every network boundary of the accumulated history. After a reconnect the
206
+ * host replays the whole history in one frame, and the same rewrite
207
+ * inserts that item once. The two server-side histories, and their cache
208
+ * prefixes, then differ. A rewrite that only adds or changes settings
209
+ * fields is unaffected. An adapter that inserts `input` items must show,
210
+ * for its own insertion, that the accumulated incremental history equals
211
+ * the rewritten full replay (including across tool loops), or accept the
212
+ * divergence and name what it costs (a cache miss and a different prompt
213
+ * after every reconnect).
195
214
  *
196
215
  * It runs for every frame, including one no attempt can be tied to
197
216
  * (`attempt` is then `undefined`), so the rewrite never depends on
@@ -33,10 +33,17 @@ export interface ProjectedQuota {
33
33
  }
34
34
  /**
35
35
  * Resolves `map` for a request in `scope` (`all` or a model family). A family
36
- * request sees only its own family's keys and the `all` keys. Per label, a
37
- * family reading shadows the `all` entry; a family tombstone or absence
38
- * record says only that no family-specific limit exists, so it does not hide
39
- * an `all` entry for the same label and is used only when there is none.
36
+ * request sees only its own family's keys and the `all` keys.
37
+ *
38
+ * The label decides how a family limit relates to a general one. Under the
39
+ * same label, the family reading replaces the `all` reading: use this only
40
+ * when the provider's family figure is a more specific view of the same cap.
41
+ * Under different labels both limits are projected and every consumer judges
42
+ * both, so a family cap that applies on top of a general cap must be stored
43
+ * under its own label; otherwise the general cap is hidden from a family
44
+ * request. A family tombstone or absence record says only that no
45
+ * family-specific limit exists, so it does not hide an `all` entry for the
46
+ * same label and is used only when there is none.
40
47
  */
41
48
  export declare function projectQuota(map: QuotaMap | undefined, scope?: string): ProjectedQuota;
42
49
  export interface ExhaustionReset {
@@ -61,10 +61,17 @@ function projectBudget(budget) {
61
61
  }
62
62
  /**
63
63
  * Resolves `map` for a request in `scope` (`all` or a model family). A family
64
- * request sees only its own family's keys and the `all` keys. Per label, a
65
- * family reading shadows the `all` entry; a family tombstone or absence
66
- * record says only that no family-specific limit exists, so it does not hide
67
- * an `all` entry for the same label and is used only when there is none.
64
+ * request sees only its own family's keys and the `all` keys.
65
+ *
66
+ * The label decides how a family limit relates to a general one. Under the
67
+ * same label, the family reading replaces the `all` reading: use this only
68
+ * when the provider's family figure is a more specific view of the same cap.
69
+ * Under different labels both limits are projected and every consumer judges
70
+ * both, so a family cap that applies on top of a general cap must be stored
71
+ * under its own label; otherwise the general cap is hidden from a family
72
+ * request. A family tombstone or absence record says only that no
73
+ * family-specific limit exists, so it does not hide an `all` entry for the
74
+ * same label and is used only when there is none.
68
75
  */
69
76
  export function projectQuota(map, scope = ALL_SCOPE) {
70
77
  const byLabel = new Map();
@@ -6,7 +6,9 @@
6
6
  //
7
7
  // A marked or backed-off row is excluded before the gates. Stage 1 then
8
8
  // judges each remaining row alone, in gate order:
9
- // 1. an API-key row is admitted without consulting quota;
9
+ // 1. an API-key row is admitted without consulting quota (a provider that
10
+ // offers paid rows only after its OAuth rows are spent leaves them out
11
+ // of `rows` until then);
10
12
  // 2. an OAuth row whose projection resolves no entry for the scope needs a
11
13
  // first reading: refused, pull requested;
12
14
  // 3. a required label with no entry is unknown: refused, pull requested;
@@ -4,5 +4,5 @@ export type { OrderedAttempt, OrderedPlacement, OrderedRoute, OrderedRouteInput,
4
4
  export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
5
5
  export type { StickyPin } from './pins.js';
6
6
  export { isPinValid, pendingBytesForPins } from './pins.js';
7
- export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, } from './sticky.js';
8
- export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, STICKY_WINDOW_SLOTS, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
7
+ export type { PinAction, StickyBreakDecision, StickyRoute, StickyRouteInput, StickySelection, StickySelectionCandidate, StickySelectionInput, StickyStatusClass, StickyStatusClassifier, } from './sticky.js';
8
+ export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
@@ -1,4 +1,4 @@
1
1
  export { admit, exclusionFor } from './admission.js';
2
2
  export { DEFAULT_FORMER_MAIN_ID, nextOrderedAttempt, orderForPlacement, resolveRoutingMode, routeOrdered, } from './ordered.js';
3
3
  export { isPinValid, pendingBytesForPins } from './pins.js';
4
- export { decideStickyBreak, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, STICKY_WINDOW_SLOTS, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
4
+ export { decideStickyBreak, defaultStickyStatusClass, MIN_RESET_HOURS, MIN_WEIGHT, QUOTA_STALENESS_MS, routeSticky, selectStickyCandidate, snapshotCheckedAt, sustainableWindowWeight, } from './sticky.js';
@@ -4,12 +4,6 @@ import { type StickyPin } from './pins.js';
4
4
  export declare const QUOTA_STALENESS_MS: number;
5
5
  export declare const MIN_RESET_HOURS: number;
6
6
  export declare const MIN_WEIGHT = 0.000001;
7
- /**
8
- * How many window readings the selection primitives judge, as openai-auth's
9
- * primary and secondary slots did. The projection's first readings in its
10
- * order fill the slots; any further window is judged by admission only.
11
- */
12
- export declare const STICKY_WINDOW_SLOTS = 2;
13
7
  /** The projection's time, else the caller's cache-entry time. */
14
8
  export declare function snapshotCheckedAt(quota: ProjectedQuota | null | undefined, entryCheckedAt?: number): number | undefined;
15
9
  export type StickyBreakDecision = {
@@ -22,6 +16,23 @@ export type StickyBreakDecision = {
22
16
  window?: WindowRef;
23
17
  resetsAt?: string;
24
18
  };
19
+ /**
20
+ * How the adapter reads a failed response's HTTP status for the break
21
+ * decision. `permanent` moves the session off its row before any quota is
22
+ * consulted; `transient` and `healthy` keep it unless the quota says the row
23
+ * is spent. A missing status means the request failed without a response.
24
+ */
25
+ export type StickyStatusClass = 'permanent' | 'transient' | 'healthy';
26
+ export type StickyStatusClassifier = (status: number | undefined) => StickyStatusClass;
27
+ /**
28
+ * The status rule used when the adapter supplies none, carried from
29
+ * openai-auth: 401 and 403 are permanent; no response, 0, a non-finite
30
+ * status, 429 and 5xx are transient; anything else leaves the row healthy.
31
+ * A provider whose 403 can mean an organisation or model policy rather than
32
+ * a dead account supplies its own classifier and can fall back to this one
33
+ * for the statuses it does not single out.
34
+ */
35
+ export declare function defaultStickyStatusClass(status: number | undefined): StickyStatusClass;
25
36
  /** Classifies whether a pinned session should leave its row after a failure. */
26
37
  export declare function decideStickyBreak(input: {
27
38
  quota: ProjectedQuota | null | undefined;
@@ -29,6 +40,8 @@ export declare function decideStickyBreak(input: {
29
40
  status?: number;
30
41
  now: number;
31
42
  killswitchPasses?: boolean;
43
+ /** Defaults to `defaultStickyStatusClass`. */
44
+ classifyStatus?: StickyStatusClassifier;
32
45
  }): StickyBreakDecision;
33
46
  export declare function sustainableWindowWeight(window: {
34
47
  remainingPercent: number;