@linxiraos/pi-ai 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. 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, api: Api, signal: AbortSignal): SimpleStreamOptions {
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
- // Codex backend rejects every sampling control with
152
- // `Unsupported parameter: …` (#3117). Strip the full set for that one
153
- // provider; everything else is harmless to forward — `streamSimple` ignores
154
- // what the underlying provider doesn't honour.
155
- const isCodex = api === "openai-codex-responses";
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 && !isCodex) opts.temperature = options.temperature;
158
- if (options.topP !== undefined && !isCodex) opts.topP = options.topP;
159
- if (options.topK !== undefined && !isCodex) opts.topK = options.topK;
160
- if (options.minP !== undefined && !isCodex) opts.minP = options.minP;
161
- if (options.stopSequences !== undefined && !isCodex) opts.stopSequences = options.stopSequences;
162
- if (options.presencePenalty !== undefined && !isCodex) opts.presencePenalty = options.presencePenalty;
163
- if (options.frequencyPenalty !== undefined && !isCodex) opts.frequencyPenalty = options.frequencyPenalty;
164
- if (options.repetitionPenalty !== undefined && !isCodex) opts.repetitionPenalty = options.repetitionPenalty;
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.storage, model, sessionId, controller.signal, peer);
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.api, controller.signal);
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(bootOpts.storage, model.provider, sessionId, apiKey.apiKey),
384
+ account: resolveGatewayAccount(apiKey),
385
+ client: bootOpts.client?.name,
379
386
  });
380
387
  streamOpts.providerSessionState = lease.states;
381
388
  streamOpts.apiKey = buildGatewayApiKeyResolver(
382
- bootOpts.storage,
389
+ bootOpts,
383
390
  model,
384
391
  sessionId,
385
392
  apiKey,
386
393
  controller.signal,
387
394
  route.label,
388
395
  peer,
389
- resolvedKey =>
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 travels with the
446
- // event stream and is released when the turn settles. Until that handoff
447
- // happens, the `finally` below owns it.
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(() => lease.release());
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) lease.release();
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.storage, model, sessionId, controller.signal, peer);
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(bootOpts.storage, model.provider, sessionId, apiKey.apiKey),
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 codex sampling strip mirrors
575
- // `buildStreamOptions` — Codex rejects every one with a 400 (#3117).
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.storage,
598
+ bootOpts,
585
599
  model,
586
600
  sessionId,
587
601
  apiKey,
588
602
  controller.signal,
589
603
  "pi-native",
590
604
  peer,
591
- resolvedKey =>
592
- lease.updateAccount(resolveGatewayAccount(bootOpts.storage, model.provider, sessionId, resolvedKey)),
605
+ resolved => lease.updateAccount(resolveGatewayAccount(resolved)),
593
606
  );
594
- if (model.api === "openai-codex-responses") {
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 travels with the
659
- // event stream and is released when the turn settles. Until that handoff
660
- // happens, the `finally` below owns it.
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(() => lease.release());
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) lease.release();
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
- /** Answers one request; `peer` names the caller in logs. Never rejects: a crashed route answers 500. */
815
- route(req: Request, peer: string): Promise<Response>;
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 `opts`, owning their per-session provider state:
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(opts: AuthGatewayRouteOptions): AuthGatewayRouter {
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
- if (!isAuthorized(req, tokens)) {
951
- logger.info("auth-gateway request unauthorized", {
952
- method: req.method,
953
- path: loggablePath(pathname),
954
- peer: socketPeer,
955
- });
956
- return withCors(json(401, { error: "unauthorized" }), req);
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, tokens)) {
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 zeta-c auth-gateway.
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
  }
@@ -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
+ }
@@ -1,7 +1,7 @@
1
- import { parseJsonWithRepair, parseStreamingJson } from "@linxiraos/pi-utils";
1
+ import { parseJsonWithRepair } from "@linxiraos/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
4
- import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
+ import { asRecord, mintToolCallId, parseCompleteToolName, partialSuffixOverlapAny } from "./coercion";
5
5
  import dialectPrompt from "./hermes.md" with { type: "text" };
6
6
  import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
7
7
  import { TerminatorWait } from "./terminator-wait";
@@ -137,15 +137,11 @@ export class HermesInbandScanner implements InbandScanner {
137
137
  }
138
138
 
139
139
  #tryStart(body: string, events: InbandScanEvent[]): void {
140
- try {
141
- const partial = parseStreamingJson<{ name?: unknown }>(body);
142
- if (typeof partial.name !== "string" || partial.name.length === 0) return;
143
- this.#name = partial.name;
144
- this.#started = true;
145
- events.push({ type: "toolStart", id: this.#id, name: this.#name });
146
- } catch {
147
- // Partial JSON is allowed until the closing tag arrives.
148
- }
140
+ const name = parseCompleteToolName(body);
141
+ if (!name) return;
142
+ this.#name = name;
143
+ this.#started = true;
144
+ events.push({ type: "toolStart", id: this.#id, name: this.#name });
149
145
  }
150
146
 
151
147
  #parseCall(body: string): { name: string; arguments: Record<string, unknown> } | undefined {
@@ -1,7 +1,7 @@
1
1
  import { parseJsonWithRepair } from "@linxiraos/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
4
- import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
+ import { asRecord, mintToolCallId, parseCompleteToolName, partialSuffixOverlapAny } from "./coercion";
5
5
  import dialectPrompt from "./qwen3.md" with { type: "text" };
6
6
  import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
7
7
  import { TerminatorWait } from "./terminator-wait";
@@ -22,7 +22,6 @@ const THINK_CLOSE = "</think>";
22
22
  const TOOL_START_TAGS = [TOOL_OPEN] as const;
23
23
  const START_TAGS = [TOOL_OPEN, THINK_OPEN] as const;
24
24
  const THINK_CLOSE_TAGS = [THINK_CLOSE] as const;
25
- const COMPLETE_NAME = /^\s*\{\s*"name"\s*:\s*("(?:\\.|[^"\\])*")/;
26
25
 
27
26
  type State = "outside" | "thinking" | "tool";
28
27
 
@@ -167,15 +166,8 @@ export class Qwen3InbandScanner implements InbandScanner {
167
166
  }
168
167
 
169
168
  #tryStart(body: string, events: InbandScanEvent[]): void {
170
- const nameMatch = COMPLETE_NAME.exec(body);
171
- if (!nameMatch) return;
172
- let name: unknown;
173
- try {
174
- name = JSON.parse(nameMatch[1]!);
175
- } catch {
176
- return;
177
- }
178
- if (typeof name !== "string" || name.length === 0) return;
169
+ const name = parseCompleteToolName(body);
170
+ if (!name) return;
179
171
  this.#name = name;
180
172
  this.#started = true;
181
173
  events.push({ type: "toolStart", id: this.#id, name: this.#name });
@@ -1,5 +1,6 @@
1
1
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
2
2
  import { AbortError } from "./abort";
3
+ import { networkFailureMessage } from "./network-cause";
3
4
 
4
5
  /** Prefix on errors raised when an Anthropic SSE stream envelope is malformed. */
5
6
  export const STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:";
@@ -202,8 +203,8 @@ export class AnthropicApiError extends ProviderHttpError {
202
203
 
203
204
  /** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */
204
205
  export class AnthropicConnectionError extends Error {
205
- constructor(cause: unknown) {
206
- super("Connection error.", { cause });
206
+ constructor(cause: unknown, requestUrl?: string) {
207
+ super(networkFailureMessage(cause, requestUrl) ?? "Connection error.", { cause });
207
208
  this.name = "AnthropicConnectionError";
208
209
  }
209
210
  }
@@ -217,8 +217,17 @@ export function isCodexSteerRejection(message: { api?: Api; errorMessage?: strin
217
217
  );
218
218
  }
219
219
 
220
+ /**
221
+ * Transport-failure wording that marks an error transient. Covers
222
+ * diagnostics forwarded verbatim by gateways/proxies in other runtimes, e.g.
223
+ * undici's "other side closed" and Go's `net.ErrClosed` ("use of closed
224
+ * network connection", #15385). Also covers OMP's own network-failure
225
+ * sentences from `networkFailureMessage` (offline / unreachable / refused /
226
+ * TLS), which must stay at least as retryable as the bare `Connection error.`
227
+ * text they replaced.
228
+ */
220
229
  export const TRANSIENT_TRANSPORT_PATTERN =
221
- /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
230
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|temporar(?:y|ily)[ _-]?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|use of closed network connection|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|\bE(?:TIMEOUT|NOTFOUND|NETUNREACH|HOSTUNREACH)\b|\bFailedToOpenSocket\b|no internet connection|couldn.?t reach|couldn.?t connect to|tls certificate verification failed|malformed.?function.?call/i;
222
231
  const AUTH_FAILURE_PATTERN =
223
232
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
224
233
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -237,6 +246,18 @@ export function isAnthropicAccountPolicyText(text: string, provider?: string, st
237
246
  return ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(text);
238
247
  }
239
248
 
249
+ const IMMUTABLE_LATEST_THINKING_PATTERN =
250
+ /messages\.\d+\.content\.\d+.*\b(?:thinking|redacted_thinking)\b.*\blatest assistant message cannot be modified\b/is;
251
+
252
+ /**
253
+ * Anthropic's rejection of a replayed latest assistant turn whose `thinking` or
254
+ * `redacted_thinking` blocks no longer verify. Resending the same turn fails the
255
+ * same way; only a request that changes that turn's reasoning can succeed.
256
+ */
257
+ export function isImmutableLatestThinkingText(text: string): boolean {
258
+ return IMMUTABLE_LATEST_THINKING_PATTERN.test(text);
259
+ }
260
+
240
261
  const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
241
262
  /\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
242
263
  const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
@@ -383,7 +404,7 @@ const CACHE_CONTROL_EMPTY_TEXT_PATTERN =
383
404
  const OAUTH_DEFINITIVE_FAILURE_PATTERN =
384
405
  /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
385
406
  const OAUTH_TRANSIENT_FAILURE_PATTERN =
386
- /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
407
+ /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|ETIMEOUT|ENOTFOUND|ENETUNREACH|EHOSTUNREACH|FailedToOpenSocket|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
387
408
  const OAUTH_HTTP_AUTH_PATTERN = /\b401\b/;
388
409
 
389
410
  function matchesStrictToolsRejection(message: string, errorStatus: number | undefined): boolean {
@@ -1082,7 +1103,7 @@ export function isTransientStreamParseError(error: unknown): boolean {
1082
1103
  * non-terminal status treat it as transient (#11805).
1083
1104
  */
1084
1105
  const STREAM_DROP_PATTERN =
1085
- /stream disconnected before completion|stream closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1106
+ /stream disconnected before completion|(?:stream|websocket) closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1086
1107
 
1087
1108
  /**
1088
1109
  * Transport stream-drop diagnostic (see {@link STREAM_DROP_PATTERN}). Unlike
@@ -8,6 +8,7 @@ export * from "./finalize";
8
8
  export * from "./flags";
9
9
  export * from "./format";
10
10
  export * from "./gateway";
11
+ export * from "./network-cause";
11
12
  export * from "./oauth";
12
13
  export * from "./provider";
13
14
  export * from "./rate-limit";