@linxiraos/pi-ai 1.2.1 → 1.3.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 +98 -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/dialect/deepseek.d.ts +13 -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 +62 -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 +37 -6
- package/dist/types/judgment/wire.d.ts +18 -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/ollama.d.ts +6 -1
- 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 +28 -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/usage/muse-code.d.ts +1 -1
- package/dist/types/utils/dsml-leak.d.ts +17 -13
- 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/deepseek.ts +344 -81
- 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 +238 -12
- package/src/judgment/openai-decisions.ts +30 -6
- package/src/judgment/systemone.ts +153 -11
- package/src/judgment/text.ts +26 -7
- package/src/judgment/types.ts +46 -6
- package/src/judgment/wire.ts +19 -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 +23 -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 +104 -55
- 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/muse-code.ts +115 -29
- 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 +88 -33
- 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
|
@@ -41,10 +41,12 @@ import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOpti
|
|
|
41
41
|
import { deterministicUuid } from "../utils/deterministic-id";
|
|
42
42
|
import { parseBind } from "../utils/parse-bind";
|
|
43
43
|
import { resolvePeer } from "../utils/resolve-peer";
|
|
44
|
+
import { type AuthGatewayClientScope, AuthGatewayClientTable } from "./clients";
|
|
44
45
|
import {
|
|
45
46
|
type AuthGatewayBootOptions,
|
|
46
47
|
type AuthGatewayRouteOptions,
|
|
47
48
|
buildGatewayApiKeyResolver,
|
|
49
|
+
gatewayRequestLogFields,
|
|
48
50
|
mirrorRequestAbort,
|
|
49
51
|
normalizeClientSessionKey,
|
|
50
52
|
recordGatewayUsage,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
hasMisplacedBearer,
|
|
59
61
|
isAuthorized,
|
|
60
62
|
json,
|
|
63
|
+
readBearerToken,
|
|
61
64
|
resolveClientIdentity,
|
|
62
65
|
withCors,
|
|
63
66
|
} from "./http";
|
|
@@ -145,23 +148,26 @@ function serializeTools(context: Context): string | undefined {
|
|
|
145
148
|
return context.tools && context.tools.length > 0 ? JSON.stringify(context.tools) : undefined;
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
function buildStreamOptions(parsed: ParsedFormatRequest,
|
|
151
|
+
function buildStreamOptions(parsed: ParsedFormatRequest, model: Model<Api>, signal: AbortSignal): SimpleStreamOptions {
|
|
149
152
|
const opts: SimpleStreamOptions = { signal, cursorExternalToolExecutor: true };
|
|
150
153
|
const { options } = parsed;
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// Backends that reject sampling controls (Codex: `Unsupported parameter: …`, #3117)
|
|
155
|
+
// get the full set stripped; everything else is harmless to forward —
|
|
156
|
+
// `streamSimple` ignores what the underlying provider doesn't honour.
|
|
157
|
+
const sampling = !(
|
|
158
|
+
model.compat &&
|
|
159
|
+
"supportsSamplingParams" in model.compat &&
|
|
160
|
+
model.compat.supportsSamplingParams === false
|
|
161
|
+
);
|
|
156
162
|
if (options.maxOutputTokens !== undefined) opts.maxTokens = options.maxOutputTokens;
|
|
157
|
-
if (options.temperature !== undefined &&
|
|
158
|
-
if (options.topP !== undefined &&
|
|
159
|
-
if (options.topK !== undefined &&
|
|
160
|
-
if (options.minP !== undefined &&
|
|
161
|
-
if (options.stopSequences !== undefined &&
|
|
162
|
-
if (options.presencePenalty !== undefined &&
|
|
163
|
-
if (options.frequencyPenalty !== undefined &&
|
|
164
|
-
if (options.repetitionPenalty !== undefined &&
|
|
163
|
+
if (options.temperature !== undefined && sampling) opts.temperature = options.temperature;
|
|
164
|
+
if (options.topP !== undefined && sampling) opts.topP = options.topP;
|
|
165
|
+
if (options.topK !== undefined && sampling) opts.topK = options.topK;
|
|
166
|
+
if (options.minP !== undefined && sampling) opts.minP = options.minP;
|
|
167
|
+
if (options.stopSequences !== undefined && sampling) opts.stopSequences = options.stopSequences;
|
|
168
|
+
if (options.presencePenalty !== undefined && sampling) opts.presencePenalty = options.presencePenalty;
|
|
169
|
+
if (options.frequencyPenalty !== undefined && sampling) opts.frequencyPenalty = options.frequencyPenalty;
|
|
170
|
+
if (options.repetitionPenalty !== undefined && sampling) opts.repetitionPenalty = options.repetitionPenalty;
|
|
165
171
|
if (options.metadata !== undefined) opts.metadata = options.metadata;
|
|
166
172
|
if (options.userProfileId !== undefined) opts.userProfileId = options.userProfileId;
|
|
167
173
|
if (options.headers !== undefined) opts.headers = { ...opts.headers, ...options.headers };
|
|
@@ -223,7 +229,7 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
|
|
|
223
229
|
options.responseFormat !== undefined
|
|
224
230
|
) {
|
|
225
231
|
logger.debug("auth-gateway dropped unsupported typed options", {
|
|
226
|
-
api,
|
|
232
|
+
api: model.api,
|
|
227
233
|
parallelToolCalls: options.parallelToolCalls,
|
|
228
234
|
previousResponseId: options.previousResponseId,
|
|
229
235
|
seed: options.seed,
|
|
@@ -358,11 +364,11 @@ async function handleFormatEndpoint(
|
|
|
358
364
|
// expected to resolve the credential and pass it as `options.apiKey`.
|
|
359
365
|
// For OAuth providers this returns the access token (refreshed via the
|
|
360
366
|
// broker override on AuthStorage when needed).
|
|
361
|
-
const apiKey = await resolveGatewayApiKey(bootOpts
|
|
367
|
+
const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
|
|
362
368
|
if (controller.signal.aborted) return clientClosedResponse(route);
|
|
363
369
|
if ("status" in apiKey) return route.module.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
364
370
|
|
|
365
|
-
const streamOpts = buildStreamOptions(parsed, model
|
|
371
|
+
const streamOpts = buildStreamOptions(parsed, model, controller.signal);
|
|
366
372
|
if (bootOpts.fetch) streamOpts.fetch = bootOpts.fetch;
|
|
367
373
|
// Per-session provider learning (sticky strict-tools / fast-mode / thinking
|
|
368
374
|
// fallbacks, Codex transport sessions). Owned by this gateway instance: the
|
|
@@ -375,19 +381,19 @@ async function handleFormatEndpoint(
|
|
|
375
381
|
model,
|
|
376
382
|
context: parsed.context,
|
|
377
383
|
toolsJson,
|
|
378
|
-
account: resolveGatewayAccount(
|
|
384
|
+
account: resolveGatewayAccount(apiKey),
|
|
385
|
+
client: bootOpts.client?.name,
|
|
379
386
|
});
|
|
380
387
|
streamOpts.providerSessionState = lease.states;
|
|
381
388
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
382
|
-
bootOpts
|
|
389
|
+
bootOpts,
|
|
383
390
|
model,
|
|
384
391
|
sessionId,
|
|
385
392
|
apiKey,
|
|
386
393
|
controller.signal,
|
|
387
394
|
route.label,
|
|
388
395
|
peer,
|
|
389
|
-
|
|
390
|
-
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
396
|
+
resolved => lease.updateAccount(resolveGatewayAccount(resolved)),
|
|
391
397
|
);
|
|
392
398
|
|
|
393
399
|
logger.info("auth-gateway request", {
|
|
@@ -398,6 +404,7 @@ async function handleFormatEndpoint(
|
|
|
398
404
|
resolvedModel: model.id,
|
|
399
405
|
stream: parsed.stream,
|
|
400
406
|
peer,
|
|
407
|
+
...gatewayRequestLogFields(bootOpts, apiKey),
|
|
401
408
|
});
|
|
402
409
|
|
|
403
410
|
if (!parsed.stream) {
|
|
@@ -442,9 +449,15 @@ async function handleFormatEndpoint(
|
|
|
442
449
|
}
|
|
443
450
|
}
|
|
444
451
|
|
|
445
|
-
// A streamed turn outlives this function, so the lease
|
|
446
|
-
//
|
|
447
|
-
//
|
|
452
|
+
// A streamed turn outlives this function, so the lease, and a scoped
|
|
453
|
+
// client's hold on its account restriction, travel with the event stream and
|
|
454
|
+
// are released when the turn settles. Until that handoff happens, the
|
|
455
|
+
// `finally` below owns them.
|
|
456
|
+
const clientHold = bootOpts.client?.retain();
|
|
457
|
+
const settle = (): void => {
|
|
458
|
+
lease.release();
|
|
459
|
+
clientHold?.();
|
|
460
|
+
};
|
|
448
461
|
let streamOwnsLease = false;
|
|
449
462
|
try {
|
|
450
463
|
let events: AssistantMessageEventStream;
|
|
@@ -463,7 +476,7 @@ async function handleFormatEndpoint(
|
|
|
463
476
|
recordGatewayUsage(bootOpts.storage, model, client, message.usage, message.timestamp || undefined),
|
|
464
477
|
)
|
|
465
478
|
.catch(() => {})
|
|
466
|
-
.finally(
|
|
479
|
+
.finally(settle);
|
|
467
480
|
streamOwnsLease = true;
|
|
468
481
|
|
|
469
482
|
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
|
|
@@ -488,7 +501,7 @@ async function handleFormatEndpoint(
|
|
|
488
501
|
},
|
|
489
502
|
});
|
|
490
503
|
} finally {
|
|
491
|
-
if (!streamOwnsLease)
|
|
504
|
+
if (!streamOwnsLease) settle();
|
|
492
505
|
}
|
|
493
506
|
}
|
|
494
507
|
|
|
@@ -553,7 +566,7 @@ async function handlePiNative(
|
|
|
553
566
|
const sessionId = clientKey ?? deriveSessionId(parsed.modelId, parsed.context, toolsJson);
|
|
554
567
|
parsed.options.sessionId = sessionId;
|
|
555
568
|
|
|
556
|
-
const apiKey = await resolveGatewayApiKey(bootOpts
|
|
569
|
+
const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
|
|
557
570
|
if (controller.signal.aborted) return aborted();
|
|
558
571
|
if ("status" in apiKey) return piNative.formatError(apiKey.status, apiKey.type, apiKey.message);
|
|
559
572
|
|
|
@@ -567,12 +580,13 @@ async function handlePiNative(
|
|
|
567
580
|
model,
|
|
568
581
|
context: parsed.context,
|
|
569
582
|
toolsJson,
|
|
570
|
-
account: resolveGatewayAccount(
|
|
583
|
+
account: resolveGatewayAccount(apiKey),
|
|
584
|
+
client: bootOpts.client?.name,
|
|
571
585
|
});
|
|
572
586
|
// Build the SimpleStreamOptions actually handed to `streamSimple`. We
|
|
573
587
|
// trust the client's options (already allow-listed by `parseRequest`) and
|
|
574
|
-
// only inject server-controlled fields. The
|
|
575
|
-
// `buildStreamOptions` —
|
|
588
|
+
// only inject server-controlled fields. The sampling strip mirrors
|
|
589
|
+
// `buildStreamOptions` — backends with `supportsSamplingParams: false` 400 on them (#3117).
|
|
576
590
|
const streamOpts: SimpleStreamOptions = {
|
|
577
591
|
...parsed.options,
|
|
578
592
|
signal: controller.signal,
|
|
@@ -581,17 +595,16 @@ async function handlePiNative(
|
|
|
581
595
|
};
|
|
582
596
|
if (bootOpts.fetch) streamOpts.fetch = bootOpts.fetch;
|
|
583
597
|
streamOpts.apiKey = buildGatewayApiKeyResolver(
|
|
584
|
-
bootOpts
|
|
598
|
+
bootOpts,
|
|
585
599
|
model,
|
|
586
600
|
sessionId,
|
|
587
601
|
apiKey,
|
|
588
602
|
controller.signal,
|
|
589
603
|
"pi-native",
|
|
590
604
|
peer,
|
|
591
|
-
|
|
592
|
-
lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
|
|
605
|
+
resolved => lease.updateAccount(resolveGatewayAccount(resolved)),
|
|
593
606
|
);
|
|
594
|
-
if (model.
|
|
607
|
+
if (model.compat && "supportsSamplingParams" in model.compat && model.compat.supportsSamplingParams === false) {
|
|
595
608
|
delete streamOpts.temperature;
|
|
596
609
|
delete streamOpts.topP;
|
|
597
610
|
delete streamOpts.topK;
|
|
@@ -615,6 +628,7 @@ async function handlePiNative(
|
|
|
615
628
|
resolvedModel: model.id,
|
|
616
629
|
stream: parsed.stream,
|
|
617
630
|
peer,
|
|
631
|
+
...gatewayRequestLogFields(bootOpts, apiKey),
|
|
618
632
|
});
|
|
619
633
|
|
|
620
634
|
if (!parsed.stream) {
|
|
@@ -655,9 +669,15 @@ async function handlePiNative(
|
|
|
655
669
|
}
|
|
656
670
|
}
|
|
657
671
|
|
|
658
|
-
// A streamed turn outlives this function, so the lease
|
|
659
|
-
//
|
|
660
|
-
//
|
|
672
|
+
// A streamed turn outlives this function, so the lease, and a scoped
|
|
673
|
+
// client's hold on its account restriction, travel with the event stream and
|
|
674
|
+
// are released when the turn settles. Until that handoff happens, the
|
|
675
|
+
// `finally` below owns them.
|
|
676
|
+
const clientHold = bootOpts.client?.retain();
|
|
677
|
+
const settle = (): void => {
|
|
678
|
+
lease.release();
|
|
679
|
+
clientHold?.();
|
|
680
|
+
};
|
|
661
681
|
let streamOwnsLease = false;
|
|
662
682
|
try {
|
|
663
683
|
let events: AssistantMessageEventStream;
|
|
@@ -676,7 +696,7 @@ async function handlePiNative(
|
|
|
676
696
|
recordGatewayUsage(bootOpts.storage, model, client, message.usage, message.timestamp || undefined),
|
|
677
697
|
)
|
|
678
698
|
.catch(() => {})
|
|
679
|
-
.finally(
|
|
699
|
+
.finally(settle);
|
|
680
700
|
streamOwnsLease = true;
|
|
681
701
|
|
|
682
702
|
const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
|
|
@@ -698,7 +718,7 @@ async function handlePiNative(
|
|
|
698
718
|
},
|
|
699
719
|
});
|
|
700
720
|
} finally {
|
|
701
|
-
if (!streamOwnsLease)
|
|
721
|
+
if (!streamOwnsLease) settle();
|
|
702
722
|
}
|
|
703
723
|
}
|
|
704
724
|
|
|
@@ -811,8 +831,12 @@ function loggablePath(pathname: string): string {
|
|
|
811
831
|
|
|
812
832
|
/** The gateway's `/v1/*` routes, for a transport that has already admitted the caller. */
|
|
813
833
|
export interface AuthGatewayRouter {
|
|
814
|
-
/**
|
|
815
|
-
|
|
834
|
+
/**
|
|
835
|
+
* Answers one request; `peer` names the caller in logs, `client` is the
|
|
836
|
+
* scoped client the transport admitted, if any. Never rejects: a crashed
|
|
837
|
+
* route answers 500.
|
|
838
|
+
*/
|
|
839
|
+
route(req: Request, peer: string, client?: AuthGatewayClientScope): Promise<Response>;
|
|
816
840
|
/**
|
|
817
841
|
* Closes the retained provider session state (Codex WebSockets, GitLab Duo
|
|
818
842
|
* workflows), whose sockets and timers would otherwise keep the process alive.
|
|
@@ -820,19 +844,53 @@ export interface AuthGatewayRouter {
|
|
|
820
844
|
close(): void;
|
|
821
845
|
}
|
|
822
846
|
|
|
847
|
+
/** Why a scoped client cannot read `/v1/usage` or `/v1/credentials/check`: both cover every account the gateway holds. */
|
|
848
|
+
const GATEWAY_WIDE_ROUTE = "client tokens cannot read gateway-wide usage or credential health";
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Route options for a request from scoped `client`. Its models narrow
|
|
852
|
+
* `resolveModel` and `listModels`, so every route refuses a model outside them
|
|
853
|
+
* as unknown before any credential lookup; `client` itself binds the
|
|
854
|
+
* request's credential lookups (see `dispatch.ts`).
|
|
855
|
+
*/
|
|
856
|
+
function scopeRouteOptions(opts: AuthGatewayRouteOptions, client: AuthGatewayClientScope): AuthGatewayRouteOptions {
|
|
857
|
+
if (!client.limitsModels) return { ...opts, client };
|
|
858
|
+
const { resolveModel, listModels } = opts;
|
|
859
|
+
return {
|
|
860
|
+
...opts,
|
|
861
|
+
client,
|
|
862
|
+
resolveModel: id => {
|
|
863
|
+
const model = resolveModel(id);
|
|
864
|
+
if (model) {
|
|
865
|
+
if (client.allows(model)) return model;
|
|
866
|
+
// A provider-qualified id names one model: a refused one is never reread as another provider's bare id.
|
|
867
|
+
if (`${model.provider}/${model.id}` === id) return undefined;
|
|
868
|
+
}
|
|
869
|
+
// A bare id whose first match is refused resolves to the first served model of that id the client may call.
|
|
870
|
+
for (const candidate of listModels?.() ?? []) {
|
|
871
|
+
if (candidate.id === id && client.allows(candidate)) return candidate;
|
|
872
|
+
}
|
|
873
|
+
return undefined;
|
|
874
|
+
},
|
|
875
|
+
listModels: listModels && (() => [...listModels()].filter(model => client.allows(model))),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
823
879
|
/**
|
|
824
|
-
* The gateway's routes over `
|
|
880
|
+
* The gateway's routes over `hostOpts`, owning their per-session provider state:
|
|
825
881
|
* two routers in one process never share (or tear down) each other's.
|
|
826
882
|
*/
|
|
827
|
-
export function createAuthGatewayRouter(
|
|
883
|
+
export function createAuthGatewayRouter(hostOpts: AuthGatewayRouteOptions): AuthGatewayRouter {
|
|
828
884
|
const sessionStates = new AuthGatewaySessionStateStore();
|
|
829
|
-
const route = async (req: Request, peer: string): Promise<Response> => {
|
|
885
|
+
const route = async (req: Request, peer: string, client?: AuthGatewayClientScope): Promise<Response> => {
|
|
830
886
|
const pathname = new URL(req.url).pathname;
|
|
887
|
+
const opts = client ? scopeRouteOptions(hostOpts, client) : hostOpts;
|
|
831
888
|
try {
|
|
832
889
|
// Aggregated usage — backed by AuthStorage's 5-min per-credential cache.
|
|
833
890
|
// Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
|
|
834
891
|
// same client struct.
|
|
835
892
|
if (req.method === "GET" && pathname === "/v1/usage") {
|
|
893
|
+
if (client) return json(403, { error: GATEWAY_WIDE_ROUTE });
|
|
836
894
|
return await handleUsage(opts, req.signal);
|
|
837
895
|
}
|
|
838
896
|
|
|
@@ -840,6 +898,7 @@ export function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGate
|
|
|
840
898
|
// pool is producing 401s. Aggregated `/v1/usage` silently drops failed
|
|
841
899
|
// credentials, so we need a separate endpoint that captures errors.
|
|
842
900
|
if (req.method === "GET" && pathname === "/v1/credentials/check") {
|
|
901
|
+
if (client) return json(403, { error: GATEWAY_WIDE_ROUTE });
|
|
843
902
|
return await handleCredentialsCheck(opts, req.signal);
|
|
844
903
|
}
|
|
845
904
|
|
|
@@ -928,6 +987,10 @@ export function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGate
|
|
|
928
987
|
export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle {
|
|
929
988
|
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
|
|
930
989
|
const tokens = new Set<string>(opts.bearerTokens);
|
|
990
|
+
const clients = new AuthGatewayClientTable(opts.storage.sessions, tokens);
|
|
991
|
+
if (opts.clients) clients.set(opts.clients);
|
|
992
|
+
// A client table, even an empty one, keeps bearer auth on without a gateway token.
|
|
993
|
+
let requireBearer = tokens.size > 0 || opts.clients !== undefined;
|
|
931
994
|
const version = opts.version;
|
|
932
995
|
const router = createAuthGatewayRouter(opts);
|
|
933
996
|
|
|
@@ -947,19 +1010,34 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
947
1010
|
if (req.method === "GET" && pathname === "/healthz") {
|
|
948
1011
|
return withCors(json(200, { ok: true, version }), req);
|
|
949
1012
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1013
|
+
let client: AuthGatewayClientScope | undefined;
|
|
1014
|
+
let misplaceable: ReadonlySet<string> = tokens;
|
|
1015
|
+
if (requireBearer && !(tokens.size > 0 && isAuthorized(req, tokens))) {
|
|
1016
|
+
const bearer = readBearerToken(req);
|
|
1017
|
+
client = bearer === undefined ? undefined : clients.authenticate(bearer);
|
|
1018
|
+
if (bearer === undefined || client === undefined) {
|
|
1019
|
+
logger.info("auth-gateway request unauthorized", {
|
|
1020
|
+
method: req.method,
|
|
1021
|
+
path: loggablePath(pathname),
|
|
1022
|
+
peer: socketPeer,
|
|
1023
|
+
});
|
|
1024
|
+
return withCors(json(401, { error: "unauthorized" }), req);
|
|
1025
|
+
}
|
|
1026
|
+
// Client tokens are configured as digests; the presented one is the only one held in full.
|
|
1027
|
+
misplaceable = new Set([...tokens, bearer]);
|
|
957
1028
|
}
|
|
958
|
-
if (hasMisplacedBearer(req, url,
|
|
1029
|
+
if (hasMisplacedBearer(req, url, misplaceable)) {
|
|
959
1030
|
return withCors(json(400, { error: "gateway bearer token outside Authorization" }), req);
|
|
960
1031
|
}
|
|
961
1032
|
const peer = resolvePeer(req, socketPeer, opts.trustProxyHeaders);
|
|
962
|
-
return withCors(await router.route(req, peer), req);
|
|
1033
|
+
if (!client) return withCors(await router.route(req, peer), req);
|
|
1034
|
+
// Taken in the tick `authenticate` ran, so a reload cannot lift the restriction first.
|
|
1035
|
+
const release = client.retain();
|
|
1036
|
+
try {
|
|
1037
|
+
return withCors(await router.route(req, peer, client), req);
|
|
1038
|
+
} finally {
|
|
1039
|
+
release();
|
|
1040
|
+
}
|
|
963
1041
|
},
|
|
964
1042
|
// Max-out Bun's idle timeout. Long thinking-budget calls can sit idle
|
|
965
1043
|
// for minutes before the first token arrives; the default kills them.
|
|
@@ -978,6 +1056,11 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
978
1056
|
// sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
|
|
979
1057
|
// process can't settle until each one is closed.
|
|
980
1058
|
router.close();
|
|
1059
|
+
clients.close();
|
|
1060
|
+
},
|
|
1061
|
+
setClients: list => {
|
|
1062
|
+
clients.set(list);
|
|
1063
|
+
requireBearer = true;
|
|
981
1064
|
},
|
|
982
1065
|
};
|
|
983
1066
|
}
|
|
@@ -86,6 +86,12 @@ export interface AuthGatewaySessionStateRequest {
|
|
|
86
86
|
* resolves a sibling credential.
|
|
87
87
|
*/
|
|
88
88
|
account: string;
|
|
89
|
+
/**
|
|
90
|
+
* Name of the scoped gateway client that sent the request, if any. A
|
|
91
|
+
* client's conversations never share retained state with another client's,
|
|
92
|
+
* or with unscoped traffic, even when their keys or histories match.
|
|
93
|
+
*/
|
|
94
|
+
client?: string;
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
interface RetainedSession {
|
|
@@ -161,6 +167,14 @@ function closeSessionState(
|
|
|
161
167
|
* `sessionId` today.
|
|
162
168
|
*/
|
|
163
169
|
function sessionKeys(request: AuthGatewaySessionStateRequest): string[] {
|
|
170
|
+
const keys = conversationKeys(request);
|
|
171
|
+
const { client } = request;
|
|
172
|
+
// Scoped keys get their own leading tag, so none can equal an unscoped one.
|
|
173
|
+
return client === undefined ? keys : keys.map(key => `s\u0000${client}\u0000${key}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** {@link sessionKeys} of one conversation, before any client scope. */
|
|
177
|
+
function conversationKeys(request: AuthGatewaySessionStateRequest): string[] {
|
|
164
178
|
const { model } = request;
|
|
165
179
|
const scope = `${model.provider}\u0000${model.id}`;
|
|
166
180
|
if (request.clientKey !== undefined) return [`c\u0000${scope}\u0000${request.clientKey}`];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Effort } from "@linxiraos/pi-catalog/effort";
|
|
2
|
+
import type { AuthGatewayClient } from "./clients";
|
|
2
3
|
import type {
|
|
3
4
|
AssistantMessage,
|
|
4
5
|
AssistantMessageEventStream,
|
|
@@ -10,7 +11,7 @@ import type {
|
|
|
10
11
|
} from "../types";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Wire types for the
|
|
14
|
+
* Wire types for the zetacode auth-gateway.
|
|
14
15
|
*
|
|
15
16
|
* The gateway sits between unauthenticated clients (containerized zeta,
|
|
16
17
|
* llm-git, …) and the broker. It accepts provider-format HTTP requests
|
|
@@ -148,8 +149,14 @@ export interface AuthGatewayFormatModule {
|
|
|
148
149
|
export interface AuthGatewayServerOptions {
|
|
149
150
|
/** Listen address. Default `127.0.0.1:4000`. */
|
|
150
151
|
bind?: string;
|
|
151
|
-
/** Accept any of these bearer tokens. Empty allows unauthenticated calls. */
|
|
152
|
+
/** Accept any of these bearer tokens. Empty allows unauthenticated calls unless `clients` is set. */
|
|
152
153
|
bearerTokens: string[];
|
|
154
|
+
/**
|
|
155
|
+
* Scoped clients, each admitted by its own bearer and limited to its models
|
|
156
|
+
* and accounts (see `clients.ts`). Any table, even an empty one, requires a
|
|
157
|
+
* bearer on every request. Throws at startup when invalid.
|
|
158
|
+
*/
|
|
159
|
+
clients?: readonly AuthGatewayClient[];
|
|
153
160
|
/** Honor forwarded peer headers only when the connecting proxy is trusted. Default false. */
|
|
154
161
|
trustProxyHeaders?: boolean;
|
|
155
162
|
/** Version surfaced on `/healthz`. */
|
|
@@ -161,4 +168,11 @@ export interface AuthGatewayServerHandle {
|
|
|
161
168
|
port: number;
|
|
162
169
|
hostname: string;
|
|
163
170
|
close(): Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Replace the scoped clients. A removed or changed token is refused from
|
|
173
|
+
* the next request on; a request already in flight keeps the models and
|
|
174
|
+
* accounts it was admitted with, retries included, until it finishes.
|
|
175
|
+
* Throws, changing nothing, when the table is invalid. Turns bearer auth on.
|
|
176
|
+
*/
|
|
177
|
+
setClients(clients: readonly AuthGatewayClient[]): void;
|
|
164
178
|
}
|
package/src/dialect/coercion.ts
CHANGED
|
@@ -134,3 +134,19 @@ export function normalizeKimiFunctionName(rawId: string): string {
|
|
|
134
134
|
export function asRecord(value: unknown): Record<string, unknown> {
|
|
135
135
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract a COMPLETE tool name from a partial in-band JSON body.
|
|
140
|
+
* Returns undefined until the name string is fully closed, so streaming
|
|
141
|
+
* scanners never start a tool call under a truncated name.
|
|
142
|
+
*/
|
|
143
|
+
export function parseCompleteToolName(body: string): string | undefined {
|
|
144
|
+
const match = /^\s*\{\s*"name"\s*:\s*("(?:\\.|[^"\\])*")/.exec(body);
|
|
145
|
+
if (!match) return undefined;
|
|
146
|
+
try {
|
|
147
|
+
const name: unknown = JSON.parse(match[1]!);
|
|
148
|
+
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|