@oh-my-pi/pi-ai 18.2.0 → 18.2.1
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 +35 -0
- package/dist/types/auth-broker/remote-store.d.ts +17 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/session-state.d.ts +65 -0
- package/dist/types/auth-storage.d.ts +16 -0
- package/dist/types/error/body-error.d.ts +15 -0
- package/dist/types/error/flags.d.ts +16 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-shared.d.ts +20 -3
- package/dist/types/registry/oauth/perplexity.d.ts +1 -7
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage.d.ts +8 -0
- package/dist/types/utils/block-symbols.d.ts +36 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/retry-after.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +4 -5
- package/dist/types/utils.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth-broker/remote-store.ts +73 -8
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/server.ts +48 -11
- package/src/auth-gateway/session-state.ts +114 -0
- package/src/auth-storage.ts +144 -13
- package/src/error/body-error.ts +310 -0
- package/src/error/flags.ts +63 -13
- package/src/error/index.ts +1 -0
- package/src/error/retryable.ts +2 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/providers/anthropic-messages-server.ts +24 -3
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/cursor.ts +7 -1
- package/src/providers/devin.ts +82 -28
- package/src/providers/openai-chat-server.ts +4 -0
- package/src/providers/openai-codex/request-transformer.ts +36 -0
- package/src/providers/openai-codex-responses.ts +35 -12
- package/src/providers/openai-completions.ts +43 -12
- package/src/providers/openai-reasoning-fallback.ts +6 -6
- package/src/providers/openai-responses-server.ts +2 -1
- package/src/providers/openai-responses.ts +25 -4
- package/src/providers/openai-shared.ts +199 -51
- package/src/registry/oauth/perplexity.ts +94 -28
- package/src/registry/oauth/types.ts +9 -0
- package/src/stream.ts +23 -2
- package/src/types.ts +3 -0
- package/src/usage/claude.ts +33 -0
- package/src/usage/google-antigravity.ts +8 -2
- package/src/usage.ts +3 -0
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/openai-http.ts +39 -3
- package/src/utils/retry-after.ts +12 -0
- package/src/utils/schema/normalize.ts +3 -3
- package/src/utils/schema/stamps.ts +33 -45
- package/src/utils/schema/wire.ts +9 -7
- package/src/utils.ts +67 -22
|
@@ -255,6 +255,18 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
255
255
|
#snapshot: SnapshotResponse = emptySnapshot();
|
|
256
256
|
#snapshotReceivedAt = Date.now();
|
|
257
257
|
#generation = 0;
|
|
258
|
+
/**
|
|
259
|
+
* Content fingerprint of the credential set in {@link #snapshot} (id +
|
|
260
|
+
* provider + credential material), recomputed after every snapshot mutation.
|
|
261
|
+
* Drives {@link #credentialRevision} independently of the broker's numeric
|
|
262
|
+
* generation, which is an in-memory counter that resets when the broker
|
|
263
|
+
* process restarts and so cannot be trusted for change detection.
|
|
264
|
+
*/
|
|
265
|
+
#credentialFingerprint = "";
|
|
266
|
+
/** Monotonic local counter bumped whenever {@link #credentialFingerprint} changes. */
|
|
267
|
+
#credentialRevision = 0;
|
|
268
|
+
/** Revision last reported as "seen" by {@link pollExternalChanges}; seeded from the initial snapshot. */
|
|
269
|
+
#acknowledgedRevision = 0;
|
|
258
270
|
#usageOverlays: Map<string, UsageReport> = new Map();
|
|
259
271
|
#backgroundAbort = new AbortController();
|
|
260
272
|
readonly #backgroundIdleMs: number;
|
|
@@ -300,6 +312,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
300
312
|
? new Map([...opts.accountPool].map(([provider, identities]) => [provider, new Set(identities)]))
|
|
301
313
|
: undefined;
|
|
302
314
|
this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
|
|
315
|
+
this.#acknowledgedRevision = this.#credentialRevision;
|
|
303
316
|
this.#onSnapshot = opts.onSnapshot;
|
|
304
317
|
void this.#runBackground();
|
|
305
318
|
}
|
|
@@ -325,6 +338,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
325
338
|
this.#snapshot = { ...snapshot, credentials };
|
|
326
339
|
this.#generation = generation;
|
|
327
340
|
this.#snapshotReceivedAt = nowMs;
|
|
341
|
+
this.#refreshCredentialRevision();
|
|
328
342
|
const onSnapshot = this.#onSnapshot;
|
|
329
343
|
if (!onSnapshot) return;
|
|
330
344
|
try {
|
|
@@ -333,6 +347,34 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
333
347
|
logger.debug("auth-broker snapshot callback failed", { error: String(error) });
|
|
334
348
|
}
|
|
335
349
|
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Recompute the credential-content fingerprint and bump
|
|
353
|
+
* {@link #credentialRevision} when it changes. Called after every snapshot
|
|
354
|
+
* mutation so {@link pollExternalChanges} detects add/remove/replace even
|
|
355
|
+
* when the broker's numeric generation repeats (e.g. after a broker
|
|
356
|
+
* restart resets its in-memory counter).
|
|
357
|
+
*/
|
|
358
|
+
#refreshCredentialRevision(): void {
|
|
359
|
+
const fingerprint = this.#computeCredentialFingerprint();
|
|
360
|
+
if (fingerprint === this.#credentialFingerprint) return;
|
|
361
|
+
this.#credentialFingerprint = fingerprint;
|
|
362
|
+
this.#credentialRevision += 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Order-independent digest of the routable credential material — exactly the
|
|
367
|
+
* fields {@link listAuthCredentials} exposes (id, provider, credential). A
|
|
368
|
+
* token rotation or an add/remove changes it; credential blocks and usage
|
|
369
|
+
* overlays do not.
|
|
370
|
+
*/
|
|
371
|
+
#computeCredentialFingerprint(): string {
|
|
372
|
+
const parts = this.#snapshot.credentials.map(
|
|
373
|
+
entry => `${entry.id}\u0000${entry.provider}\u0000${JSON.stringify(entry.credential)}`,
|
|
374
|
+
);
|
|
375
|
+
parts.sort();
|
|
376
|
+
return parts.join("\u0001");
|
|
377
|
+
}
|
|
336
378
|
#protectNewSnapshotBlocks(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[], nowMs: number): void {
|
|
337
379
|
const previousBlocksByKey = new Map<string, string>();
|
|
338
380
|
for (const entry of previous) {
|
|
@@ -499,15 +541,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
499
541
|
#applyStreamEvent(event: SnapshotStreamEvent): void {
|
|
500
542
|
switch (event.kind) {
|
|
501
543
|
case "snapshot": {
|
|
502
|
-
//
|
|
544
|
+
// The first frame of every SSE connection is a full authoritative
|
|
545
|
+
// snapshot. Always adopt it as the new generation baseline: the
|
|
546
|
+
// broker's in-memory generation counter resets on restart and may
|
|
547
|
+
// therefore be lower than the previous stream's last value.
|
|
548
|
+
// Subsequent entry/removal frames remain guarded against reordering
|
|
549
|
+
// relative to this new baseline below.
|
|
503
550
|
const { kind: _kind, ...snapshot } = event;
|
|
504
|
-
if (snapshot.generation < this.#generation) {
|
|
505
|
-
logger.debug("auth-broker stream snapshot older than local; ignoring", {
|
|
506
|
-
local: this.#generation,
|
|
507
|
-
incoming: snapshot.generation,
|
|
508
|
-
});
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
551
|
this.#applySnapshot(snapshot, snapshot.generation);
|
|
512
552
|
return;
|
|
513
553
|
}
|
|
@@ -548,6 +588,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
548
588
|
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
549
589
|
this.#generation = generation;
|
|
550
590
|
this.#snapshotReceivedAt = Date.now();
|
|
591
|
+
this.#refreshCredentialRevision();
|
|
551
592
|
}
|
|
552
593
|
|
|
553
594
|
#removeStreamCredential(
|
|
@@ -564,6 +605,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
564
605
|
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
565
606
|
this.#generation = generation;
|
|
566
607
|
this.#snapshotReceivedAt = Date.now();
|
|
608
|
+
this.#refreshCredentialRevision();
|
|
567
609
|
}
|
|
568
610
|
|
|
569
611
|
/** Re-hydrate the in-memory snapshot from the broker. */
|
|
@@ -574,6 +616,29 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
574
616
|
return this.#snapshot;
|
|
575
617
|
}
|
|
576
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Stateful probe for broker-side credential changes, mirroring
|
|
621
|
+
* {@link SqliteAuthCredentialStore.pollExternalChanges} so long-lived broker
|
|
622
|
+
* clients (notably `auth-gateway serve`) pick up logins/logouts made by
|
|
623
|
+
* another process without a restart.
|
|
624
|
+
*
|
|
625
|
+
* Compares a local content revision, not the broker's numeric generation:
|
|
626
|
+
* generation is an in-memory counter that resets when the broker process
|
|
627
|
+
* restarts, so a reconnecting stream can deliver a different credential set
|
|
628
|
+
* under a repeated (or lower) generation. {@link #refreshCredentialRevision}
|
|
629
|
+
* bumps the revision whenever the applied credential material actually
|
|
630
|
+
* changes, catching those cases too. Records foreground activity first: a
|
|
631
|
+
* low-traffic client's background sync parks after `#backgroundIdleMs`, and
|
|
632
|
+
* without this wakeup it would never fetch the new snapshot to report in the
|
|
633
|
+
* first place.
|
|
634
|
+
*/
|
|
635
|
+
pollExternalChanges(): boolean {
|
|
636
|
+
this.#noteActivity();
|
|
637
|
+
if (this.#credentialRevision === this.#acknowledgedRevision) return false;
|
|
638
|
+
this.#acknowledgedRevision = this.#credentialRevision;
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
|
|
577
642
|
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
578
643
|
this.#noteActivity();
|
|
579
644
|
const out: StoredAuthCredential[] = [];
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
22
|
-
import { extractHttpStatusFromError,
|
|
22
|
+
import { extractHttpStatusFromError, logger } from "@oh-my-pi/pi-utils";
|
|
23
23
|
import type { ApiKeyResolver } from "../auth-retry";
|
|
24
24
|
import type { AuthStorage } from "../auth-storage";
|
|
25
25
|
import * as AIError from "../error";
|
|
@@ -34,6 +34,7 @@ import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model
|
|
|
34
34
|
import type { ClientUsageIdentity } from "../usage";
|
|
35
35
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
36
36
|
import { parseBind } from "../utils/parse-bind";
|
|
37
|
+
import { extractProviderRetryHint } from "../utils/retry-after";
|
|
37
38
|
import {
|
|
38
39
|
captureRequestHeaders,
|
|
39
40
|
corsHeaders,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
resolvePeer,
|
|
45
46
|
withCors,
|
|
46
47
|
} from "./http";
|
|
48
|
+
import { AuthGatewaySessionStateStore } from "./session-state";
|
|
47
49
|
import type {
|
|
48
50
|
AuthGatewayServerHandle,
|
|
49
51
|
AuthGatewayServerOptions,
|
|
@@ -128,6 +130,18 @@ function deriveSessionId(modelId: string, context: Context): string {
|
|
|
128
130
|
return deterministicUuid(seed);
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the logical session identity for one request. A client-supplied key
|
|
135
|
+
* wins so external session ids line up with the gateway's, but a blank one
|
|
136
|
+
* counts as absent: honouring it would collapse every caller that sends an
|
|
137
|
+
* empty key into one shared credential-sticky, prefix-cache and
|
|
138
|
+
* provider-session bucket.
|
|
139
|
+
*/
|
|
140
|
+
function resolveSessionId(clientKey: string | undefined, modelId: string, context: Context): string {
|
|
141
|
+
if (clientKey !== undefined && clientKey.trim().length > 0) return clientKey;
|
|
142
|
+
return deriveSessionId(modelId, context);
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
|
|
132
146
|
const opts: SimpleStreamOptions = { signal, cursorExternalToolExecutor: true };
|
|
133
147
|
const { options } = parsed;
|
|
@@ -168,7 +182,7 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
|
|
|
168
182
|
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
|
|
169
183
|
// key from the model + system + tools so prefix caching engages on
|
|
170
184
|
// Codex-class backends across turns of the same logical conversation.
|
|
171
|
-
const promptCacheKey = options.promptCacheKey
|
|
185
|
+
const promptCacheKey = resolveSessionId(options.promptCacheKey, parsed.modelId, parsed.context);
|
|
172
186
|
opts.promptCacheKey = promptCacheKey;
|
|
173
187
|
opts.sessionId = promptCacheKey;
|
|
174
188
|
if (options.thinkingBudgets) {
|
|
@@ -248,7 +262,7 @@ async function refreshGatewayApiKeyAfterAuthError(
|
|
|
248
262
|
const message = error instanceof Error ? error.message : String(error);
|
|
249
263
|
const status = extractHttpStatusFromError(error);
|
|
250
264
|
if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
|
|
251
|
-
const retryAfterMs =
|
|
265
|
+
const retryAfterMs = extractProviderRetryHint(provider, message);
|
|
252
266
|
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
|
|
253
267
|
retryAfterMs,
|
|
254
268
|
providerTimed: retryAfterMs !== undefined,
|
|
@@ -379,6 +393,7 @@ async function handleFormatEndpoint(
|
|
|
379
393
|
bootOpts: AuthGatewayBootOptions,
|
|
380
394
|
req: Request,
|
|
381
395
|
peer: string,
|
|
396
|
+
sessionStates: AuthGatewaySessionStateStore,
|
|
382
397
|
): Promise<Response> {
|
|
383
398
|
const startedAt = performance.now();
|
|
384
399
|
const requestId = crypto.randomUUID();
|
|
@@ -461,8 +476,8 @@ async function handleFormatEndpoint(
|
|
|
461
476
|
// supplied (so external session ids align), otherwise derive from
|
|
462
477
|
// modelId + system + tools + first message. Mirrored into
|
|
463
478
|
// streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
|
|
464
|
-
const sessionId = parsed.options.promptCacheKey
|
|
465
|
-
parsed.options.promptCacheKey
|
|
479
|
+
const sessionId = resolveSessionId(parsed.options.promptCacheKey, parsed.modelId, parsed.context);
|
|
480
|
+
parsed.options.promptCacheKey = sessionId;
|
|
466
481
|
|
|
467
482
|
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
|
|
468
483
|
// expected to resolve the credential and pass it as `options.apiKey`.
|
|
@@ -499,6 +514,11 @@ async function handleFormatEndpoint(
|
|
|
499
514
|
route.label,
|
|
500
515
|
peer,
|
|
501
516
|
);
|
|
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);
|
|
502
522
|
|
|
503
523
|
logger.info("auth-gateway request", {
|
|
504
524
|
requestId,
|
|
@@ -600,7 +620,12 @@ async function handleFormatEndpoint(
|
|
|
600
620
|
* `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
|
|
601
621
|
* path.
|
|
602
622
|
*/
|
|
603
|
-
async function handlePiNative(
|
|
623
|
+
async function handlePiNative(
|
|
624
|
+
bootOpts: AuthGatewayBootOptions,
|
|
625
|
+
req: Request,
|
|
626
|
+
peer: string,
|
|
627
|
+
sessionStates: AuthGatewaySessionStateStore,
|
|
628
|
+
): Promise<Response> {
|
|
604
629
|
const startedAt = performance.now();
|
|
605
630
|
const requestId = crypto.randomUUID();
|
|
606
631
|
const controller = mirrorRequestAbort(req);
|
|
@@ -635,8 +660,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
635
660
|
// up with cache-prefix stickiness — same identity used for both means
|
|
636
661
|
// the next turn of this conversation reuses the same credential until
|
|
637
662
|
// it hits a usage cap, then markUsageLimitReached can hand off.
|
|
638
|
-
const sessionId = parsed.options.sessionId
|
|
639
|
-
parsed.options.sessionId
|
|
663
|
+
const sessionId = resolveSessionId(parsed.options.sessionId, parsed.modelId, parsed.context);
|
|
664
|
+
parsed.options.sessionId = sessionId;
|
|
640
665
|
|
|
641
666
|
let apiKey: string | undefined;
|
|
642
667
|
try {
|
|
@@ -668,6 +693,11 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
668
693
|
apiKey,
|
|
669
694
|
signal: controller.signal,
|
|
670
695
|
cursorExternalToolExecutor: true,
|
|
696
|
+
// Per-session provider learning, owned by this gateway instance. The map
|
|
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),
|
|
671
701
|
};
|
|
672
702
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
673
703
|
bootOpts.storage,
|
|
@@ -692,7 +722,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
692
722
|
// headers — the client's values win when they collide.
|
|
693
723
|
const captured = captureRequestHeaders(req.headers);
|
|
694
724
|
streamOpts.headers = { ...captured, ...streamOpts.headers };
|
|
695
|
-
streamOpts.sessionId
|
|
725
|
+
streamOpts.sessionId = sessionId;
|
|
696
726
|
|
|
697
727
|
logger.info("auth-gateway request", {
|
|
698
728
|
requestId,
|
|
@@ -847,6 +877,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
847
877
|
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
|
|
848
878
|
const tokens = new Set<string>(opts.bearerTokens);
|
|
849
879
|
const version = opts.version;
|
|
880
|
+
// Owned by this server instance so two gateways in one process never share
|
|
881
|
+
// (or tear down) each other's provider state, and so `close()` can drain it.
|
|
882
|
+
const sessionStates = new AuthGatewaySessionStateStore();
|
|
850
883
|
|
|
851
884
|
const server = Bun.serve({
|
|
852
885
|
hostname: bind.hostname,
|
|
@@ -887,13 +920,13 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
887
920
|
// Provider-format dispatch.
|
|
888
921
|
const formatRoute = FORMAT_ROUTES[pathname];
|
|
889
922
|
if (formatRoute && req.method === "POST") {
|
|
890
|
-
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
|
|
923
|
+
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates), req);
|
|
891
924
|
}
|
|
892
925
|
|
|
893
926
|
// Pi-native fast path. Same auth + provider plumbing as the
|
|
894
927
|
// foreign-wire routes, just without the wire-format translation.
|
|
895
928
|
if (req.method === "POST" && pathname === "/v1/pi/stream") {
|
|
896
|
-
return withCors(await handlePiNative(opts, req, peer), req);
|
|
929
|
+
return withCors(await handlePiNative(opts, req, peer, sessionStates), req);
|
|
897
930
|
}
|
|
898
931
|
|
|
899
932
|
// Model catalog.
|
|
@@ -927,6 +960,10 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
927
960
|
hostname: boundHost,
|
|
928
961
|
close: async () => {
|
|
929
962
|
server.stop(true);
|
|
963
|
+
// Drain after the listener is down: the retained provider states own
|
|
964
|
+
// sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
|
|
965
|
+
// process can't settle until each one is closed.
|
|
966
|
+
sessionStates.close();
|
|
930
967
|
},
|
|
931
968
|
};
|
|
932
969
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-owned provider session state for the auth-gateway.
|
|
3
|
+
*
|
|
4
|
+
* `SimpleStreamOptions.providerSessionState` is how a provider keeps what it
|
|
5
|
+
* learned about an endpoint across turns of one conversation: Anthropic's
|
|
6
|
+
* sticky `strictToolsDisabled` / `fastModeDisabled` /
|
|
7
|
+
* `replayUnsignedThinkingDisabled` flags and dropped-thinking-prefix set,
|
|
8
|
+
* OpenAI's strict-tools and reasoning-effort fallbacks, Codex's WebSocket and
|
|
9
|
+
* turn-state sessions. An in-process omp session owns that `Map` for its whole
|
|
10
|
+
* lifetime, so a grammar-too-large 400 or a fast-mode rejection costs one
|
|
11
|
+
* wasted round-trip per session rather than one per turn.
|
|
12
|
+
*
|
|
13
|
+
* The map is deliberately non-serializable — `Set`/`Map` fields, live sockets,
|
|
14
|
+
* a `close()` method — so `pi-native-client` strips it from the wire and
|
|
15
|
+
* `pi-native-server` never accepts it. Gateway clients therefore cannot bring
|
|
16
|
+
* their own, and without a server-side owner every containerized / robomp turn
|
|
17
|
+
* re-learns every lesson from a fresh upstream rejection.
|
|
18
|
+
*
|
|
19
|
+
* A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
|
|
20
|
+
* ever reclaims an entry, and the entries own timers and sockets. This store is
|
|
21
|
+
* an LRU with a hard entry ceiling that calls `close()` on everything it drops
|
|
22
|
+
* and on everything it still holds at shutdown.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
26
|
+
import { type DisposeReason, LRUCache } from "@oh-my-pi/pi-utils/lru";
|
|
27
|
+
import type { Api, Model, ProviderSessionState } from "../types";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Retained logical sessions. Each entry is a handful of small provider records
|
|
31
|
+
* plus, for Codex, a WebSocket session — cheap to keep, but not free, so the
|
|
32
|
+
* ceiling is what turns "one entry per session id forever" into a bounded cost.
|
|
33
|
+
* Eviction is least-recently-used, so the ceiling only ever drops sessions that
|
|
34
|
+
* have been quiet longer than the 256 most recent ones.
|
|
35
|
+
*/
|
|
36
|
+
export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Close every provider record an evicted (or drained) session held.
|
|
40
|
+
*
|
|
41
|
+
* Anthropic's `close()` resets its sticky flags, Codex's tears down WebSockets
|
|
42
|
+
* and GitLab Duo's stops the server-side workflow — so dropping an entry
|
|
43
|
+
* without closing it leaks exactly the resources the bound exists to cap.
|
|
44
|
+
*/
|
|
45
|
+
function closeSessionState(states: Map<string, ProviderSessionState>, sessionKey: string, reason: DisposeReason): void {
|
|
46
|
+
for (const [providerKey, state] of states) {
|
|
47
|
+
try {
|
|
48
|
+
state.close();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// One provider's teardown must not abort the rest: a throw here
|
|
51
|
+
// propagates out of `LRUCache.set` into whichever request happened to
|
|
52
|
+
// trigger the eviction, or abandons the remainder of the shutdown
|
|
53
|
+
// drain.
|
|
54
|
+
logger.warn("auth-gateway provider session state close failed", {
|
|
55
|
+
sessionKey,
|
|
56
|
+
providerKey,
|
|
57
|
+
reason,
|
|
58
|
+
error: String(error),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
states.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bounded per-session provider state, owned by one gateway server instance.
|
|
67
|
+
*
|
|
68
|
+
* Two gateways in the same process get separate stores, so neither can hand a
|
|
69
|
+
* request another gateway's learned state or close it out from under one.
|
|
70
|
+
*/
|
|
71
|
+
export class AuthGatewaySessionStateStore {
|
|
72
|
+
readonly #sessions: LRUCache<string, Map<string, ProviderSessionState>>;
|
|
73
|
+
|
|
74
|
+
constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
|
|
75
|
+
this.#sessions = new LRUCache({ max, dispose: closeSessionState });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Retained logical sessions. */
|
|
79
|
+
get size(): number {
|
|
80
|
+
return this.#sessions.size;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The provider-session map for one logical session on one model, created on
|
|
85
|
+
* first use and returned by reference so provider mutations persist into the
|
|
86
|
+
* next request.
|
|
87
|
+
*
|
|
88
|
+
* Keyed by session + provider + model id. The session is the identity that
|
|
89
|
+
* matters — it is the same identity used for credential stickiness and
|
|
90
|
+
* prefix-cache keying — but a client is free to reuse one session id across
|
|
91
|
+
* models, and the coarsest provider entries do not separate models
|
|
92
|
+
* themselves (`openai-responses` keys its strict-tools / history-replay
|
|
93
|
+
* record by provider alone, Antigravity by a single constant), so the model
|
|
94
|
+
* belongs in the key here. Endpoint is deliberately absent: every provider
|
|
95
|
+
* whose learning is endpoint-specific already sub-keys it internally
|
|
96
|
+
* (`anthropic-messages:${baseUrl}\0${modelId}`,
|
|
97
|
+
* `openai-completions:${provider}:${baseUrl}:${modelId}`), and repeating it
|
|
98
|
+
* would only fragment the map. NUL separates the components so none of them
|
|
99
|
+
* can forge the boundary.
|
|
100
|
+
*/
|
|
101
|
+
acquire(sessionId: string, model: Model<Api>): Map<string, ProviderSessionState> {
|
|
102
|
+
const key = `${sessionId}\u0000${model.provider}\u0000${model.id}`;
|
|
103
|
+
const existing = this.#sessions.get(key);
|
|
104
|
+
if (existing) return existing;
|
|
105
|
+
const created = new Map<string, ProviderSessionState>();
|
|
106
|
+
this.#sessions.set(key, created);
|
|
107
|
+
return created;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Close and drop every retained state. Called when the gateway shuts down. */
|
|
111
|
+
close(): void {
|
|
112
|
+
this.#sessions.clear();
|
|
113
|
+
}
|
|
114
|
+
}
|