@linxiraos/pi-ai 1.2.0 → 1.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. package/src/utils.ts +38 -23
@@ -3,6 +3,7 @@ import type { AuthCredential, OAuthCredential } from "./types";
3
3
  import type { CredentialPool } from "./pool";
4
4
  import type { Provider } from "../types";
5
5
  import type { UsageCredential, UsageProvider, UsageReport } from "../usage";
6
+ import { isRecord } from "../utils";
6
7
 
7
8
  const USAGE_CACHE_PREFIX = "usage_cache:";
8
9
  const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
@@ -53,6 +54,17 @@ function withoutRawPayload(value: unknown): unknown {
53
54
  return rest;
54
55
  }
55
56
 
57
+ /**
58
+ * End of a cached value's durable retention. A report is retained for 24h past
59
+ * its last successful fetch, so re-storing it as a failure fallback never
60
+ * extends that window; other values are retained for 24h from now.
61
+ */
62
+ function retentionDeadline(value: unknown): number {
63
+ const fetchedAt = isRecord(value) ? value.fetchedAt : undefined;
64
+ const start = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : Date.now();
65
+ return start + USAGE_LAST_GOOD_RETENTION_MS;
66
+ }
67
+
56
68
  /** Convert a stored credential for usage providers; used by usage and health services. */
57
69
  export function buildUsageCredential(credential: AuthCredential): UsageCredential {
58
70
  if (credential.type === "api_key") {
@@ -203,15 +215,19 @@ export class UsageCache {
203
215
  return parseUsageCacheEntry<T>(raw);
204
216
  }
205
217
 
218
+ /** Last successful report under `key` while inside its retention window; the failure-path fallback. */
219
+ lastGood(key: string): UsageReport | null {
220
+ const report = this.getStale<UsageReport | null>(key)?.value;
221
+ return report && Date.now() < retentionDeadline(report) ? report : null;
222
+ }
223
+
206
224
  set<T>(key: string, entry: UsageCacheEntry<T>): void {
207
225
  const value = withoutRawPayload(entry.value);
208
- const payload = JSON.stringify({
209
- value,
210
- expiresAt: entry.expiresAt,
211
- });
212
- const durableExpiresAt =
213
- value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
214
- this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
226
+ const deadline = value === null ? undefined : retentionDeadline(value);
227
+ // Failure cooldowns must not make a last-good report fresh beyond retention.
228
+ const expiresAt = deadline === undefined ? entry.expiresAt : Math.min(entry.expiresAt, deadline);
229
+ const payload = JSON.stringify({ value, expiresAt });
230
+ this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor((deadline ?? expiresAt) / 1000));
215
231
  }
216
232
 
217
233
  deletePrefix(prefix: string): boolean {
package/src/auth/usage.ts CHANGED
@@ -334,7 +334,7 @@ export class UsageService implements UsageApi {
334
334
 
335
335
  const failure = this.#deps.cache.get<null>(failureKey);
336
336
  if (failure && failure.expiresAt > now) {
337
- return this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
337
+ return this.#deps.cache.lastGood(cacheKey);
338
338
  }
339
339
 
340
340
  // Invalidation must not fork an already-running credential probe. Share it;
@@ -378,9 +378,7 @@ export class UsageService implements UsageApi {
378
378
  // so an expired login does not display stale quota indefinitely.
379
379
  const providerImpl = this.providerFor(request.provider);
380
380
  const retainLastGood = !invalidated && providerImpl?.retainLastGoodOnFailure !== false;
381
- const lastGood = retainLastGood
382
- ? (this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
383
- : null;
381
+ const lastGood = retainLastGood ? this.#deps.cache.lastGood(cacheKey) : null;
384
382
  const failureBackoffMs = providerImpl?.failureBackoffMs ?? USAGE_FAILURE_BACKOFF_MS;
385
383
  const backoffJitter = failureBackoffMs * (Math.random() * 0.5 - 0.25);
386
384
  const coolDown = Date.now() + failureBackoffMs + backoffJitter;
@@ -3,9 +3,9 @@
3
3
  * snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
4
4
  * `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
5
5
  *
6
- * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
7
- * usage reports cache TTL is 5 minutes per credential, so durability across
8
- * runs isn't required.
6
+ * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral.
7
+ * The bounded hot set preserves recently used session pins without keeping
8
+ * every gateway client's 30-day sticky row resident.
9
9
  */
10
10
  import * as os from "node:os";
11
11
  import { getAppName, getInstallId, logger, postmortem } from "@linxiraos/pi-utils";
@@ -36,6 +36,11 @@ import type {
36
36
  SnapshotStreamEvent,
37
37
  } from "./types";
38
38
 
39
+ // Bound both the number of distinct session pins and the size of caller-supplied
40
+ // keys/values; an evicted session falls back to normal credential selection.
41
+ const REMOTE_CACHE_MAX_ENTRIES = 4096;
42
+ const REMOTE_CACHE_MAX_CHARACTERS = 4 * 1024 * 1024;
43
+
39
44
  /**
40
45
  * Per-provider OAuth identities visible to this trusted broker client.
41
46
  * Missing providers are unrestricted; an empty set excludes that provider's
@@ -289,6 +294,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
289
294
  /** Present while the background loop is parked; resolved by `#noteActivity` or `close()`. */
290
295
  #activityWakeup: PromiseWithResolvers<void> | null = null;
291
296
  #cache: Map<string, CacheEntry> = new Map();
297
+ #cacheCharacters = 0;
292
298
  #usageCache?: UsageCacheEntry;
293
299
  #usageInflight?: Promise<UsageReport[] | null>;
294
300
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
@@ -1177,28 +1183,46 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1177
1183
  const entry = this.#cache.get(key);
1178
1184
  if (!entry) return null;
1179
1185
  if (entry.expiresAtSec * 1000 <= Date.now()) {
1180
- this.#cache.delete(key);
1186
+ this.#deleteCache(key);
1181
1187
  return null;
1182
1188
  }
1189
+ this.#cache.delete(key);
1190
+ this.#cache.set(key, entry);
1183
1191
  return entry.value;
1184
1192
  }
1185
1193
 
1186
1194
  setCache(key: string, value: string, expiresAtSec: number): void {
1187
1195
  this.#noteActivity();
1196
+ this.#deleteCache(key);
1197
+ const size = key.length + value.length;
1198
+ if (size > REMOTE_CACHE_MAX_CHARACTERS) return;
1188
1199
  this.#cache.set(key, { value, expiresAtSec });
1200
+ this.#cacheCharacters += size;
1201
+ while (this.#cache.size > REMOTE_CACHE_MAX_ENTRIES || this.#cacheCharacters > REMOTE_CACHE_MAX_CHARACTERS) {
1202
+ const oldest = this.#cache.keys().next().value;
1203
+ if (oldest === undefined) break;
1204
+ this.#deleteCache(oldest);
1205
+ }
1206
+ }
1207
+
1208
+ #deleteCache(key: string): void {
1209
+ const entry = this.#cache.get(key);
1210
+ if (!entry) return;
1211
+ this.#cacheCharacters -= key.length + entry.value.length;
1212
+ this.#cache.delete(key);
1189
1213
  }
1190
1214
 
1191
1215
  /** Drop all cache rows whose keys start with the supplied prefix. */
1192
1216
  deleteCachePrefix(prefix: string): void {
1193
1217
  for (const key of this.#cache.keys()) {
1194
- if (key.startsWith(prefix)) this.#cache.delete(key);
1218
+ if (key.startsWith(prefix)) this.#deleteCache(key);
1195
1219
  }
1196
1220
  }
1197
1221
 
1198
1222
  cleanExpiredCache(): void {
1199
1223
  const nowSec = Math.floor(Date.now() / 1000);
1200
1224
  for (const [key, entry] of this.#cache) {
1201
- if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
1225
+ if (entry.expiresAtSec <= nowSec) this.#deleteCache(key);
1202
1226
  }
1203
1227
  }
1204
1228
 
@@ -1572,6 +1596,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1572
1596
  // process exiting right after close() still sends it; failures are dropped.
1573
1597
  void this.#flushObservedUsage().finally(() => this.#cancelObservedUsageExitFlush?.());
1574
1598
  this.#cache.clear();
1599
+ this.#cacheCharacters = 0;
1575
1600
  this.#usageOverlays.clear();
1576
1601
  }
1577
1602
  }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Scoped auth-gateway clients.
3
+ *
4
+ * A holder of the gateway token may call every served model, and recovery may
5
+ * serve it from any broker credential. A scoped client authenticates with its
6
+ * own bearer, configured only as a SHA-256 digest, and is limited to:
7
+ *
8
+ * - `models`: the served models it may call, as exact `provider/id` grants or
9
+ * `provider/*`. Absent allows every served model; `[]` allows none.
10
+ * - `accounts`: per provider, the broker OAuth accounts (identity keys, as in
11
+ * account pools) its requests may use. A listed provider never falls back to
12
+ * a sibling account or an API key; an empty list allows no account. When
13
+ * present, it is also a provider allow-list: a provider it omits is refused,
14
+ * so a binding can never be bypassed through another provider's accounts.
15
+ *
16
+ * Account binding reuses `AuthStorage.sessions.restrict`. Each admitted
17
+ * version of a client (its token, models and accounts) resolves a bound
18
+ * provider's credentials under a session id of its own, restricted before the
19
+ * token is admitted. A reload that changes any of them admits a new version
20
+ * under a new session; requests the old version admitted keep resolving under
21
+ * the old restriction until they finish, and only then is it lifted. A
22
+ * version's requests share that session's account pin.
23
+ */
24
+ import { isRecord } from "@linxiraos/pi-utils";
25
+ import type { SessionRestrictionLease, SessionsApi } from "../auth/types";
26
+ import type { Api, Model } from "../types";
27
+
28
+ /** One scoped client. */
29
+ export interface AuthGatewayClient {
30
+ /** Operator-chosen name, logged with the client's requests. */
31
+ name: string;
32
+ /** Hex SHA-256 digest of the client's bearer token. */
33
+ tokenSha256: string;
34
+ /** `provider/id` or `provider/*` grants; absent allows every served model, `[]` none. */
35
+ models?: readonly string[];
36
+ /** Provider id → OAuth identity keys the client's requests to that provider may use. */
37
+ accounts?: Readonly<Record<string, readonly string[]>>;
38
+ }
39
+
40
+ /** An admitted scoped client, as the gateway's routes see it. */
41
+ export interface AuthGatewayClientScope {
42
+ readonly name: string;
43
+ /** False when the client may call every served model. */
44
+ readonly limitsModels: boolean;
45
+ /** Whether the client may call `model`. */
46
+ allows(model: Model<Api>): boolean;
47
+ /**
48
+ * Session id the client's credential lookups for `provider` resolve under:
49
+ * its own restricted session when `accounts` lists the provider, otherwise
50
+ * the request's `sessionId`. Throws for a provider `accounts` omits, and for
51
+ * a bound provider once the restriction is lifted, so a lookup can never
52
+ * resolve unrestricted on the client's behalf.
53
+ */
54
+ credentialSessionId(provider: string, sessionId: string): string;
55
+ /**
56
+ * Hold the client's account restriction while a request uses it; call the
57
+ * returned release once the request can no longer resolve a credential. A
58
+ * reload that retires the client lifts the restriction only after every
59
+ * hold is released. Take the first hold in the same tick as
60
+ * `authenticate`, before a reload can retire the client.
61
+ */
62
+ retain(): () => void;
63
+ }
64
+
65
+ const CLIENT_FIELDS: Record<string, true> = { tokenSha256: true, models: true, accounts: true };
66
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
67
+ // C0 controls and DEL: a name is logged verbatim.
68
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
69
+
70
+ function invalid(where: string, problem: string): Error {
71
+ return new Error(`Invalid auth-gateway clients: ${where}: ${problem}`);
72
+ }
73
+
74
+ /**
75
+ * Parse a clients file: a JSON object mapping each client name to
76
+ * `{ tokenSha256, models?, accounts? }`. Any malformed entry or unknown field
77
+ * rejects the whole file, since a misspelled `models` would otherwise allow
78
+ * every model. Errors name the offending field, never its value.
79
+ */
80
+ export function parseAuthGatewayClients(text: string): AuthGatewayClient[] {
81
+ let value: unknown;
82
+ try {
83
+ value = JSON.parse(text);
84
+ } catch {
85
+ throw new Error("Invalid auth-gateway clients: not valid JSON");
86
+ }
87
+ if (!isRecord(value)) {
88
+ throw new Error("Invalid auth-gateway clients: expected an object mapping each client name to its entry");
89
+ }
90
+ const clients: AuthGatewayClient[] = [];
91
+ for (const [name, entry] of Object.entries(value)) {
92
+ if (!isRecord(entry)) throw invalid(name, "expected an object");
93
+ for (const field of Object.keys(entry)) {
94
+ if (!Object.hasOwn(CLIENT_FIELDS, field)) throw invalid(name, `unknown field "${field}"`);
95
+ }
96
+ const { tokenSha256, models, accounts } = entry;
97
+ if (typeof tokenSha256 !== "string") throw invalid(`${name}.tokenSha256`, "expected a hex SHA-256 digest");
98
+ const client: AuthGatewayClient = { name, tokenSha256 };
99
+ if (models !== undefined) {
100
+ if (!Array.isArray(models) || !models.every((grant): grant is string => typeof grant === "string")) {
101
+ throw invalid(`${name}.models`, 'expected a list of "provider/id" or "provider/*" grants');
102
+ }
103
+ client.models = models;
104
+ }
105
+ if (accounts !== undefined) {
106
+ if (!isRecord(accounts))
107
+ throw invalid(`${name}.accounts`, "expected an object mapping provider to identity keys");
108
+ // Null prototype: a provider named `__proto__` stays an own entry.
109
+ const pools: Record<string, readonly string[]> = Object.create(null);
110
+ for (const [provider, keys] of Object.entries(accounts)) {
111
+ if (!Array.isArray(keys) || !keys.every((key): key is string => typeof key === "string")) {
112
+ throw invalid(`${name}.accounts.${provider}`, "expected a list of OAuth identity keys");
113
+ }
114
+ pools[provider] = keys;
115
+ }
116
+ client.accounts = pools;
117
+ }
118
+ clients.push(client);
119
+ }
120
+ // Validate every entry here too, so a bad file fails where it is read.
121
+ compileClients(clients);
122
+ return clients;
123
+ }
124
+
125
+ /** Hex SHA-256 digest of a bearer token, as `tokenSha256` records it. */
126
+ export function hashAuthGatewayToken(token: string): string {
127
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
128
+ }
129
+
130
+ type RestrictionSessions = Pick<SessionsApi, "restrict" | "unrestrict">;
131
+
132
+ /** A validated client: its model grants compiled, its accounts restricted while it is admitted or still serving. */
133
+ class ClientScope implements AuthGatewayClientScope {
134
+ readonly name: string;
135
+ readonly tokenSha256: string;
136
+ readonly limitsModels: boolean;
137
+ /** Equal for the same entry read again, so a reload that leaves it unchanged keeps the scope and its pin. */
138
+ readonly policy: string;
139
+ /** Exact `provider/id` grants. */
140
+ readonly #models = new Set<string>();
141
+ /** Providers granted with `provider/*`. */
142
+ readonly #providers = new Set<string>();
143
+ readonly #accounts = new Map<string, readonly string[]>();
144
+ /** Whether `models` was given; absent allows every served model `accounts` permits. */
145
+ readonly #grantsModels: boolean;
146
+ /** Whether `accounts` was given, which makes it a provider allow-list too. */
147
+ readonly #bindsAccounts: boolean;
148
+ readonly #sessionId = crypto.randomUUID();
149
+ #leases: Array<[provider: string, lease: SessionRestrictionLease]> = [];
150
+ #sessions: RestrictionSessions | undefined;
151
+ #holds = 0;
152
+ #retired = false;
153
+ #released = false;
154
+
155
+ constructor(client: AuthGatewayClient) {
156
+ const { name } = client;
157
+ if (typeof name !== "string" || name.length === 0 || CONTROL_CHARACTER.test(name)) {
158
+ throw new Error("Invalid auth-gateway clients: a client name must be non-empty text");
159
+ }
160
+ if (!SHA256_HEX.test(client.tokenSha256)) throw invalid(`${name}.tokenSha256`, "expected 64 hex digits");
161
+ this.name = name;
162
+ this.tokenSha256 = client.tokenSha256.toLowerCase();
163
+ this.#grantsModels = client.models !== undefined;
164
+ this.#bindsAccounts = client.accounts !== undefined;
165
+ this.limitsModels = this.#grantsModels || this.#bindsAccounts;
166
+ for (const grant of client.models ?? []) {
167
+ const slash = grant.indexOf("/");
168
+ const provider = grant.slice(0, slash);
169
+ const id = grant.slice(slash + 1);
170
+ if (
171
+ slash <= 0 ||
172
+ id.length === 0 ||
173
+ provider.trim() !== provider ||
174
+ id.trim() !== id ||
175
+ (id.includes("*") && id !== "*")
176
+ ) {
177
+ throw invalid(`${name}.models`, 'each grant must be "provider/id" or "provider/*"');
178
+ }
179
+ if (id === "*") this.#providers.add(provider);
180
+ else this.#models.add(grant);
181
+ }
182
+ for (const [provider, keys] of Object.entries(client.accounts ?? {})) {
183
+ // A provider id that matches no request would leave that provider unbound, so it must be exact.
184
+ if (provider.length === 0 || provider.trim() !== provider) {
185
+ throw invalid(`${name}.accounts`, "a provider id must be non-empty with no surrounding whitespace");
186
+ }
187
+ if (!keys.every(key => key.length > 0 && key.trim() === key)) {
188
+ throw invalid(`${name}.accounts`, 'expected identity keys such as "email:you@example.com|org:<org-id>"');
189
+ }
190
+ this.#accounts.set(provider, keys);
191
+ }
192
+ if (this.#bindsAccounts) {
193
+ // A grant `accounts` cannot serve is a typo or a misunderstanding; refusing it at load beats a silent 404.
194
+ const granted = [...this.#providers, ...Array.from(this.#models, grant => grant.slice(0, grant.indexOf("/")))];
195
+ const unbound = granted.find(provider => !this.#accounts.has(provider));
196
+ if (unbound !== undefined) {
197
+ throw invalid(`${name}.models`, `grants provider "${unbound}", which "accounts" does not list`);
198
+ }
199
+ }
200
+ this.policy = JSON.stringify([name, this.tokenSha256, client.models ?? null, client.accounts ?? null]);
201
+ }
202
+
203
+ allows(model: Model<Api>): boolean {
204
+ if (this.#bindsAccounts && !this.#accounts.has(model.provider)) return false;
205
+ if (!this.#grantsModels) return true;
206
+ return this.#providers.has(model.provider) || this.#models.has(`${model.provider}/${model.id}`);
207
+ }
208
+
209
+ credentialSessionId(provider: string, sessionId: string): string {
210
+ if (!this.#bindsAccounts) return sessionId;
211
+ if (!this.#accounts.has(provider)) {
212
+ throw new Error(`auth-gateway client ${this.name} is not bound to provider ${provider}`);
213
+ }
214
+ if (this.#released) throw new Error(`auth-gateway client ${this.name} was retired`);
215
+ return this.#sessionId;
216
+ }
217
+
218
+ retain(): () => void {
219
+ this.#holds++;
220
+ let held = true;
221
+ return () => {
222
+ if (!held) return;
223
+ held = false;
224
+ this.#holds--;
225
+ this.#releaseIfDone();
226
+ };
227
+ }
228
+
229
+ /** Restrict this scope's session to its accounts. */
230
+ restrict(sessions: RestrictionSessions): void {
231
+ this.#sessions = sessions;
232
+ for (const [provider, keys] of this.#accounts) {
233
+ this.#leases.push([provider, sessions.restrict(provider, this.#sessionId, keys)]);
234
+ }
235
+ }
236
+
237
+ /** No longer admitted: lift the restriction once the last request holding it is done. */
238
+ retire(): void {
239
+ this.#retired = true;
240
+ this.#releaseIfDone();
241
+ }
242
+
243
+ #releaseIfDone(): void {
244
+ if (!this.#retired || this.#holds > 0) return;
245
+ this.#released = true;
246
+ for (const [provider, lease] of this.#leases) this.#sessions?.unrestrict(provider, this.#sessionId, lease);
247
+ this.#leases = [];
248
+ }
249
+ }
250
+
251
+ /** Validate `clients` as one table and compile it, keyed by token digest. */
252
+ function compileClients(clients: readonly AuthGatewayClient[]): Map<string, ClientScope> {
253
+ const compiled = new Map<string, ClientScope>();
254
+ const names = new Set<string>();
255
+ for (const client of clients) {
256
+ const scope = new ClientScope(client);
257
+ if (names.has(scope.name)) throw invalid(scope.name, "duplicate client name");
258
+ if (compiled.has(scope.tokenSha256)) throw invalid(scope.name, "another client has the same tokenSha256");
259
+ names.add(scope.name);
260
+ compiled.set(scope.tokenSha256, scope);
261
+ }
262
+ return compiled;
263
+ }
264
+
265
+ /**
266
+ * The scoped clients one gateway admits, looked up by the digest of the
267
+ * presented bearer.
268
+ */
269
+ export class AuthGatewayClientTable {
270
+ readonly #sessions: RestrictionSessions;
271
+ /** Digests of the gateway's own tokens, which no client may claim. */
272
+ readonly #reserved: ReadonlySet<string>;
273
+ #byDigest = new Map<string, ClientScope>();
274
+ #closed = false;
275
+
276
+ constructor(sessions: RestrictionSessions, gatewayTokens: Iterable<string>) {
277
+ this.#sessions = sessions;
278
+ this.#reserved = new Set(Array.from(gatewayTokens, hashAuthGatewayToken));
279
+ }
280
+
281
+ /**
282
+ * Replace the admitted clients. An invalid table throws and changes
283
+ * nothing. An unchanged client keeps its scope; a new or changed one is
284
+ * restricted before its token is admitted; the scopes left out are retired.
285
+ * Ignored once the table is closed.
286
+ */
287
+ set(clients: readonly AuthGatewayClient[]): void {
288
+ if (this.#closed) return;
289
+ const next = compileClients(clients);
290
+ for (const scope of next.values()) {
291
+ if (this.#reserved.has(scope.tokenSha256)) {
292
+ throw invalid(scope.name, "tokenSha256 is the gateway token's digest; give the client its own token");
293
+ }
294
+ }
295
+ for (const [digest, scope] of next) {
296
+ const current = this.#byDigest.get(digest);
297
+ if (current?.policy === scope.policy) next.set(digest, current);
298
+ else scope.restrict(this.#sessions);
299
+ }
300
+ const previous = this.#byDigest;
301
+ this.#byDigest = next;
302
+ for (const [digest, scope] of previous) {
303
+ if (next.get(digest) !== scope) scope.retire();
304
+ }
305
+ }
306
+
307
+ /** The client `token` authenticates, if any. */
308
+ authenticate(token: string): AuthGatewayClientScope | undefined {
309
+ return this.#byDigest.get(hashAuthGatewayToken(token));
310
+ }
311
+
312
+ /** Admit no more clients and retire every scope. */
313
+ close(): void {
314
+ this.#closed = true;
315
+ for (const scope of this.#byDigest.values()) scope.retire();
316
+ this.#byDigest = new Map();
317
+ }
318
+ }
@@ -16,6 +16,7 @@ import { isUsageLimitOutcome } from "../error/rate-limit";
16
16
  import type { Api, FetchImpl, Model, Usage } from "../types";
17
17
  import type { ClientUsageIdentity } from "../usage";
18
18
  import { extractProviderRetryHint } from "../utils/retry-after";
19
+ import type { AuthGatewayClientScope } from "./clients";
19
20
  import type { AuthGatewayServerOptions } from "./types";
20
21
 
21
22
  export type ModelResolver = (modelId: string) => Model<Api> | undefined;
@@ -36,8 +37,17 @@ export interface AuthGatewayRouteOptions {
36
37
  excludeProviders?: ReadonlySet<string>;
37
38
  /** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
38
39
  fetch?: FetchImpl;
40
+ /**
41
+ * The scoped client that sent this request, set per request by the HTTP
42
+ * server: it narrows `resolveModel` / `listModels`, and every credential
43
+ * lookup below resolves under its {@link AuthGatewayClientScope.credentialSessionId}.
44
+ */
45
+ client?: AuthGatewayClientScope;
39
46
  }
40
47
 
48
+ /** The route options a request's credential lookups read. */
49
+ export type AuthGatewayCredentialOptions = Pick<AuthGatewayRouteOptions, "storage" | "client">;
50
+
41
51
  /** The HTTP server's options: the routes' plus its listener and inbound auth. */
42
52
  export interface AuthGatewayBootOptions extends AuthGatewayServerOptions, AuthGatewayRouteOptions {}
43
53
 
@@ -56,28 +66,28 @@ export function normalizeClientSessionKey(clientKey: string | undefined): string
56
66
  *
57
67
  * `markUsageLimitReached` and the auth-retry resolver switch a session to a
58
68
  * sibling credential, so the provider state retained for that session can
59
- * outlive the account that taught it. OAuth rows expose an account id / email
60
- * that survives token refresh — fingerprinting the bearer instead would look
69
+ * outlive the account that taught it. A stored credential keeps its durable
70
+ * row id across token refreshes — fingerprinting the bearer instead would look
61
71
  * like a rotation every time a token refreshes and discard the retained
62
- * lessons for nothing. Key-based rows fall back to a hash of the key, never
63
- * the key itself: this value is held for the lifetime of the entry.
72
+ * lessons for nothing. The id comes from the selection itself, not the
73
+ * session's current pin, which a concurrent request on the same session may
74
+ * have moved. Unstored keys fall back to a hash of the key, never the key
75
+ * itself: this value is held for the lifetime of the entry.
64
76
  */
65
- export function resolveGatewayAccount(
66
- storage: AuthStorage,
67
- provider: string,
68
- sessionId: string,
69
- apiKey: string,
70
- ): string {
71
- const identity = storage.oauth.identity(provider, sessionId);
72
- if (identity) {
73
- return `oauth:${JSON.stringify([
74
- identity.accountId ?? "",
75
- identity.email ?? "",
76
- identity.projectId ?? "",
77
- identity.orgId ?? "",
78
- ])}`;
79
- }
80
- return `key:${Bun.hash(apiKey).toString(36)}`;
77
+ export function resolveGatewayAccount(resolved: ResolvedApiKey): string {
78
+ if (resolved.credentialId !== undefined) return `credential:${resolved.credentialId}`;
79
+ return `key:${Bun.hash(resolved.apiKey).toString(36)}`;
80
+ }
81
+
82
+ /** Who sent a request and which stored credential it started on, for the `auth-gateway request` log line. */
83
+ export function gatewayRequestLogFields(
84
+ opts: AuthGatewayCredentialOptions,
85
+ resolved: ResolvedApiKey,
86
+ ): { client?: string; credentialId?: number } {
87
+ const fields: { client?: string; credentialId?: number } = {};
88
+ if (opts.client) fields.client = opts.client.name;
89
+ if (resolved.credentialId !== undefined) fields.credentialId = resolved.credentialId;
90
+ return fields;
81
91
  }
82
92
 
83
93
  /**
@@ -90,7 +100,7 @@ export function resolveGatewayAccount(
90
100
  * {@link classifyGatewayError}; a provider without any credential is a 401.
91
101
  */
92
102
  export async function resolveGatewayApiKey(
93
- storage: AuthStorage,
103
+ opts: AuthGatewayCredentialOptions,
94
104
  model: Model<Api>,
95
105
  sessionId: string,
96
106
  signal: AbortSignal,
@@ -98,7 +108,11 @@ export async function resolveGatewayApiKey(
98
108
  ): Promise<ResolvedApiKey | GatewayErrorClassification> {
99
109
  let apiKey: ResolvedApiKey | undefined;
100
110
  try {
101
- apiKey = await storage.keys.getWithCredential(model.provider, sessionId, modelKeyOptions(model, signal));
111
+ apiKey = await opts.storage.keys.getWithCredential(
112
+ model.provider,
113
+ gatewayCredentialSessionId(opts, model, sessionId),
114
+ modelKeyOptions(model, signal),
115
+ );
102
116
  } catch (error) {
103
117
  const classified = classifyGatewayError(error);
104
118
  logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
@@ -197,20 +211,23 @@ function modelKeyOptions(model: Model<Api>, signal: AbortSignal): AuthApiKeyOpti
197
211
  * `lastKey` tracks the most recent bearer so the switch step invalidates the
198
212
  * credential that actually failed; a retry context's `previousKey` wins, since
199
213
  * concurrent operations sharing one resolver (split judgment chunks) each know
200
- * the bearer they sent. `onResolvedKey` observes every rotation; routes that
201
- * retain provider session state use it to re-key the account lease, one-shot
202
- * routes pass `undefined`.
214
+ * the bearer they sent. Every step resolves under the scoped client's
215
+ * credential session when `opts.client` binds the provider, so refresh,
216
+ * blocking and sibling rotation stay inside its accounts. `onResolvedKey`
217
+ * observes every rotation; routes that retain provider session state use it
218
+ * to re-key the account lease, one-shot routes pass `undefined`.
203
219
  */
204
220
  export function buildGatewayApiKeyResolver(
205
- storage: AuthStorage,
221
+ opts: AuthGatewayCredentialOptions,
206
222
  model: Model<Api>,
207
223
  sessionId: string,
208
224
  initialKey: ResolvedApiKey,
209
225
  requestSignal: AbortSignal,
210
226
  format: string,
211
227
  peer: string,
212
- onResolvedKey?: (apiKey: string) => void,
228
+ onResolvedKey?: (resolved: ResolvedApiKey) => void,
213
229
  ): ApiKeyResolver {
230
+ const { storage } = opts;
214
231
  let lastKey = initialKey.apiKey;
215
232
  return async ({ lastChance, error, signal, previousKey }) => {
216
233
  const sig = signal ?? requestSignal;
@@ -219,19 +236,23 @@ export function buildGatewayApiKeyResolver(
219
236
  return initialKey;
220
237
  }
221
238
  if (!lastChance) {
222
- const refreshed = await storage.keys.getWithCredential(model.provider, sessionId, {
223
- ...modelKeyOptions(model, sig),
224
- forceRefresh: true,
225
- refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
226
- });
239
+ const refreshed = await storage.keys.getWithCredential(
240
+ model.provider,
241
+ gatewayCredentialSessionId(opts, model, sessionId),
242
+ {
243
+ ...modelKeyOptions(model, sig),
244
+ forceRefresh: true,
245
+ refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
246
+ },
247
+ );
227
248
  lastKey = refreshed?.apiKey ?? lastKey;
228
- if (refreshed) onResolvedKey?.(refreshed.apiKey);
249
+ if (refreshed) onResolvedKey?.(refreshed);
229
250
  return refreshed;
230
251
  }
231
252
  const next = await refreshGatewayApiKeyAfterAuthError(
232
253
  storage,
233
254
  model,
234
- sessionId,
255
+ gatewayCredentialSessionId(opts, model, sessionId),
235
256
  model.provider,
236
257
  previousKey ?? lastKey,
237
258
  error,
@@ -240,11 +261,21 @@ export function buildGatewayApiKeyResolver(
240
261
  peer,
241
262
  );
242
263
  lastKey = next?.apiKey ?? lastKey;
243
- if (next) onResolvedKey?.(next.apiKey);
264
+ if (next) onResolvedKey?.(next);
244
265
  return next;
245
266
  };
246
267
  }
247
268
 
269
+ /**
270
+ * The session a request's credential lookup resolves under: a scoped client's
271
+ * own restricted session for a provider it binds. Asked on every lookup, not
272
+ * once per request, so a retry after the client's restriction is lifted fails
273
+ * instead of resolving unrestricted.
274
+ */
275
+ function gatewayCredentialSessionId(opts: AuthGatewayCredentialOptions, model: Model<Api>, sessionId: string): string {
276
+ return opts.client?.credentialSessionId(model.provider, sessionId) ?? sessionId;
277
+ }
278
+
248
279
  /**
249
280
  * Attribute one settled upstream request to the originating client via the
250
281
  * broker's observed-usage channel (`AuthStorage.usage.observe`, batched