@linxiraos/pi-ai 1.2.1 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +7 -7
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
package/src/auth/usage-cache.ts
CHANGED
|
@@ -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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
|
|
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.
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.
|
|
60
|
-
*
|
|
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.
|
|
63
|
-
*
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
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?: (
|
|
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(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
|
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
|