@oh-my-pi/pi-ai 18.2.0 → 18.2.2
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 +53 -0
- package/README.md +2 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
- 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 +118 -0
- package/dist/types/auth-storage.d.ts +17 -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/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -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-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-responses.d.ts +15 -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/openai-codex.d.ts +3 -1
- package/dist/types/usage.d.ts +11 -1
- 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/sqlite-credential-store.ts +8 -33
- 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 +186 -74
- package/src/auth-gateway/session-state.ts +312 -0
- package/src/auth-storage.ts +146 -15
- 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/index.ts +1 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/provider-session-state.ts +56 -0
- package/src/providers/amazon-bedrock.ts +20 -3
- package/src/providers/anthropic-messages-server.ts +104 -23
- package/src/providers/anthropic-signature.ts +5 -2
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/aws-sigv4.ts +16 -5
- package/src/providers/cursor.ts +60 -10
- 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 +49 -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 +52 -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/openai-codex.ts +94 -11
- package/src/usage.ts +8 -1
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/http-inspector.ts +20 -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
|
@@ -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,40 @@ function deriveSessionId(modelId: string, context: Context): string {
|
|
|
128
130
|
return deterministicUuid(seed);
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
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.
|
|
138
|
+
*/
|
|
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)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
131
167
|
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
|
|
132
168
|
const opts: SimpleStreamOptions = { signal, cursorExternalToolExecutor: true };
|
|
133
169
|
const { options } = parsed;
|
|
@@ -168,7 +204,8 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
|
|
|
168
204
|
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
|
|
169
205
|
// key from the model + system + tools so prefix caching engages on
|
|
170
206
|
// Codex-class backends across turns of the same logical conversation.
|
|
171
|
-
const promptCacheKey =
|
|
207
|
+
const promptCacheKey =
|
|
208
|
+
normalizeClientSessionKey(options.promptCacheKey) ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
172
209
|
opts.promptCacheKey = promptCacheKey;
|
|
173
210
|
opts.sessionId = promptCacheKey;
|
|
174
211
|
if (options.thinkingBudgets) {
|
|
@@ -248,7 +285,7 @@ async function refreshGatewayApiKeyAfterAuthError(
|
|
|
248
285
|
const message = error instanceof Error ? error.message : String(error);
|
|
249
286
|
const status = extractHttpStatusFromError(error);
|
|
250
287
|
if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
|
|
251
|
-
const retryAfterMs =
|
|
288
|
+
const retryAfterMs = extractProviderRetryHint(provider, message);
|
|
252
289
|
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
|
|
253
290
|
retryAfterMs,
|
|
254
291
|
providerTimed: retryAfterMs !== undefined,
|
|
@@ -300,6 +337,7 @@ function buildGatewayApiKeyResolver(
|
|
|
300
337
|
requestSignal: AbortSignal,
|
|
301
338
|
format: string,
|
|
302
339
|
peer: string,
|
|
340
|
+
onResolvedKey: (apiKey: string) => void,
|
|
303
341
|
): ApiKeyResolver {
|
|
304
342
|
let lastKey = initialKey;
|
|
305
343
|
return async ({ lastChance, error, signal }) => {
|
|
@@ -315,6 +353,7 @@ function buildGatewayApiKeyResolver(
|
|
|
315
353
|
forceRefresh: true,
|
|
316
354
|
});
|
|
317
355
|
lastKey = refreshed ?? lastKey;
|
|
356
|
+
if (refreshed) onResolvedKey(refreshed);
|
|
318
357
|
return refreshed;
|
|
319
358
|
}
|
|
320
359
|
const next = await refreshGatewayApiKeyAfterAuthError(
|
|
@@ -329,6 +368,7 @@ function buildGatewayApiKeyResolver(
|
|
|
329
368
|
peer,
|
|
330
369
|
);
|
|
331
370
|
lastKey = next ?? lastKey;
|
|
371
|
+
if (next) onResolvedKey(next);
|
|
332
372
|
return next;
|
|
333
373
|
};
|
|
334
374
|
}
|
|
@@ -379,6 +419,7 @@ async function handleFormatEndpoint(
|
|
|
379
419
|
bootOpts: AuthGatewayBootOptions,
|
|
380
420
|
req: Request,
|
|
381
421
|
peer: string,
|
|
422
|
+
sessionStates: AuthGatewaySessionStateStore,
|
|
382
423
|
): Promise<Response> {
|
|
383
424
|
const startedAt = performance.now();
|
|
384
425
|
const requestId = crypto.randomUUID();
|
|
@@ -461,8 +502,9 @@ async function handleFormatEndpoint(
|
|
|
461
502
|
// supplied (so external session ids align), otherwise derive from
|
|
462
503
|
// modelId + system + tools + first message. Mirrored into
|
|
463
504
|
// streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
|
|
464
|
-
const
|
|
465
|
-
parsed.
|
|
505
|
+
const clientKey = normalizeClientSessionKey(parsed.options.promptCacheKey);
|
|
506
|
+
const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
507
|
+
parsed.options.promptCacheKey = sessionId;
|
|
466
508
|
|
|
467
509
|
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
|
|
468
510
|
// expected to resolve the credential and pass it as `options.apiKey`.
|
|
@@ -490,6 +532,19 @@ async function handleFormatEndpoint(
|
|
|
490
532
|
}
|
|
491
533
|
|
|
492
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;
|
|
493
548
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
494
549
|
bootOpts.storage,
|
|
495
550
|
model,
|
|
@@ -498,6 +553,8 @@ async function handleFormatEndpoint(
|
|
|
498
553
|
controller.signal,
|
|
499
554
|
route.label,
|
|
500
555
|
peer,
|
|
556
|
+
resolvedKey =>
|
|
557
|
+
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
501
558
|
);
|
|
502
559
|
|
|
503
560
|
logger.info("auth-gateway request", {
|
|
@@ -545,45 +602,59 @@ async function handleFormatEndpoint(
|
|
|
545
602
|
peer,
|
|
546
603
|
});
|
|
547
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();
|
|
548
609
|
}
|
|
549
610
|
}
|
|
550
611
|
|
|
551
|
-
|
|
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;
|
|
552
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
|
+
}
|
|
553
626
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
554
|
-
events
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
561
|
-
void events
|
|
562
|
-
.result()
|
|
563
|
-
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
564
|
-
.catch(() => {});
|
|
627
|
+
void events
|
|
628
|
+
.result()
|
|
629
|
+
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
630
|
+
.catch(() => {})
|
|
631
|
+
.finally(() => lease.release());
|
|
632
|
+
streamOwnsLease = true;
|
|
565
633
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
+
}
|
|
587
658
|
}
|
|
588
659
|
|
|
589
660
|
/**
|
|
@@ -600,7 +671,12 @@ async function handleFormatEndpoint(
|
|
|
600
671
|
* `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
|
|
601
672
|
* path.
|
|
602
673
|
*/
|
|
603
|
-
async function handlePiNative(
|
|
674
|
+
async function handlePiNative(
|
|
675
|
+
bootOpts: AuthGatewayBootOptions,
|
|
676
|
+
req: Request,
|
|
677
|
+
peer: string,
|
|
678
|
+
sessionStates: AuthGatewaySessionStateStore,
|
|
679
|
+
): Promise<Response> {
|
|
604
680
|
const startedAt = performance.now();
|
|
605
681
|
const requestId = crypto.randomUUID();
|
|
606
682
|
const controller = mirrorRequestAbort(req);
|
|
@@ -635,8 +711,9 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
635
711
|
// up with cache-prefix stickiness — same identity used for both means
|
|
636
712
|
// the next turn of this conversation reuses the same credential until
|
|
637
713
|
// it hits a usage cap, then markUsageLimitReached can hand off.
|
|
638
|
-
const
|
|
639
|
-
parsed.
|
|
714
|
+
const clientKey = normalizeClientSessionKey(parsed.options.sessionId);
|
|
715
|
+
const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context);
|
|
716
|
+
parsed.options.sessionId = sessionId;
|
|
640
717
|
|
|
641
718
|
let apiKey: string | undefined;
|
|
642
719
|
try {
|
|
@@ -659,6 +736,17 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
659
736
|
);
|
|
660
737
|
}
|
|
661
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
|
+
});
|
|
662
750
|
// Build the SimpleStreamOptions actually handed to `streamSimple`. We
|
|
663
751
|
// trust the client's options (already allow-listed by `parseRequest`) and
|
|
664
752
|
// only inject server-controlled fields. The codex sampling strip mirrors
|
|
@@ -668,6 +756,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
668
756
|
apiKey,
|
|
669
757
|
signal: controller.signal,
|
|
670
758
|
cursorExternalToolExecutor: true,
|
|
759
|
+
providerSessionState: lease.states,
|
|
671
760
|
};
|
|
672
761
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
673
762
|
bootOpts.storage,
|
|
@@ -677,6 +766,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
677
766
|
controller.signal,
|
|
678
767
|
"pi-native",
|
|
679
768
|
peer,
|
|
769
|
+
resolvedKey =>
|
|
770
|
+
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
680
771
|
);
|
|
681
772
|
if (model.api === "openai-codex-responses") {
|
|
682
773
|
delete streamOpts.temperature;
|
|
@@ -692,7 +783,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
692
783
|
// headers — the client's values win when they collide.
|
|
693
784
|
const captured = captureRequestHeaders(req.headers);
|
|
694
785
|
streamOpts.headers = { ...captured, ...streamOpts.headers };
|
|
695
|
-
streamOpts.sessionId
|
|
786
|
+
streamOpts.sessionId = sessionId;
|
|
696
787
|
|
|
697
788
|
logger.info("auth-gateway request", {
|
|
698
789
|
requestId,
|
|
@@ -731,42 +822,56 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
|
|
731
822
|
const classified = classifyGatewayError(error);
|
|
732
823
|
logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
|
|
733
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();
|
|
734
829
|
}
|
|
735
830
|
}
|
|
736
831
|
|
|
737
|
-
|
|
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;
|
|
738
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
|
+
}
|
|
739
846
|
if (controller.signal.aborted) return aborted();
|
|
740
|
-
events
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (controller.signal.aborted) return aborted();
|
|
747
|
-
void events
|
|
748
|
-
.result()
|
|
749
|
-
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
750
|
-
.catch(() => {});
|
|
847
|
+
void events
|
|
848
|
+
.result()
|
|
849
|
+
.then(message => recordGatewayUsage(bootOpts.storage, model, client, message))
|
|
850
|
+
.catch(() => {})
|
|
851
|
+
.finally(() => lease.release());
|
|
852
|
+
streamOwnsLease = true;
|
|
751
853
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
+
}
|
|
770
875
|
}
|
|
771
876
|
|
|
772
877
|
/**
|
|
@@ -847,6 +952,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
847
952
|
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
|
|
848
953
|
const tokens = new Set<string>(opts.bearerTokens);
|
|
849
954
|
const version = opts.version;
|
|
955
|
+
// Owned by this server instance so two gateways in one process never share
|
|
956
|
+
// (or tear down) each other's provider state, and so `close()` can drain it.
|
|
957
|
+
const sessionStates = new AuthGatewaySessionStateStore();
|
|
850
958
|
|
|
851
959
|
const server = Bun.serve({
|
|
852
960
|
hostname: bind.hostname,
|
|
@@ -887,13 +995,13 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
887
995
|
// Provider-format dispatch.
|
|
888
996
|
const formatRoute = FORMAT_ROUTES[pathname];
|
|
889
997
|
if (formatRoute && req.method === "POST") {
|
|
890
|
-
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
|
|
998
|
+
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates), req);
|
|
891
999
|
}
|
|
892
1000
|
|
|
893
1001
|
// Pi-native fast path. Same auth + provider plumbing as the
|
|
894
1002
|
// foreign-wire routes, just without the wire-format translation.
|
|
895
1003
|
if (req.method === "POST" && pathname === "/v1/pi/stream") {
|
|
896
|
-
return withCors(await handlePiNative(opts, req, peer), req);
|
|
1004
|
+
return withCors(await handlePiNative(opts, req, peer, sessionStates), req);
|
|
897
1005
|
}
|
|
898
1006
|
|
|
899
1007
|
// Model catalog.
|
|
@@ -927,6 +1035,10 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
927
1035
|
hostname: boundHost,
|
|
928
1036
|
close: async () => {
|
|
929
1037
|
server.stop(true);
|
|
1038
|
+
// Drain after the listener is down: the retained provider states own
|
|
1039
|
+
// sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
|
|
1040
|
+
// process can't settle until each one is closed.
|
|
1041
|
+
sessionStates.close();
|
|
930
1042
|
},
|
|
931
1043
|
};
|
|
932
1044
|
}
|