@gajae-code/ai 0.15.5 → 0.16.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 (88) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/types/adapter-internals/aws-region.d.ts +7 -0
  3. package/dist/types/auth-broker/client.d.ts +6 -2
  4. package/dist/types/auth-broker/remote-store.d.ts +14 -2
  5. package/dist/types/auth-broker/types.d.ts +6 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +19 -0
  7. package/dist/types/auth-gateway/server.d.ts +39 -5
  8. package/dist/types/auth-gateway/types.d.ts +16 -2
  9. package/dist/types/auth-storage.d.ts +92 -24
  10. package/dist/types/core.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/provider-models/openai-compat.d.ts +1 -0
  13. package/dist/types/providers/anthropic.d.ts +1 -1
  14. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  15. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  16. package/dist/types/providers/register-builtins.d.ts +12 -12
  17. package/dist/types/stream.d.ts +2 -1
  18. package/dist/types/types.d.ts +22 -2
  19. package/dist/types/utils/oauth/api-key-login.d.ts +4 -1
  20. package/dist/types/utils/oauth/api-key-validation.d.ts +12 -6
  21. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  22. package/dist/types/utils/oauth/types.d.ts +1 -1
  23. package/dist/types/utils/retry.d.ts +2 -0
  24. package/dist/types/utils/schema/normalize.d.ts +0 -5
  25. package/dist/types/utils/sqlite-errors.d.ts +4 -0
  26. package/package.json +3 -3
  27. package/src/adapter-internals/aws-region.d.ts +7 -0
  28. package/src/adapter-internals/aws-region.ts +14 -0
  29. package/src/auth-broker/client.ts +41 -13
  30. package/src/auth-broker/redact.ts +25 -1
  31. package/src/auth-broker/remote-store.ts +374 -115
  32. package/src/auth-broker/server.ts +131 -91
  33. package/src/auth-broker/types.ts +6 -0
  34. package/src/auth-broker/wire-schemas.ts +6 -0
  35. package/src/auth-gateway/server.ts +447 -79
  36. package/src/auth-gateway/types.ts +28 -2
  37. package/src/auth-storage.ts +672 -168
  38. package/src/cli.ts +1 -0
  39. package/src/core.ts +1 -0
  40. package/src/index.ts +1 -0
  41. package/src/model-thinking.ts +8 -0
  42. package/src/models.json +1224 -3
  43. package/src/provider-models/descriptors.ts +3 -1
  44. package/src/provider-models/openai-compat.ts +102 -1
  45. package/src/providers/amazon-bedrock.ts +5 -1
  46. package/src/providers/anthropic.d.ts +1 -1
  47. package/src/providers/anthropic.ts +8 -2
  48. package/src/providers/aws-credentials.ts +6 -0
  49. package/src/providers/azure-openai-responses.ts +4 -1
  50. package/src/providers/cursor.ts +256 -101
  51. package/src/providers/gitlab-duo.ts +18 -1
  52. package/src/providers/google-gemini-cli.ts +3 -0
  53. package/src/providers/google-gemini-headers.d.ts +1 -1
  54. package/src/providers/google-gemini-headers.ts +1 -1
  55. package/src/providers/google-shared.ts +3 -0
  56. package/src/providers/kiro-api-key.ts +33 -8
  57. package/src/providers/kiro-codewhisperer.ts +28 -9
  58. package/src/providers/ollama.ts +3 -0
  59. package/src/providers/openai-codex-responses.d.ts +6 -0
  60. package/src/providers/openai-codex-responses.ts +37 -8
  61. package/src/providers/openai-completions.ts +11 -1
  62. package/src/providers/openai-responses.ts +10 -1
  63. package/src/providers/pi-native-client.ts +25 -1
  64. package/src/providers/pi-native-server.ts +24 -0
  65. package/src/providers/register-builtins.d.ts +12 -12
  66. package/src/providers/register-builtins.ts +16 -3
  67. package/src/stream.d.ts +2 -1
  68. package/src/stream.ts +175 -67
  69. package/src/types.d.ts +22 -2
  70. package/src/types.ts +27 -1
  71. package/src/utils/oauth/api-key-login.ts +13 -2
  72. package/src/utils/oauth/api-key-validation.ts +242 -41
  73. package/src/utils/oauth/commandcode.ts +17 -0
  74. package/src/utils/oauth/index.ts +20 -5
  75. package/src/utils/oauth/kiro.ts +91 -22
  76. package/src/utils/oauth/types.d.ts +1 -1
  77. package/src/utils/oauth/types.ts +1 -0
  78. package/src/utils/retry.d.ts +2 -0
  79. package/src/utils/retry.ts +15 -2
  80. package/src/utils/schema/dereference.ts +169 -49
  81. package/src/utils/schema/draft.ts +46 -23
  82. package/src/utils/schema/normalize.d.ts +0 -5
  83. package/src/utils/schema/normalize.ts +396 -119
  84. package/src/utils/schema/types.ts +3 -1
  85. package/src/utils/schema/zod-decontaminate.ts +83 -29
  86. package/src/utils/sqlite-errors.d.ts +4 -0
  87. package/src/utils/sqlite-errors.ts +13 -0
  88. package/src/utils/tool-choice-capability.ts +2 -3
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * gjc auth-gateway HTTP server.
3
3
  *
4
- * Accepts any provider-format request (OpenAI chat-completions, Anthropic
4
+ * Accepts a provider-scoped provider-format request (OpenAI chat-completions, Anthropic
5
5
  * messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
6
6
  * — which handles credential injection, anthropic-beta headers, OpenAI code backend
7
7
  * websocket transport, and all the per-provider intricacies. The gateway is
@@ -12,7 +12,7 @@
12
12
  * GET /healthz → unauth; ok + version
13
13
  * GET /v1/usage → aggregated provider usage (5-min per-credential cache via AuthStorage)
14
14
  * GET /v1/credentials/check → per-credential auth probe (diagnose 401s in a multi-account pool)
15
- * GET /v1/models → list known models from the registry
15
+ * GET /v1/models → list models from the selected provider scope
16
16
  * POST /v1/chat/completions → OpenAI chat-completions in/out
17
17
  * POST /v1/messages → Anthropic messages in/out
18
18
  * POST /v1/responses → OpenAI Responses in/out
@@ -32,8 +32,10 @@ import type {
32
32
  AssistantMessage,
33
33
  AssistantMessageEvent,
34
34
  AssistantMessageEventStream,
35
+ AuthRetryCredential,
35
36
  Context,
36
37
  Model,
38
+ Provider,
37
39
  SimpleStreamOptions,
38
40
  } from "../types";
39
41
  import { beginAttempt, classifyFallbackTrigger } from "../utils/fallback-transport";
@@ -53,7 +55,7 @@ import type {
53
55
  AuthGatewayFormatModule as FormatModule,
54
56
  AuthGatewayParsedRequest as ParsedFormatRequest,
55
57
  } from "./types";
56
- import { DEFAULT_AUTH_GATEWAY_BIND } from "./types";
58
+ import { AUTH_GATEWAY_PROVIDER_APIS, DEFAULT_AUTH_GATEWAY_BIND } from "./types";
57
59
 
58
60
  // ParsedFormatRequest / ParsedFormatOptions / FormatModule come from ./types.
59
61
 
@@ -62,14 +64,267 @@ export type ModelResolver = (modelId: string) => Model<Api> | undefined;
62
64
  export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
63
65
  /** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
64
66
  storage: AuthStorage;
67
+ /**
68
+ * Current broker-backed scope authority. When supplied, this is checked on
69
+ * every request so a live broker snapshot removal immediately fails closed.
70
+ */
71
+ hasProviderCredential: () => boolean;
72
+ /** Refresh the dispatch cache from the current broker snapshot before use. */
73
+ reloadProviderCredentials: (signal?: AbortSignal) => Promise<void>;
74
+ /** Confirm that the selected key is still present in the current authority snapshot. */
75
+ validateProviderCredential: (provider: string, apiKey: string) => boolean;
65
76
  /**
66
77
  * Resolve a client-requested model id to a pi-ai Model. Caller supplies
67
78
  * this from a ModelRegistry (lives in `coding-agent` to avoid an inverse
68
79
  * dependency in `pi-ai`).
69
80
  */
70
81
  resolveModel: ModelResolver;
71
- /** Optional supplier for `/v1/models` listing. Returns the full model array. */
72
- listModels?: () => Iterable<Model<Api>>;
82
+ /** Supplier for the source-backed model catalog used by `/v1/models`. */
83
+ listModels: () => Iterable<Model<Api>>;
84
+ }
85
+
86
+ export interface AuthGatewayModelCatalog {
87
+ readonly models: readonly Model<Api>[];
88
+ resolve(modelId: string): Model<Api> | undefined;
89
+ }
90
+
91
+ function modelApiForProvider(provider: Provider): Api | undefined {
92
+ return AUTH_GATEWAY_PROVIDER_APIS[provider];
93
+ }
94
+
95
+ /**
96
+ * Whether a model can be served through the broker-backed auth gateway.
97
+ *
98
+ * Bedrock's credential chain is process-local AWS authority, not a broker
99
+ * credential. Advertising a native Bedrock model from this gateway would let
100
+ * direct callers bypass the broker boundary (and make readiness lie about a
101
+ * model the gateway cannot authenticate). Keep this predicate shared with the
102
+ * CLI readiness checks so every entry point applies the same fence.
103
+ */
104
+ export function isAuthGatewayModelBrokerConsumable(model: Pick<Model<Api>, "api" | "transport">): boolean {
105
+ return (
106
+ model.api !== "bedrock-converse-stream" &&
107
+ model.api !== "google-vertex" &&
108
+ model.api !== "kiro-codewhisperer-stream" &&
109
+ model.transport !== "pi-native"
110
+ );
111
+ }
112
+
113
+ function isModelInProviderScope(model: Model<Api>, provider: Provider): boolean {
114
+ if (model.provider !== provider) return false;
115
+ const expectedApi = modelApiForProvider(provider);
116
+ return expectedApi === undefined || model.api === expectedApi;
117
+ }
118
+
119
+ /**
120
+ * Build an unambiguous, provider-scoped catalog.
121
+ *
122
+ * Models from other providers are intentionally ignored rather than allowed
123
+ * to compete for the same id. Duplicate ids within the selected provider are
124
+ * rejected because choosing either one would make request dispatch
125
+ * order-dependent.
126
+ */
127
+ export function createAuthGatewayModelCatalog(
128
+ provider: Provider,
129
+ models: Iterable<Model<Api>>,
130
+ ): AuthGatewayModelCatalog {
131
+ const byId = new Map<string, Model<Api>>();
132
+ for (const model of models) {
133
+ if (!isAuthGatewayModelBrokerConsumable(model)) continue;
134
+ if (!isModelInProviderScope(model, provider)) continue;
135
+ if (byId.has(model.id)) {
136
+ throw new Error(`Ambiguous auth-gateway model id ${model.id} for provider ${provider}`);
137
+ }
138
+ byId.set(model.id, model);
139
+ }
140
+ const scopedModels = [...byId.values()];
141
+ return {
142
+ models: scopedModels,
143
+ resolve: (modelId: string) => byId.get(modelId),
144
+ };
145
+ }
146
+
147
+ function resolveScopedModel(
148
+ opts: AuthGatewayBootOptions,
149
+ catalog: AuthGatewayModelCatalog,
150
+ modelId: string,
151
+ ): Model<Api> | undefined {
152
+ const catalogModel = catalog.resolve(modelId);
153
+ if (!catalogModel) return undefined;
154
+ const resolved = opts.resolveModel(modelId);
155
+ if (
156
+ !resolved ||
157
+ resolved !== catalogModel ||
158
+ resolved.id !== catalogModel.id ||
159
+ resolved.api !== catalogModel.api ||
160
+ !isModelInProviderScope(resolved, opts.providerScope.provider)
161
+ ) {
162
+ return undefined;
163
+ }
164
+ return catalogModel;
165
+ }
166
+
167
+ function hasProviderCredential(opts: AuthGatewayBootOptions): boolean {
168
+ return opts.hasProviderCredential();
169
+ }
170
+
171
+ type ProviderScopeAvailability = "available" | "absent" | "reload_failed";
172
+
173
+ async function providerScopeAvailability(
174
+ opts: AuthGatewayBootOptions,
175
+ signal?: AbortSignal,
176
+ ): Promise<ProviderScopeAvailability> {
177
+ try {
178
+ await opts.reloadProviderCredentials(signal);
179
+ } catch (error) {
180
+ logger.warn("auth-gateway provider snapshot reload failed", {
181
+ provider: opts.providerScope.provider,
182
+ error: cleanReason(error) ?? "snapshot reload failed",
183
+ });
184
+ return "reload_failed";
185
+ }
186
+ return hasProviderCredential(opts) ? "available" : "absent";
187
+ }
188
+
189
+ class GatewayCredentialError extends Error {
190
+ readonly status: number;
191
+ readonly type: string;
192
+
193
+ constructor(status: number, type: string, message: string) {
194
+ super(message);
195
+ this.name = "GatewayCredentialError";
196
+ this.status = status;
197
+ this.type = type;
198
+ }
199
+ }
200
+
201
+ const credentialAuthorityTails = new WeakMap<AuthGatewayBootOptions, Promise<void>>();
202
+
203
+ interface GatewayCredentialLease {
204
+ apiKey: string;
205
+ release(): void;
206
+ }
207
+
208
+ export function releaseGatewayCredentialLeaseOnAdmission(
209
+ events: Pick<AssistantMessageEventStream, "result">,
210
+ release: () => void,
211
+ signal?: AbortSignal,
212
+ ): void {
213
+ let released = false;
214
+ const releaseOnce = (): void => {
215
+ if (released) return;
216
+ released = true;
217
+ signal?.removeEventListener("abort", releaseOnce);
218
+ release();
219
+ };
220
+ if (signal?.aborted) {
221
+ releaseOnce();
222
+ return;
223
+ }
224
+ signal?.addEventListener("abort", releaseOnce, { once: true });
225
+ // A deferred provider import can fail before the admission hook runs. Do
226
+ // not strand the authority lease in that case, but never wait for a
227
+ // successful stream's full response lifetime.
228
+ void events.result().then(releaseOnce, releaseOnce);
229
+ }
230
+
231
+ async function resolveGatewayApiKey(
232
+ opts: AuthGatewayBootOptions,
233
+ model: Model<Api>,
234
+ peer: string,
235
+ signal: AbortSignal,
236
+ ): Promise<string> {
237
+ try {
238
+ const scopeAvailability = await providerScopeAvailability(opts, signal);
239
+ if (scopeAvailability === "reload_failed") {
240
+ throw new GatewayCredentialError(503, "upstream_error", "Auth broker unavailable");
241
+ }
242
+ if (scopeAvailability !== "available") {
243
+ throw new GatewayCredentialError(
244
+ 401,
245
+ "authentication_error",
246
+ `No credential available for provider ${model.provider}`,
247
+ );
248
+ }
249
+ const apiKey = await opts.storage.getApiKey(model.provider, undefined, { modelId: model.id, signal });
250
+ if (!apiKey || !opts.validateProviderCredential(model.provider, apiKey) || !hasProviderCredential(opts)) {
251
+ throw new GatewayCredentialError(
252
+ 401,
253
+ "authentication_error",
254
+ `No credential available for provider ${model.provider}`,
255
+ );
256
+ }
257
+ return apiKey;
258
+ } catch (error) {
259
+ if (error instanceof GatewayCredentialError) throw error;
260
+ const classified = classifyGatewayError(error);
261
+ logger.warn("auth-gateway getApiKey threw", {
262
+ provider: model.provider,
263
+ peer,
264
+ error: classified.message,
265
+ });
266
+ throw new GatewayCredentialError(classified.status, classified.type, classified.message);
267
+ }
268
+ }
269
+
270
+ async function acquireGatewayApiKey(
271
+ opts: AuthGatewayBootOptions,
272
+ model: Model<Api>,
273
+ peer: string,
274
+ signal: AbortSignal,
275
+ ): Promise<GatewayCredentialLease> {
276
+ const previous = credentialAuthorityTails.get(opts) ?? Promise.resolve();
277
+ const deferred = Promise.withResolvers<void>();
278
+ const tail = previous.then(
279
+ () => deferred.promise,
280
+ () => deferred.promise,
281
+ );
282
+ credentialAuthorityTails.set(opts, tail);
283
+ let released = false;
284
+ const release = (): void => {
285
+ if (released) return;
286
+ released = true;
287
+ deferred.resolve();
288
+ };
289
+ void tail.then(() => {
290
+ if (credentialAuthorityTails.get(opts) === tail) credentialAuthorityTails.delete(opts);
291
+ });
292
+ try {
293
+ if (signal.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
294
+ const abort = Promise.withResolvers<never>();
295
+ const onAbort = (): void =>
296
+ abort.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
297
+ signal.addEventListener("abort", onAbort, { once: true });
298
+ try {
299
+ await Promise.race([previous, abort.promise]);
300
+ } finally {
301
+ signal.removeEventListener("abort", onAbort);
302
+ }
303
+ const apiKey = await resolveGatewayApiKey(opts, model, peer, signal);
304
+ const dispatchTicket = await opts.storage.acquireCredentialDispatchTicket?.(model.provider, signal);
305
+ if (!opts.validateProviderCredential(model.provider, apiKey) || !hasProviderCredential(opts)) {
306
+ dispatchTicket?.release();
307
+ throw new GatewayCredentialError(
308
+ 401,
309
+ "authentication_error",
310
+ `No credential available for provider ${model.provider}`,
311
+ );
312
+ }
313
+ return {
314
+ apiKey,
315
+ release: () => {
316
+ // The store-owned ticket orders remote snapshot authority against
317
+ // provider admission; the gateway tail independently orders local
318
+ // acquisitions without serializing response lifetimes.
319
+ dispatchTicket?.release();
320
+ release();
321
+ },
322
+ };
323
+ } catch (error) {
324
+ release();
325
+ if (error instanceof GatewayCredentialError) throw error;
326
+ throw error;
327
+ }
73
328
  }
74
329
 
75
330
  // `parseBind` lives in ../utils/parse-bind so the gateway and broker can't
@@ -134,7 +389,15 @@ function deriveSessionId(modelId: string, context: Context): string {
134
389
  }
135
390
 
136
391
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
137
- const opts: SimpleStreamOptions = { signal };
392
+ // Gateway authority is acquired once per managed attempt. Provider-internal
393
+ // retries would otherwise resend a captured credential after the broker lease
394
+ // is released; replacement attempts must flow through onAuthError instead.
395
+ const opts: SimpleStreamOptions = {
396
+ signal,
397
+ requestMaxRetries: 0,
398
+ streamMaxRetries: 0,
399
+ disableProviderRetries: true,
400
+ };
138
401
  const { options } = parsed;
139
402
  // OpenAI code backend backend rejects `temperature` / `top_p` (per-model defaults only),
140
403
  // so we drop them silently for that one provider. Every other unsupported
@@ -199,7 +462,7 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
199
462
  previousResponseId: options.previousResponseId,
200
463
  seed: options.seed,
201
464
  hasLogitBias: options.logitBias !== undefined,
202
- user: options.user,
465
+ hasUser: options.user !== undefined,
203
466
  hasResponseFormat: options.responseFormat !== undefined,
204
467
  });
205
468
  }
@@ -295,7 +558,7 @@ function redactGatewayStream(events: AssistantMessageEventStream): AssistantMess
295
558
  }
296
559
 
297
560
  async function refreshGatewayApiKeyAfterAuthError(
298
- storage: AuthStorage,
561
+ opts: AuthGatewayBootOptions,
299
562
  model: Model<Api>,
300
563
  provider: string,
301
564
  oldKey: string,
@@ -303,15 +566,29 @@ async function refreshGatewayApiKeyAfterAuthError(
303
566
  signal: AbortSignal,
304
567
  format: string,
305
568
  peer: string,
306
- ): Promise<string | undefined> {
307
- await storage.invalidateCredentialMatching(provider, oldKey, signal);
569
+ ): Promise<AuthRetryCredential | undefined> {
570
+ await opts.storage.invalidateCredentialMatching(provider, oldKey, signal);
308
571
  logger.debug("auth-gateway retrying provider request after credential invalidation", {
309
572
  format,
310
573
  provider,
311
574
  peer,
312
575
  error: cleanReason(error) ?? "Upstream request failed",
313
576
  });
314
- return storage.getApiKey(provider, undefined, { modelId: model.id, signal });
577
+ try {
578
+ const lease = await acquireGatewayApiKey(opts, model, peer, signal);
579
+ return { apiKey: lease.apiKey, onStreamCreated: lease.release } satisfies AuthRetryCredential;
580
+ } catch (resolutionError) {
581
+ if (resolutionError instanceof GatewayCredentialError) {
582
+ logger.debug("auth-gateway has no broker-authorized replacement credential", {
583
+ format,
584
+ provider,
585
+ peer,
586
+ status: resolutionError.status,
587
+ });
588
+ return undefined;
589
+ }
590
+ throw resolutionError;
591
+ }
315
592
  }
316
593
 
317
594
  /**
@@ -423,6 +700,7 @@ function mirrorRequestAbort(req: Request): AbortController {
423
700
  async function handleFormatEndpoint(
424
701
  route: { module: FormatModule; label: string },
425
702
  bootOpts: AuthGatewayBootOptions,
703
+ catalog: AuthGatewayModelCatalog,
426
704
  req: Request,
427
705
  peer: string,
428
706
  ): Promise<Response> {
@@ -453,36 +731,11 @@ async function handleFormatEndpoint(
453
731
  return route.module.formatError(400, "invalid_request_error", "Missing top-level `model` field");
454
732
  }
455
733
 
456
- const model = bootOpts.resolveModel(modelId);
734
+ const model = resolveScopedModel(bootOpts, catalog, modelId);
457
735
  if (!model) {
458
736
  return route.module.formatError(404, "invalid_request_error", `Unknown model: ${modelId}`);
459
737
  }
460
738
 
461
- // pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
462
- // expected to resolve the credential and pass it as `options.apiKey`.
463
- // For OAuth providers this returns the access token (refreshed via the
464
- // broker override on AuthStorage when needed).
465
- let apiKey: string | undefined;
466
- try {
467
- apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, {
468
- modelId: model.id,
469
- signal: controller.signal,
470
- });
471
- } catch (error) {
472
- if (controller.signal.aborted) return clientClosedResponse(route);
473
- const classified = classifyGatewayError(error);
474
- logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
475
- return route.module.formatError(classified.status, classified.type, classified.message);
476
- }
477
- if (controller.signal.aborted) return clientClosedResponse(route);
478
- if (!apiKey) {
479
- return route.module.formatError(
480
- 401,
481
- "authentication_error",
482
- `No credential available for provider ${model.provider}`,
483
- );
484
- }
485
-
486
739
  // Parse + validate against the strict format schema, rebuild as gjc's
487
740
  // canonical Context, dispatch through pi-ai's streamSimple, encode the
488
741
  // canonical event stream back to the inbound format. There is no
@@ -508,13 +761,12 @@ async function handleFormatEndpoint(
508
761
  if (controller.signal.aborted) return clientClosedResponse(route);
509
762
 
510
763
  const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
511
- streamOpts.apiKey = apiKey;
512
764
  if (streamOpts.fallbackManaged) {
513
765
  streamOpts.fallbackAttempt = beginAttempt(model.id, "auth-gateway");
514
766
  } else {
515
767
  streamOpts.onAuthError = (provider, oldKey, error) =>
516
768
  refreshGatewayApiKeyAfterAuthError(
517
- bootOpts.storage,
769
+ bootOpts,
518
770
  model,
519
771
  provider,
520
772
  oldKey,
@@ -534,11 +786,36 @@ async function handleFormatEndpoint(
534
786
  peer,
535
787
  });
536
788
 
537
- let events: AssistantMessageEventStream;
789
+ let apiKey: string;
790
+ let credentialLease: GatewayCredentialLease;
538
791
  try {
792
+ credentialLease = await acquireGatewayApiKey(bootOpts, model, peer, controller.signal);
793
+ apiKey = credentialLease.apiKey;
794
+ streamOpts.apiKey = apiKey;
795
+ } catch (error) {
539
796
  if (controller.signal.aborted) return clientClosedResponse(route);
797
+ if (error instanceof GatewayCredentialError) {
798
+ return route.module.formatError(error.status, error.type, error.message);
799
+ }
800
+ throw error;
801
+ }
802
+
803
+ let events: AssistantMessageEventStream;
804
+ let releasedAtAdmission = false;
805
+ const releaseAtAdmission = (): void => {
806
+ if (releasedAtAdmission) return;
807
+ releasedAtAdmission = true;
808
+ credentialLease.release();
809
+ };
810
+ streamOpts.onStreamCreated = releaseAtAdmission;
811
+ try {
812
+ if (controller.signal.aborted) {
813
+ credentialLease.release();
814
+ return clientClosedResponse(route);
815
+ }
540
816
  events = streamSimple(model, parsed.context, streamOpts);
541
817
  } catch (error) {
818
+ credentialLease.release();
542
819
  if (streamOpts.fallbackManaged) {
543
820
  await markManagedGatewayCredentialFailure(
544
821
  bootOpts.storage,
@@ -554,6 +831,7 @@ async function handleFormatEndpoint(
554
831
  logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
555
832
  return route.module.formatError(classified.status, classified.type, classified.message);
556
833
  }
834
+ releaseGatewayCredentialLeaseOnAdmission(events, releaseAtAdmission, controller.signal);
557
835
  if (streamOpts.fallbackManaged) {
558
836
  events = observeManagedGatewayFailure(events, error =>
559
837
  markManagedGatewayCredentialFailure(
@@ -633,7 +911,12 @@ async function handleFormatEndpoint(
633
911
  * `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
634
912
  * path.
635
913
  */
636
- async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
914
+ async function handlePiNative(
915
+ bootOpts: AuthGatewayBootOptions,
916
+ catalog: AuthGatewayModelCatalog,
917
+ req: Request,
918
+ peer: string,
919
+ ): Promise<Response> {
637
920
  const controller = mirrorRequestAbort(req);
638
921
  const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
639
922
  if (controller.signal.aborted) return aborted();
@@ -660,43 +943,28 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
660
943
  return piNative.formatError(400, "invalid_request_error", message);
661
944
  }
662
945
 
663
- const model = bootOpts.resolveModel(parsed.modelId);
946
+ const model = resolveScopedModel(bootOpts, catalog, parsed.modelId);
664
947
  if (!model) {
665
948
  return piNative.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
666
949
  }
667
950
 
668
- let apiKey: string | undefined;
669
- try {
670
- apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, {
671
- modelId: model.id,
672
- signal: controller.signal,
673
- });
674
- } catch (error) {
675
- if (controller.signal.aborted) return aborted();
676
- const classified = classifyGatewayError(error);
677
- logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
678
- return piNative.formatError(classified.status, classified.type, classified.message);
679
- }
680
- if (controller.signal.aborted) return aborted();
681
- if (!apiKey) {
682
- return piNative.formatError(
683
- 401,
684
- "authentication_error",
685
- `No credential available for provider ${model.provider}`,
686
- );
687
- }
688
-
689
951
  // Build the SimpleStreamOptions actually handed to `streamSimple`. We
690
952
  // trust the client's options (already allow-listed by `parseRequest`) and
691
953
  // only inject server-controlled fields. The OpenAI code backend temperature/topP strip
692
954
  // matches `buildStreamOptions` — OpenAI code backend rejects them with a 400.
693
- const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
955
+ const streamOpts: SimpleStreamOptions = {
956
+ ...parsed.options,
957
+ signal: controller.signal,
958
+ requestMaxRetries: 0,
959
+ streamMaxRetries: 0,
960
+ disableProviderRetries: true,
961
+ };
694
962
  if (streamOpts.fallbackManaged) {
695
963
  streamOpts.fallbackAttempt = beginAttempt(model.id, "auth-gateway-pi-native");
696
964
  } else {
697
965
  streamOpts.onAuthError = (provider, oldKey, error) =>
698
966
  refreshGatewayApiKeyAfterAuthError(
699
- bootOpts.storage,
967
+ bootOpts,
700
968
  model,
701
969
  provider,
702
970
  oldKey,
@@ -728,11 +996,36 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
728
996
  peer,
729
997
  });
730
998
 
731
- let events: AssistantMessageEventStream;
999
+ let apiKey: string;
1000
+ let credentialLease: GatewayCredentialLease;
732
1001
  try {
1002
+ credentialLease = await acquireGatewayApiKey(bootOpts, model, peer, controller.signal);
1003
+ apiKey = credentialLease.apiKey;
1004
+ streamOpts.apiKey = apiKey;
1005
+ } catch (error) {
733
1006
  if (controller.signal.aborted) return aborted();
1007
+ if (error instanceof GatewayCredentialError) {
1008
+ return piNative.formatError(error.status, error.type, error.message);
1009
+ }
1010
+ throw error;
1011
+ }
1012
+
1013
+ let events: AssistantMessageEventStream;
1014
+ let releasedAtAdmission = false;
1015
+ const releaseAtAdmission = (): void => {
1016
+ if (releasedAtAdmission) return;
1017
+ releasedAtAdmission = true;
1018
+ credentialLease.release();
1019
+ };
1020
+ streamOpts.onStreamCreated = releaseAtAdmission;
1021
+ try {
1022
+ if (controller.signal.aborted) {
1023
+ credentialLease.release();
1024
+ return aborted();
1025
+ }
734
1026
  events = streamSimple(model, parsed.context, streamOpts);
735
1027
  } catch (error) {
1028
+ credentialLease.release();
736
1029
  if (streamOpts.fallbackManaged) {
737
1030
  await markManagedGatewayCredentialFailure(
738
1031
  bootOpts.storage,
@@ -748,6 +1041,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
748
1041
  logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
749
1042
  return piNative.formatError(classified.status, classified.type, classified.message);
750
1043
  }
1044
+ releaseGatewayCredentialLeaseOnAdmission(events, releaseAtAdmission, controller.signal);
751
1045
  if (streamOpts.fallbackManaged) {
752
1046
  events = observeManagedGatewayFailure(events, error =>
753
1047
  markManagedGatewayCredentialFailure(
@@ -812,8 +1106,12 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
812
1106
  * failure) inside `AuthStorage`, so this handler is a thin wrapper that
813
1107
  * surfaces the same data to HTTP callers (notably the macOS usage widget).
814
1108
  */
815
- async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
816
- const reports = (await storage.fetchUsageReports?.({ signal })) ?? [];
1109
+ async function handleUsage(storage: AuthStorage, provider: Provider, signal: AbortSignal): Promise<Response> {
1110
+ const fetchedReports = await storage.fetchUsageReports?.({ provider, signal });
1111
+ if (fetchedReports === null || fetchedReports === undefined) {
1112
+ throw new Error("Usage unavailable.");
1113
+ }
1114
+ const reports = fetchedReports.filter(report => report.provider === provider);
817
1115
  // Drop the heavy provider-specific `raw` payload — UI consumers only need
818
1116
  // `limits` + `metadata`. Match the broker's `/v1/usage` shape so a single
819
1117
  // client struct (Swift widget, llm-git, ...) works against either endpoint.
@@ -821,6 +1119,10 @@ async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<R
821
1119
  return json(200, { generatedAt: Date.now(), reports: trimmed });
822
1120
  }
823
1121
 
1122
+ function emptyScopedCredentialsResponse(): Response {
1123
+ return json(200, { generatedAt: Date.now(), credentials: [] });
1124
+ }
1125
+
824
1126
  /**
825
1127
  * Per-credential health probe surfaced on `GET /v1/credentials/check`. Tells
826
1128
  * the caller exactly which row in their broker is producing 401s — the
@@ -832,14 +1134,28 @@ async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<R
832
1134
  * endpoints. For multi-account pools that's the difference between getting
833
1135
  * a clean diagnosis and getting a 429 storm.
834
1136
  */
835
- async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
836
- const credentials = await storage.checkCredentials({ signal });
1137
+ async function handleCredentialsCheck(
1138
+ storage: AuthStorage,
1139
+ provider: Provider,
1140
+ signal: AbortSignal,
1141
+ ): Promise<Response> {
1142
+ const credentials = (await storage.checkCredentials({ provider, signal }))
1143
+ .filter(row => row.provider === provider)
1144
+ .map(row => ({
1145
+ id: row.id,
1146
+ provider: row.provider,
1147
+ type: row.type,
1148
+ ...(row.remoteRefresh ? { remoteRefresh: true as const } : {}),
1149
+ ok: row.ok,
1150
+ ...(row.reason
1151
+ ? { reason: row.ok === false ? "Credential check failed." : "Credential status unavailable." }
1152
+ : {}),
1153
+ }));
837
1154
  return json(200, { generatedAt: Date.now(), credentials });
838
1155
  }
839
1156
 
840
- function handleModelsList(opts: AuthGatewayBootOptions): Response {
841
- const list = opts.listModels ? Array.from(opts.listModels()) : [];
842
- const data = list.map(model => ({
1157
+ function handleModelsList(catalog: AuthGatewayModelCatalog): Response {
1158
+ const data = catalog.models.map(model => ({
843
1159
  id: model.id,
844
1160
  object: "model" as const,
845
1161
  owned_by: model.provider,
@@ -849,7 +1165,21 @@ function handleModelsList(opts: AuthGatewayBootOptions): Response {
849
1165
  }
850
1166
 
851
1167
  export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle {
1168
+ const provider = opts.providerScope.provider;
1169
+ if (!isSafeProviderScope(provider)) {
1170
+ throw new Error("Auth gateway requires a valid provider scope");
1171
+ }
1172
+ if (!opts.reloadProviderCredentials || !opts.hasProviderCredential || !opts.validateProviderCredential) {
1173
+ throw new Error("Auth gateway requires live provider authority callbacks");
1174
+ }
852
1175
  const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
1176
+ if (!hasProviderCredential(opts)) {
1177
+ throw new Error(`Auth gateway scope ${provider} has no enabled broker credential`);
1178
+ }
1179
+ const catalog = createAuthGatewayModelCatalog(provider, opts.listModels());
1180
+ if (catalog.models.length === 0) {
1181
+ throw new Error(`Auth gateway scope ${provider} has no source-backed models`);
1182
+ }
853
1183
  const tokens = new Set<string>(opts.bearerTokens);
854
1184
  assertAuthenticatedOrLoopback(bind, tokens.size, "auth-gateway");
855
1185
  const version = opts.version;
@@ -868,7 +1198,7 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
868
1198
  peer,
869
1199
  origin: req.headers.get("origin"),
870
1200
  });
871
- return json(403, { error: "browser origin requires bearer token" });
1201
+ return json(403, { error: "no-auth rejects requests carrying Origin" });
872
1202
  }
873
1203
  // CORS preflight is always answered without auth — browsers send
874
1204
  // preflights pre-authentication and a 401 here breaks the actual
@@ -889,31 +1219,61 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
889
1219
  // Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
890
1220
  // same client struct.
891
1221
  if (req.method === "GET" && pathname === "/v1/usage") {
892
- return withCors(await handleUsage(opts.storage, req.signal), req);
1222
+ const scopeAvailability = await providerScopeAvailability(opts, req.signal);
1223
+ if (scopeAvailability === "reload_failed") {
1224
+ return withCors(
1225
+ json(503, { error: { code: "broker_unavailable", message: "Auth broker unavailable." } }),
1226
+ req,
1227
+ );
1228
+ }
1229
+ if (scopeAvailability === "absent")
1230
+ return withCors(json(200, { generatedAt: Date.now(), reports: [] }), req);
1231
+ try {
1232
+ return withCors(await handleUsage(opts.storage, opts.providerScope.provider, req.signal), req);
1233
+ } catch (error) {
1234
+ logger.warn("auth-gateway scoped usage unavailable", {
1235
+ error: cleanReason(error) ?? "Usage unavailable.",
1236
+ });
1237
+ return withCors(
1238
+ json(503, { error: { code: "usage_unavailable", message: "Usage unavailable." } }),
1239
+ req,
1240
+ );
1241
+ }
893
1242
  }
894
1243
 
895
1244
  // Per-credential auth probe — diagnoses which row in a multi-account
896
1245
  // pool is producing 401s. Aggregated `/v1/usage` silently drops failed
897
1246
  // credentials, so we need a separate endpoint that captures errors.
898
1247
  if (req.method === "GET" && pathname === "/v1/credentials/check") {
899
- return withCors(await handleCredentialsCheck(opts.storage, req.signal), req);
1248
+ const scopeAvailability = await providerScopeAvailability(opts, req.signal);
1249
+ if (scopeAvailability === "reload_failed") {
1250
+ return withCors(
1251
+ json(503, { error: { code: "broker_unavailable", message: "Auth broker unavailable." } }),
1252
+ req,
1253
+ );
1254
+ }
1255
+ if (scopeAvailability === "absent") return withCors(emptyScopedCredentialsResponse(), req);
1256
+ return withCors(
1257
+ await handleCredentialsCheck(opts.storage, opts.providerScope.provider, req.signal),
1258
+ req,
1259
+ );
900
1260
  }
901
1261
 
902
1262
  // Provider-format dispatch.
903
1263
  const formatRoute = FORMAT_ROUTES[pathname];
904
1264
  if (formatRoute && req.method === "POST") {
905
- return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
1265
+ return withCors(await handleFormatEndpoint(formatRoute, opts, catalog, req, peer), req);
906
1266
  }
907
1267
 
908
1268
  // Pi-native fast path. Same auth + provider plumbing as the
909
1269
  // foreign-wire routes, just without the wire-format translation.
910
1270
  if (req.method === "POST" && pathname === "/v1/pi/stream") {
911
- return withCors(await handlePiNative(opts, req, peer), req);
1271
+ return withCors(await handlePiNative(opts, catalog, req, peer), req);
912
1272
  }
913
1273
 
914
1274
  // Model catalog.
915
1275
  if (req.method === "GET" && pathname === "/v1/models") {
916
- return withCors(handleModelsList(opts), req);
1276
+ return withCors(handleModelsList(catalog), req);
917
1277
  }
918
1278
 
919
1279
  // Route-table miss: no format module to defer to, so we emit a
@@ -945,3 +1305,11 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
945
1305
  },
946
1306
  };
947
1307
  }
1308
+
1309
+ export function isSafeProviderScope(provider: unknown): provider is string {
1310
+ return (
1311
+ typeof provider === "string" &&
1312
+ provider === provider.trim() &&
1313
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(provider)
1314
+ );
1315
+ }