@oh-my-pi/pi-ai 18.2.1 → 18.2.3
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 +25 -0
- package/README.md +2 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
- package/dist/types/auth-gateway/session-state.d.ts +69 -16
- package/dist/types/auth-storage.d.ts +11 -6
- package/dist/types/index.d.ts +1 -0
- package/dist/types/provider-session-state.d.ts +46 -0
- package/dist/types/providers/amazon-bedrock.d.ts +3 -0
- package/dist/types/providers/aws-sigv4.d.ts +12 -0
- package/dist/types/providers/openai-responses.d.ts +15 -0
- package/dist/types/registry/oauth/github-copilot.d.ts +2 -6
- package/dist/types/registry/oauth/kimi.d.ts +2 -1
- package/dist/types/registry/oauth/types.d.ts +2 -0
- package/dist/types/usage/openai-codex.d.ts +3 -1
- package/dist/types/usage.d.ts +3 -1
- package/package.json +6 -6
- package/src/auth/sqlite-credential-store.ts +8 -33
- package/src/auth-gateway/server.ts +159 -84
- package/src/auth-gateway/session-state.ts +227 -29
- package/src/auth-storage.ts +19 -9
- package/src/index.ts +1 -0
- package/src/provider-session-state.ts +56 -0
- package/src/providers/amazon-bedrock.ts +20 -3
- package/src/providers/anthropic-messages-server.ts +80 -20
- package/src/providers/anthropic-signature.ts +5 -2
- package/src/providers/aws-sigv4.ts +16 -5
- package/src/providers/cursor.ts +53 -9
- package/src/providers/openai-completions.ts +6 -0
- package/src/providers/openai-responses.ts +27 -0
- package/src/registry/oauth/github-copilot.ts +2 -2
- package/src/registry/oauth/kimi.ts +4 -4
- package/src/registry/oauth/types.ts +2 -0
- package/src/stream.ts +36 -1
- package/src/usage/openai-codex.ts +94 -11
- package/src/usage.ts +5 -1
- package/src/utils/http-inspector.ts +20 -0
|
@@ -131,15 +131,37 @@ function deriveSessionId(modelId: string, context: Context): string {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* provider-session bucket.
|
|
134
|
+
* The client's own session key, or `undefined` when it sent none. A blank key
|
|
135
|
+
* counts as none: honouring it would collapse every caller that sends an empty
|
|
136
|
+
* key into one shared credential-sticky, prefix-cache and provider-session
|
|
137
|
+
* bucket.
|
|
139
138
|
*/
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
function normalizeClientSessionKey(clientKey: string | undefined): string | undefined {
|
|
140
|
+
return clientKey !== undefined && clientKey.trim().length > 0 ? clientKey : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stable identity of the account a request's credential belongs to.
|
|
145
|
+
*
|
|
146
|
+
* `markUsageLimitReached` and the auth-retry resolver switch a session to a
|
|
147
|
+
* sibling credential, so the provider state retained for that session can
|
|
148
|
+
* outlive the account that taught it. OAuth rows expose an account id / email
|
|
149
|
+
* that survives token refresh — fingerprinting the bearer instead would look
|
|
150
|
+
* like a rotation every time a token refreshes and discard the retained
|
|
151
|
+
* lessons for nothing. Key-based rows fall back to a hash of the key, never
|
|
152
|
+
* the key itself: this value is held for the lifetime of the entry.
|
|
153
|
+
*/
|
|
154
|
+
function resolveGatewayAccount(storage: AuthStorage, provider: string, sessionId: string, apiKey: string): string {
|
|
155
|
+
const identity = storage.getOAuthAccountIdentity(provider, sessionId);
|
|
156
|
+
if (identity) {
|
|
157
|
+
return `oauth:${JSON.stringify([
|
|
158
|
+
identity.accountId ?? "",
|
|
159
|
+
identity.email ?? "",
|
|
160
|
+
identity.projectId ?? "",
|
|
161
|
+
identity.orgId ?? "",
|
|
162
|
+
])}`;
|
|
163
|
+
}
|
|
164
|
+
return `key:${Bun.hash(apiKey).toString(36)}`;
|
|
143
165
|
}
|
|
144
166
|
|
|
145
167
|
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
|
|
@@ -182,7 +204,8 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
|
|
|
182
204
|
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
|
|
183
205
|
// key from the model + system + tools so prefix caching engages on
|
|
184
206
|
// Codex-class backends across turns of the same logical conversation.
|
|
185
|
-
const promptCacheKey =
|
|
207
|
+
const promptCacheKey =
|
|
208
|
+
normalizeClientSessionKey(options.promptCacheKey) ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
186
209
|
opts.promptCacheKey = promptCacheKey;
|
|
187
210
|
opts.sessionId = promptCacheKey;
|
|
188
211
|
if (options.thinkingBudgets) {
|
|
@@ -314,6 +337,7 @@ function buildGatewayApiKeyResolver(
|
|
|
314
337
|
requestSignal: AbortSignal,
|
|
315
338
|
format: string,
|
|
316
339
|
peer: string,
|
|
340
|
+
onResolvedKey: (apiKey: string) => void,
|
|
317
341
|
): ApiKeyResolver {
|
|
318
342
|
let lastKey = initialKey;
|
|
319
343
|
return async ({ lastChance, error, signal }) => {
|
|
@@ -329,6 +353,7 @@ function buildGatewayApiKeyResolver(
|
|
|
329
353
|
forceRefresh: true,
|
|
330
354
|
});
|
|
331
355
|
lastKey = refreshed ?? lastKey;
|
|
356
|
+
if (refreshed) onResolvedKey(refreshed);
|
|
332
357
|
return refreshed;
|
|
333
358
|
}
|
|
334
359
|
const next = await refreshGatewayApiKeyAfterAuthError(
|
|
@@ -343,6 +368,7 @@ function buildGatewayApiKeyResolver(
|
|
|
343
368
|
peer,
|
|
344
369
|
);
|
|
345
370
|
lastKey = next ?? lastKey;
|
|
371
|
+
if (next) onResolvedKey(next);
|
|
346
372
|
return next;
|
|
347
373
|
};
|
|
348
374
|
}
|
|
@@ -476,7 +502,8 @@ async function handleFormatEndpoint(
|
|
|
476
502
|
// supplied (so external session ids align), otherwise derive from
|
|
477
503
|
// modelId + system + tools + first message. Mirrored into
|
|
478
504
|
// streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
|
|
479
|
-
const
|
|
505
|
+
const clientKey = normalizeClientSessionKey(parsed.options.promptCacheKey);
|
|
506
|
+
const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
480
507
|
parsed.options.promptCacheKey = sessionId;
|
|
481
508
|
|
|
482
509
|
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
|
|
@@ -505,6 +532,19 @@ async function handleFormatEndpoint(
|
|
|
505
532
|
}
|
|
506
533
|
|
|
507
534
|
const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
|
|
535
|
+
// Per-session provider learning (sticky strict-tools / fast-mode / thinking
|
|
536
|
+
// fallbacks, Codex transport sessions). Owned by this gateway instance: the
|
|
537
|
+
// map is non-serializable, so no client can supply it and every turn would
|
|
538
|
+
// otherwise re-learn each lesson from a fresh upstream rejection. The lease
|
|
539
|
+
// keeps the entry out of reach of eviction until this request is done with
|
|
540
|
+
// it, so it MUST be released on every exit path.
|
|
541
|
+
const lease = sessionStates.acquire({
|
|
542
|
+
clientKey,
|
|
543
|
+
model,
|
|
544
|
+
context: parsed.context,
|
|
545
|
+
account: resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, apiKey),
|
|
546
|
+
});
|
|
547
|
+
streamOpts.providerSessionState = lease.states;
|
|
508
548
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
509
549
|
bootOpts.storage,
|
|
510
550
|
model,
|
|
@@ -513,12 +553,9 @@ async function handleFormatEndpoint(
|
|
|
513
553
|
controller.signal,
|
|
514
554
|
route.label,
|
|
515
555
|
peer,
|
|
556
|
+
resolvedKey =>
|
|
557
|
+
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
516
558
|
);
|
|
517
|
-
// Per-session provider learning (sticky strict-tools / fast-mode / thinking
|
|
518
|
-
// fallbacks, Codex transport sessions). Owned by this gateway instance: the
|
|
519
|
-
// map is non-serializable, so no client can supply it and every turn would
|
|
520
|
-
// otherwise re-learn each lesson from a fresh upstream rejection.
|
|
521
|
-
streamOpts.providerSessionState = sessionStates.acquire(sessionId, model);
|
|
522
559
|
|
|
523
560
|
logger.info("auth-gateway request", {
|
|
524
561
|
requestId,
|
|
@@ -565,45 +602,59 @@ async function handleFormatEndpoint(
|
|
|
565
602
|
peer,
|
|
566
603
|
});
|
|
567
604
|
return route.module.formatError(classified.status, classified.type, classified.message);
|
|
605
|
+
} finally {
|
|
606
|
+
// Every non-streaming outcome — answered, upstream error, thrown,
|
|
607
|
+
// client gone — is done with the provider state here.
|
|
608
|
+
lease.release();
|
|
568
609
|
}
|
|
569
610
|
}
|
|
570
611
|
|
|
571
|
-
|
|
612
|
+
// A streamed turn outlives this function, so the lease travels with the
|
|
613
|
+
// event stream and is released when the turn settles. Until that handoff
|
|
614
|
+
// happens, the `finally` below owns it.
|
|
615
|
+
let streamOwnsLease = false;
|
|
572
616
|
try {
|
|
617
|
+
let events: AssistantMessageEventStream;
|
|
618
|
+
try {
|
|
619
|
+
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
620
|
+
events = streamSimple(model, parsed.context, streamOpts);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
const classified = classifyGatewayError(error);
|
|
623
|
+
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
|
|
624
|
+
return route.module.formatError(classified.status, classified.type, classified.message);
|
|
625
|
+
}
|
|
573
626
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
574
|
-
events
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
581
|
-
void events
|
|
582
|
-
.result()
|
|
583
|
-
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
584
|
-
.catch(() => {});
|
|
627
|
+
void events
|
|
628
|
+
.result()
|
|
629
|
+
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
630
|
+
.catch(() => {})
|
|
631
|
+
.finally(() => lease.release());
|
|
632
|
+
streamOwnsLease = true;
|
|
585
633
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
634
|
+
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
|
|
635
|
+
signal: controller.signal,
|
|
636
|
+
onCancel: reason => {
|
|
637
|
+
if (!controller.signal.aborted) {
|
|
638
|
+
controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
return new Response(sseStream, {
|
|
643
|
+
status: 200,
|
|
644
|
+
headers: {
|
|
645
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
646
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
647
|
+
"Cache-Control": "no-cache",
|
|
648
|
+
Connection: "keep-alive",
|
|
649
|
+
// Disable proxy buffering (nginx and ingress controllers honor this).
|
|
650
|
+
// Without it the SSE stream gets held until the buffer flushes, which
|
|
651
|
+
// stalls the long-thinking-budget calls we exist to support.
|
|
652
|
+
"X-Accel-Buffering": "no",
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
} finally {
|
|
656
|
+
if (!streamOwnsLease) lease.release();
|
|
657
|
+
}
|
|
607
658
|
}
|
|
608
659
|
|
|
609
660
|
/**
|
|
@@ -660,7 +711,8 @@ async function handlePiNative(
|
|
|
660
711
|
// up with cache-prefix stickiness — same identity used for both means
|
|
661
712
|
// the next turn of this conversation reuses the same credential until
|
|
662
713
|
// it hits a usage cap, then markUsageLimitReached can hand off.
|
|
663
|
-
const
|
|
714
|
+
const clientKey = normalizeClientSessionKey(parsed.options.sessionId);
|
|
715
|
+
const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
664
716
|
parsed.options.sessionId = sessionId;
|
|
665
717
|
|
|
666
718
|
let apiKey: string | undefined;
|
|
@@ -684,6 +736,17 @@ async function handlePiNative(
|
|
|
684
736
|
);
|
|
685
737
|
}
|
|
686
738
|
|
|
739
|
+
// Per-session provider learning, owned by this gateway instance. The map is
|
|
740
|
+
// non-serializable, so `parseRequest` cannot accept one from the wire and
|
|
741
|
+
// every turn would otherwise re-learn each lesson from a fresh upstream
|
|
742
|
+
// rejection. The lease keeps the entry out of reach of eviction until this
|
|
743
|
+
// request is done with it, so it MUST be released on every exit path.
|
|
744
|
+
const lease = sessionStates.acquire({
|
|
745
|
+
clientKey,
|
|
746
|
+
model,
|
|
747
|
+
context: parsed.context,
|
|
748
|
+
account: resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, apiKey),
|
|
749
|
+
});
|
|
687
750
|
// Build the SimpleStreamOptions actually handed to `streamSimple`. We
|
|
688
751
|
// trust the client's options (already allow-listed by `parseRequest`) and
|
|
689
752
|
// only inject server-controlled fields. The codex sampling strip mirrors
|
|
@@ -693,11 +756,7 @@ async function handlePiNative(
|
|
|
693
756
|
apiKey,
|
|
694
757
|
signal: controller.signal,
|
|
695
758
|
cursorExternalToolExecutor: true,
|
|
696
|
-
|
|
697
|
-
// is non-serializable, so `parseRequest` cannot accept one from the wire
|
|
698
|
-
// and every turn would otherwise re-learn each lesson from a fresh
|
|
699
|
-
// upstream rejection.
|
|
700
|
-
providerSessionState: sessionStates.acquire(sessionId, model),
|
|
759
|
+
providerSessionState: lease.states,
|
|
701
760
|
};
|
|
702
761
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
703
762
|
bootOpts.storage,
|
|
@@ -707,6 +766,8 @@ async function handlePiNative(
|
|
|
707
766
|
controller.signal,
|
|
708
767
|
"pi-native",
|
|
709
768
|
peer,
|
|
769
|
+
resolvedKey =>
|
|
770
|
+
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
710
771
|
);
|
|
711
772
|
if (model.api === "openai-codex-responses") {
|
|
712
773
|
delete streamOpts.temperature;
|
|
@@ -761,42 +822,56 @@ async function handlePiNative(
|
|
|
761
822
|
const classified = classifyGatewayError(error);
|
|
762
823
|
logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
|
|
763
824
|
return piNative.formatError(classified.status, classified.type, classified.message);
|
|
825
|
+
} finally {
|
|
826
|
+
// Every non-streaming outcome — answered, upstream error, thrown,
|
|
827
|
+
// client gone — is done with the provider state here.
|
|
828
|
+
lease.release();
|
|
764
829
|
}
|
|
765
830
|
}
|
|
766
831
|
|
|
767
|
-
|
|
832
|
+
// A streamed turn outlives this function, so the lease travels with the
|
|
833
|
+
// event stream and is released when the turn settles. Until that handoff
|
|
834
|
+
// happens, the `finally` below owns it.
|
|
835
|
+
let streamOwnsLease = false;
|
|
768
836
|
try {
|
|
837
|
+
let events: AssistantMessageEventStream;
|
|
838
|
+
try {
|
|
839
|
+
if (controller.signal.aborted) return aborted();
|
|
840
|
+
events = streamSimple(model, parsed.context, streamOpts);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
const classified = classifyGatewayError(error);
|
|
843
|
+
logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
|
|
844
|
+
return piNative.formatError(classified.status, classified.type, classified.message);
|
|
845
|
+
}
|
|
769
846
|
if (controller.signal.aborted) return aborted();
|
|
770
|
-
events
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (controller.signal.aborted) return aborted();
|
|
777
|
-
void events
|
|
778
|
-
.result()
|
|
779
|
-
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
780
|
-
.catch(() => {});
|
|
847
|
+
void events
|
|
848
|
+
.result()
|
|
849
|
+
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
850
|
+
.catch(() => {})
|
|
851
|
+
.finally(() => lease.release());
|
|
852
|
+
streamOwnsLease = true;
|
|
781
853
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
854
|
+
const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
|
|
855
|
+
signal: controller.signal,
|
|
856
|
+
onCancel: reason => {
|
|
857
|
+
if (!controller.signal.aborted) {
|
|
858
|
+
controller.abort(reason instanceof Error ? reason : new Error("client closed request"));
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
return new Response(sseStream, {
|
|
863
|
+
status: 200,
|
|
864
|
+
headers: {
|
|
865
|
+
...gatewayResponseHeaders(model, { requestId }),
|
|
866
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
867
|
+
"Cache-Control": "no-cache",
|
|
868
|
+
Connection: "keep-alive",
|
|
869
|
+
"X-Accel-Buffering": "no",
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
} finally {
|
|
873
|
+
if (!streamOwnsLease) lease.release();
|
|
874
|
+
}
|
|
800
875
|
}
|
|
801
876
|
|
|
802
877
|
/**
|
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
* A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
|
|
20
20
|
* ever reclaims an entry, and the entries own timers and sockets. This store is
|
|
21
21
|
* an LRU with a hard entry ceiling that calls `close()` on everything it drops
|
|
22
|
-
* and on everything it still holds at shutdown
|
|
22
|
+
* and on everything it still holds at shutdown — but it only ever drops an
|
|
23
|
+
* entry no request is holding, because `close()` on a live entry tears down
|
|
24
|
+
* state an in-flight stream is still streaming through.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
26
|
-
import {
|
|
27
|
-
import type { Api, Model, ProviderSessionState } from "../types";
|
|
28
|
+
import { resetAccountScopedProviderSessionState } from "../provider-session-state";
|
|
29
|
+
import type { Api, Context, Model, ProviderSessionState } from "../types";
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Retained logical sessions. Each entry is a handful of small provider records
|
|
@@ -35,6 +37,61 @@ import type { Api, Model, ProviderSessionState } from "../types";
|
|
|
35
37
|
*/
|
|
36
38
|
export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
|
|
37
39
|
|
|
40
|
+
/** Why an entry's provider records were closed. Logged on teardown failure. */
|
|
41
|
+
type SessionDisposeReason = "evict" | "shutdown";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One request's claim on a retained session.
|
|
45
|
+
*
|
|
46
|
+
* `release()` is what makes the entry evictable again, so it MUST run for every
|
|
47
|
+
* outcome of the request — a `finally` at the call site for the synchronous
|
|
48
|
+
* paths, stream completion for the streaming ones. It is idempotent, so the
|
|
49
|
+
* two can overlap.
|
|
50
|
+
*/
|
|
51
|
+
export interface AuthGatewaySessionStateLease {
|
|
52
|
+
/** The map to hand to `streamSimple` as `providerSessionState`. */
|
|
53
|
+
readonly states: Map<string, ProviderSessionState>;
|
|
54
|
+
/** Reset account-scoped records if an in-request auth retry switches accounts. */
|
|
55
|
+
updateAccount(account: string): void;
|
|
56
|
+
/** Give up this request's claim. Idempotent. */
|
|
57
|
+
release(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything the store needs to place one request on a retained session. */
|
|
61
|
+
export interface AuthGatewaySessionStateRequest {
|
|
62
|
+
/**
|
|
63
|
+
* The client's own session key (`prompt_cache_key` / `sessionId`), or
|
|
64
|
+
* `undefined` when it sent none — blank counts as none. A supplied key is
|
|
65
|
+
* authoritative: the client is telling us which conversation this is.
|
|
66
|
+
*/
|
|
67
|
+
clientKey: string | undefined;
|
|
68
|
+
model: Model<Api>;
|
|
69
|
+
/**
|
|
70
|
+
* System prompt, tools and message history of this request. Used only when
|
|
71
|
+
* `clientKey` is absent, to place the request on the conversation it
|
|
72
|
+
* continues.
|
|
73
|
+
*/
|
|
74
|
+
context: Context;
|
|
75
|
+
/**
|
|
76
|
+
* Stable identity of the account this request's credential resolved to.
|
|
77
|
+
* A change means the gateway switched the session to a sibling credential,
|
|
78
|
+
* so the account-dependent lessons in the retained map are re-probed. The
|
|
79
|
+
* comparison happens on acquire and whenever an in-request auth retry
|
|
80
|
+
* resolves a sibling credential.
|
|
81
|
+
*/
|
|
82
|
+
account: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RetainedSession {
|
|
86
|
+
/** Current index key. Advances as a keyless conversation grows. */
|
|
87
|
+
key: string;
|
|
88
|
+
states: Map<string, ProviderSessionState>;
|
|
89
|
+
/** Account identity of the most recent request placed on this entry. */
|
|
90
|
+
account: string;
|
|
91
|
+
/** Requests currently holding this entry. Eviction never takes one of these. */
|
|
92
|
+
leases: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
38
95
|
/**
|
|
39
96
|
* Close every provider record an evicted (or drained) session held.
|
|
40
97
|
*
|
|
@@ -42,15 +99,18 @@ export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
|
|
|
42
99
|
* and GitLab Duo's stops the server-side workflow — so dropping an entry
|
|
43
100
|
* without closing it leaks exactly the resources the bound exists to cap.
|
|
44
101
|
*/
|
|
45
|
-
function closeSessionState(
|
|
102
|
+
function closeSessionState(
|
|
103
|
+
states: Map<string, ProviderSessionState>,
|
|
104
|
+
sessionKey: string,
|
|
105
|
+
reason: SessionDisposeReason,
|
|
106
|
+
): void {
|
|
46
107
|
for (const [providerKey, state] of states) {
|
|
47
108
|
try {
|
|
48
109
|
state.close();
|
|
49
110
|
} catch (error) {
|
|
50
111
|
// One provider's teardown must not abort the rest: a throw here
|
|
51
|
-
// propagates out of
|
|
52
|
-
// trigger
|
|
53
|
-
// drain.
|
|
112
|
+
// propagates out of the eviction into whichever request happened to
|
|
113
|
+
// trigger it, or abandons the remainder of the shutdown drain.
|
|
54
114
|
logger.warn("auth-gateway provider session state close failed", {
|
|
55
115
|
sessionKey,
|
|
56
116
|
providerKey,
|
|
@@ -62,17 +122,82 @@ function closeSessionState(states: Map<string, ProviderSessionState>, sessionKey
|
|
|
62
122
|
states.clear();
|
|
63
123
|
}
|
|
64
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Index keys this request may be placed on, most specific first.
|
|
127
|
+
*
|
|
128
|
+
* With a client key there is exactly one: the client named its conversation, so
|
|
129
|
+
* provider + model + that key is the identity.
|
|
130
|
+
*
|
|
131
|
+
* Without one the gateway has to infer the conversation, and the request's
|
|
132
|
+
* message history is the only thing that can distinguish two of them. The
|
|
133
|
+
* derived `sessionId` used for prefix caching and credential stickiness hashes
|
|
134
|
+
* the model, system prompt, tools and *first* message, which is deliberately
|
|
135
|
+
* prefix-shaped — two chats that open the same way share a cache bucket, which
|
|
136
|
+
* is a cache hit rather than a leak, and share a sticky account, which is a
|
|
137
|
+
* load-balancing hint. Retained provider state is neither: sharing it means one
|
|
138
|
+
* chat's rejection silences another chat's request, and one chat's Codex
|
|
139
|
+
* transport session answers another chat's turn. So provider state gets its own
|
|
140
|
+
* key, and only provider state: `deriveSessionId` keeps its two other jobs.
|
|
141
|
+
*
|
|
142
|
+
* The key is therefore a running hash over (provider, model, system, tools) and
|
|
143
|
+
* then every message, one key per message — the last of which identifies the
|
|
144
|
+
* exact history this request presented. Turn N+1 of a conversation extends turn
|
|
145
|
+
* N's history, so turn N's key is one of the earlier entries in turn N+1's
|
|
146
|
+
* chain: the store finds the nearest ancestor and moves that entry forward onto
|
|
147
|
+
* the new key. Two conversations that share an opening therefore share an entry
|
|
148
|
+
* only until they diverge; after that the first branch to arrive keeps the
|
|
149
|
+
* ancestor and the other starts clean. That is the most a stateless wire can
|
|
150
|
+
* tell us — before divergence the two requests are byte-identical.
|
|
151
|
+
*
|
|
152
|
+
* System prompt and tools sit in the root rather than per-message because they
|
|
153
|
+
* are not history: a client that re-stamps its system prompt every turn (a
|
|
154
|
+
* date, a cwd) starts a new lineage, exactly as it already starts a new derived
|
|
155
|
+
* `sessionId` today.
|
|
156
|
+
*/
|
|
157
|
+
function sessionKeys(request: AuthGatewaySessionStateRequest): string[] {
|
|
158
|
+
const { model } = request;
|
|
159
|
+
const scope = `${model.provider}\u0000${model.id}`;
|
|
160
|
+
if (request.clientKey !== undefined) return [`c\u0000${scope}\u0000${request.clientKey}`];
|
|
161
|
+
const { context } = request;
|
|
162
|
+
// NUL separates the components so none of them can forge the boundary.
|
|
163
|
+
let hash = Bun.hash(
|
|
164
|
+
`${scope}\u0000${context.systemPrompt?.join("\n\n") ?? ""}\u0000${context.tools ? JSON.stringify(context.tools) : ""}`,
|
|
165
|
+
);
|
|
166
|
+
const keys: string[] = [];
|
|
167
|
+
for (const message of context.messages) {
|
|
168
|
+
// Role + content only: omp re-stamps `timestamp` and provider metadata on
|
|
169
|
+
// every parsed message, so hashing those would break the chain on turn
|
|
170
|
+
// two of every conversation.
|
|
171
|
+
hash = Bun.hash(JSON.stringify({ role: message.role, content: message.content }), hash);
|
|
172
|
+
keys.push(`h\u0000${scope}\u0000${hash.toString(36)}`);
|
|
173
|
+
}
|
|
174
|
+
// A request with no messages has no history to place; its root is the key.
|
|
175
|
+
if (keys.length === 0) return [`h\u0000${scope}\u0000${hash.toString(36)}`];
|
|
176
|
+
keys.reverse();
|
|
177
|
+
return keys;
|
|
178
|
+
}
|
|
179
|
+
|
|
65
180
|
/**
|
|
66
181
|
* Bounded per-session provider state, owned by one gateway server instance.
|
|
67
182
|
*
|
|
68
183
|
* Two gateways in the same process get separate stores, so neither can hand a
|
|
69
184
|
* request another gateway's learned state or close it out from under one.
|
|
185
|
+
*
|
|
186
|
+
* The recency order is this class's own (a `Map` iterates in insertion order,
|
|
187
|
+
* and every acquire re-inserts) rather than `LRUCache`'s, because the policy
|
|
188
|
+
* needs two things a general cache cannot express: an entry that a request is
|
|
189
|
+
* still holding must be skipped when picking a victim, and an entry must be
|
|
190
|
+
* able to change key — `LRUCache` disposes on every removal, which is precisely
|
|
191
|
+
* the `close()` we must not run here.
|
|
70
192
|
*/
|
|
71
193
|
export class AuthGatewaySessionStateStore {
|
|
72
|
-
|
|
194
|
+
/** Least recently acquired first — insertion order is the LRU order. */
|
|
195
|
+
readonly #sessions = new Map<string, RetainedSession>();
|
|
196
|
+
readonly #max: number;
|
|
73
197
|
|
|
74
198
|
constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
|
|
75
|
-
|
|
199
|
+
if (!Number.isInteger(max) || max < 1) throw new TypeError("max must be a positive integer");
|
|
200
|
+
this.#max = max;
|
|
76
201
|
}
|
|
77
202
|
|
|
78
203
|
/** Retained logical sessions. */
|
|
@@ -81,34 +206,107 @@ export class AuthGatewaySessionStateStore {
|
|
|
81
206
|
}
|
|
82
207
|
|
|
83
208
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* next request.
|
|
209
|
+
* Claim the provider-session map for one request, created on first use and
|
|
210
|
+
* returned by reference so provider mutations persist into the next request.
|
|
87
211
|
*
|
|
88
|
-
* Keyed by
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* whose learning is endpoint-specific already sub-keys it internally
|
|
212
|
+
* Keyed by provider + model + conversation (see {@link sessionKeys}). A
|
|
213
|
+
* client is free to reuse one session id across models, and the coarsest
|
|
214
|
+
* provider entries do not separate models themselves (`openai-responses`
|
|
215
|
+
* keys its strict-tools / history-replay record by provider alone,
|
|
216
|
+
* Antigravity by a single constant), so the model belongs in the key here.
|
|
217
|
+
* Endpoint is deliberately absent: every provider whose learning is
|
|
218
|
+
* endpoint-specific already sub-keys it internally
|
|
96
219
|
* (`anthropic-messages:${baseUrl}\0${modelId}`,
|
|
97
220
|
* `openai-completions:${provider}:${baseUrl}:${modelId}`), and repeating it
|
|
98
|
-
* would only fragment the map.
|
|
99
|
-
*
|
|
221
|
+
* would only fragment the map. The credential is absent for the same reason
|
|
222
|
+
* — most of what is retained is true of the endpoint whoever calls it, and
|
|
223
|
+
* Codex already sub-keys its transport by account and bearer — so a
|
|
224
|
+
* credential switch resets the account-dependent subset instead of
|
|
225
|
+
* splitting the entry (see `resetAccountScopedProviderSessionState`).
|
|
226
|
+
*
|
|
227
|
+
* The returned lease MUST be released; until then the entry cannot be
|
|
228
|
+
* evicted.
|
|
100
229
|
*/
|
|
101
|
-
acquire(
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
230
|
+
acquire(request: AuthGatewaySessionStateRequest): AuthGatewaySessionStateLease {
|
|
231
|
+
const session = this.#claim(sessionKeys(request), request.account);
|
|
232
|
+
let released = false;
|
|
233
|
+
return {
|
|
234
|
+
states: session.states,
|
|
235
|
+
updateAccount: (account: string): void => {
|
|
236
|
+
if (session.account === account) return;
|
|
237
|
+
resetAccountScopedProviderSessionState(session.states);
|
|
238
|
+
session.account = account;
|
|
239
|
+
},
|
|
240
|
+
release: (): void => {
|
|
241
|
+
if (released) return;
|
|
242
|
+
released = true;
|
|
243
|
+
session.leases--;
|
|
244
|
+
// This entry may be the victim the bound has been waiting for.
|
|
245
|
+
if (session.leases === 0) this.#evict();
|
|
246
|
+
},
|
|
247
|
+
};
|
|
108
248
|
}
|
|
109
249
|
|
|
110
250
|
/** Close and drop every retained state. Called when the gateway shuts down. */
|
|
111
251
|
close(): void {
|
|
252
|
+
// The only place a leased entry is torn down: the listener is already
|
|
253
|
+
// down, every in-flight stream is being cancelled with it, and the
|
|
254
|
+
// process cannot settle while a Codex WebSocket or Duo workflow is open.
|
|
255
|
+
for (const session of this.#sessions.values()) closeSessionState(session.states, session.key, "shutdown");
|
|
112
256
|
this.#sessions.clear();
|
|
113
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve `keys` to an entry — reusing the nearest ancestor when a keyless
|
|
261
|
+
* conversation has grown — mark it most recently used, and hand the caller
|
|
262
|
+
* the claim. The claim is taken before the ceiling is enforced: a brand-new
|
|
263
|
+
* entry belongs to the request that just created it, and is not a candidate
|
|
264
|
+
* for making room for itself.
|
|
265
|
+
*/
|
|
266
|
+
#claim(keys: readonly string[], account: string): RetainedSession {
|
|
267
|
+
const key = keys[0] ?? "";
|
|
268
|
+
for (const candidate of keys) {
|
|
269
|
+
const session = this.#sessions.get(candidate);
|
|
270
|
+
if (session === undefined) continue;
|
|
271
|
+
// Re-insert at the tail for recency, under this request's own key so
|
|
272
|
+
// the next turn of this conversation finds it as its ancestor. A
|
|
273
|
+
// sibling branch of the same ancestor no longer matches, which is the
|
|
274
|
+
// point: it gets an entry of its own.
|
|
275
|
+
this.#sessions.delete(candidate);
|
|
276
|
+
session.key = key;
|
|
277
|
+
this.#sessions.set(key, session);
|
|
278
|
+
session.leases++;
|
|
279
|
+
if (session.account !== account) {
|
|
280
|
+
resetAccountScopedProviderSessionState(session.states);
|
|
281
|
+
session.account = account;
|
|
282
|
+
}
|
|
283
|
+
return session;
|
|
284
|
+
}
|
|
285
|
+
const created: RetainedSession = { key, states: new Map(), account, leases: 1 };
|
|
286
|
+
this.#sessions.set(key, created);
|
|
287
|
+
this.#evict();
|
|
288
|
+
return created;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Enforce the ceiling against entries no request is holding.
|
|
293
|
+
*
|
|
294
|
+
* A long-running stream is exactly the entry LRU order would pick — it was
|
|
295
|
+
* acquired when the stream opened and not touched since — so blind eviction
|
|
296
|
+
* would `close()` the sockets and flags that stream is still using. Live
|
|
297
|
+
* entries are skipped instead, and the store sits above its bound until
|
|
298
|
+
* their requests release; the excess is therefore capped by the number of
|
|
299
|
+
* concurrent requests, each of which holds a client connection.
|
|
300
|
+
*/
|
|
301
|
+
#evict(): void {
|
|
302
|
+
if (this.#sessions.size <= this.#max) return;
|
|
303
|
+
// Deleting during Map iteration is well-defined: the current and later
|
|
304
|
+
// keys stay consistent, so this walks least-recently-acquired first.
|
|
305
|
+
for (const session of this.#sessions.values()) {
|
|
306
|
+
if (this.#sessions.size <= this.#max) return;
|
|
307
|
+
if (session.leases > 0) continue;
|
|
308
|
+
this.#sessions.delete(session.key);
|
|
309
|
+
closeSessionState(session.states, session.key, "evict");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
114
312
|
}
|