@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.
Files changed (153) hide show
  1. package/CHANGELOG.md +98 -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/dialect/deepseek.d.ts +13 -0
  18. package/dist/types/error/classes.d.ts +1 -1
  19. package/dist/types/error/flags.d.ts +15 -0
  20. package/dist/types/error/index.d.ts +1 -0
  21. package/dist/types/error/network-cause.d.ts +40 -0
  22. package/dist/types/images/shared.d.ts +26 -2
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/judgment/native.d.ts +62 -3
  25. package/dist/types/judgment/systemone.d.ts +9 -2
  26. package/dist/types/judgment/text.d.ts +11 -5
  27. package/dist/types/judgment/types.d.ts +37 -6
  28. package/dist/types/judgment/wire.d.ts +18 -3
  29. package/dist/types/provider-session-state.d.ts +2 -2
  30. package/dist/types/providers/anthropic-images.d.ts +7 -0
  31. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  32. package/dist/types/providers/anthropic-state.d.ts +7 -0
  33. package/dist/types/providers/anthropic.d.ts +21 -6
  34. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  35. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  36. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  37. package/dist/types/providers/cursor.d.ts +7 -0
  38. package/dist/types/providers/ollama.d.ts +6 -1
  39. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  40. package/dist/types/providers/openai-responses.d.ts +9 -0
  41. package/dist/types/providers/openai-shared.d.ts +30 -0
  42. package/dist/types/providers/systemone-server.d.ts +7 -3
  43. package/dist/types/providers/transform-messages.d.ts +28 -5
  44. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  45. package/dist/types/stream.d.ts +0 -2
  46. package/dist/types/types.d.ts +33 -9
  47. package/dist/types/usage/muse-code.d.ts +1 -1
  48. package/dist/types/utils/dsml-leak.d.ts +17 -13
  49. package/dist/types/utils/narration.d.ts +9 -0
  50. package/dist/types/utils/openai-http.d.ts +2 -0
  51. package/dist/types/utils/request-body.d.ts +11 -0
  52. package/dist/types/utils/request-compression.d.ts +21 -0
  53. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  54. package/dist/types/utils/transport-fetch.d.ts +8 -0
  55. package/dist/types/utils.d.ts +1 -1
  56. package/package.json +7 -7
  57. package/src/auth/cascade.ts +23 -13
  58. package/src/auth/pool.ts +3 -3
  59. package/src/auth/refresh.ts +39 -12
  60. package/src/auth/select.ts +8 -2
  61. package/src/auth/types.ts +16 -5
  62. package/src/auth/usage-cache.ts +23 -7
  63. package/src/auth/usage.ts +2 -4
  64. package/src/auth-broker/remote-store.ts +31 -6
  65. package/src/auth-gateway/clients.ts +318 -0
  66. package/src/auth-gateway/dispatch.ts +66 -35
  67. package/src/auth-gateway/http.ts +11 -5
  68. package/src/auth-gateway/index.ts +1 -0
  69. package/src/auth-gateway/routes/embeddings.ts +4 -10
  70. package/src/auth-gateway/routes/images.ts +6 -10
  71. package/src/auth-gateway/routes/rerank.ts +4 -10
  72. package/src/auth-gateway/routes/speech.ts +10 -10
  73. package/src/auth-gateway/routes/systemone.ts +22 -12
  74. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  75. package/src/auth-gateway/routes/video.ts +12 -15
  76. package/src/auth-gateway/server.ts +136 -53
  77. package/src/auth-gateway/session-state.ts +14 -0
  78. package/src/auth-gateway/types.ts +16 -2
  79. package/src/dialect/coercion.ts +16 -0
  80. package/src/dialect/deepseek.ts +344 -81
  81. package/src/dialect/hermes.ts +7 -11
  82. package/src/dialect/qwen3.ts +3 -11
  83. package/src/error/classes.ts +3 -2
  84. package/src/error/flags.ts +24 -3
  85. package/src/error/index.ts +1 -0
  86. package/src/error/network-cause.ts +179 -0
  87. package/src/images/codex-format-directive.md +3 -0
  88. package/src/images/google-antigravity.ts +2 -1
  89. package/src/images/google-generative-ai.ts +4 -2
  90. package/src/images/openai-hosted.ts +25 -2
  91. package/src/images/openai-images.ts +11 -6
  92. package/src/images/shared.ts +77 -3
  93. package/src/index.ts +1 -0
  94. package/src/judgment/chat.ts +17 -1
  95. package/src/judgment/native.ts +238 -12
  96. package/src/judgment/openai-decisions.ts +30 -6
  97. package/src/judgment/systemone.ts +153 -11
  98. package/src/judgment/text.ts +26 -7
  99. package/src/judgment/types.ts +46 -6
  100. package/src/judgment/wire.ts +19 -3
  101. package/src/provider-session-state.ts +2 -2
  102. package/src/providers/amazon-bedrock.ts +4 -1
  103. package/src/providers/anthropic-client.ts +1 -1
  104. package/src/providers/anthropic-images.ts +263 -0
  105. package/src/providers/anthropic-messages-server.ts +84 -1
  106. package/src/providers/anthropic-signature.ts +35 -8
  107. package/src/providers/anthropic-state.ts +13 -0
  108. package/src/providers/anthropic.ts +479 -397
  109. package/src/providers/connection-prewarm.ts +78 -0
  110. package/src/providers/cowork-fetch.ts +5 -1
  111. package/src/providers/cursor/exec-modern.ts +1 -0
  112. package/src/providers/cursor-pi-args.ts +19 -0
  113. package/src/providers/cursor.ts +53 -13
  114. package/src/providers/devin.ts +10 -4
  115. package/src/providers/factory-droid/gemini.ts +9 -3
  116. package/src/providers/google-gemini-cli.ts +11 -1
  117. package/src/providers/google-shared.ts +2 -6
  118. package/src/providers/images-server.ts +6 -1
  119. package/src/providers/ollama.ts +23 -5
  120. package/src/providers/openai-anthropic-shim.ts +1 -0
  121. package/src/providers/openai-chat-server.ts +29 -0
  122. package/src/providers/openai-codex/access-programs.ts +2 -1
  123. package/src/providers/openai-codex-responses.ts +49 -82
  124. package/src/providers/openai-completions.ts +117 -19
  125. package/src/providers/openai-responses.ts +25 -2
  126. package/src/providers/openai-shared.ts +36 -5
  127. package/src/providers/pi-native-server.ts +1 -0
  128. package/src/providers/systemone-server.ts +72 -12
  129. package/src/providers/transform-messages.ts +104 -55
  130. package/src/registry/oauth/google-antigravity.ts +1 -1
  131. package/src/stream.ts +13 -73
  132. package/src/types.ts +33 -9
  133. package/src/usage/claude.ts +2 -9
  134. package/src/usage/github-copilot.ts +26 -4
  135. package/src/usage/muse-code.ts +115 -29
  136. package/src/usage/openai-codex.ts +13 -7
  137. package/src/usage/opencode-go.ts +7 -8
  138. package/src/usage/xai-oauth.ts +2 -9
  139. package/src/utils/dsml-leak.ts +88 -33
  140. package/src/utils/leaked-thinking-stream.ts +10 -0
  141. package/src/utils/narration.ts +18 -0
  142. package/src/utils/openai-http.ts +27 -18
  143. package/src/utils/request-body.ts +27 -0
  144. package/src/utils/request-compression.ts +202 -0
  145. package/src/utils/request-debug.ts +23 -9
  146. package/src/utils/schema/dereference.ts +51 -18
  147. package/src/utils/schema/draft.ts +10 -2
  148. package/src/utils/schema/json-schema-validator.ts +4 -10
  149. package/src/utils/schema/normalize.ts +6 -9
  150. package/src/utils/tool-call-arguments.ts +2 -1
  151. package/src/utils/transport-fetch.ts +37 -0
  152. package/src/utils/validation.ts +26 -16
  153. package/src/utils.ts +38 -23
@@ -16,6 +16,7 @@ import { isUsageLimitOutcome } from "../error/rate-limit";
16
16
  import type { Api, FetchImpl, Model, Usage } from "../types";
17
17
  import type { ClientUsageIdentity } from "../usage";
18
18
  import { extractProviderRetryHint } from "../utils/retry-after";
19
+ import type { AuthGatewayClientScope } from "./clients";
19
20
  import type { AuthGatewayServerOptions } from "./types";
20
21
 
21
22
  export type ModelResolver = (modelId: string) => Model<Api> | undefined;
@@ -36,8 +37,17 @@ export interface AuthGatewayRouteOptions {
36
37
  excludeProviders?: ReadonlySet<string>;
37
38
  /** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
38
39
  fetch?: FetchImpl;
40
+ /**
41
+ * The scoped client that sent this request, set per request by the HTTP
42
+ * server: it narrows `resolveModel` / `listModels`, and every credential
43
+ * lookup below resolves under its {@link AuthGatewayClientScope.credentialSessionId}.
44
+ */
45
+ client?: AuthGatewayClientScope;
39
46
  }
40
47
 
48
+ /** The route options a request's credential lookups read. */
49
+ export type AuthGatewayCredentialOptions = Pick<AuthGatewayRouteOptions, "storage" | "client">;
50
+
41
51
  /** The HTTP server's options: the routes' plus its listener and inbound auth. */
42
52
  export interface AuthGatewayBootOptions extends AuthGatewayServerOptions, AuthGatewayRouteOptions {}
43
53
 
@@ -56,28 +66,28 @@ export function normalizeClientSessionKey(clientKey: string | undefined): string
56
66
  *
57
67
  * `markUsageLimitReached` and the auth-retry resolver switch a session to a
58
68
  * sibling credential, so the provider state retained for that session can
59
- * outlive the account that taught it. OAuth rows expose an account id / email
60
- * that survives token refresh — fingerprinting the bearer instead would look
69
+ * outlive the account that taught it. A stored credential keeps its durable
70
+ * row id across token refreshes — fingerprinting the bearer instead would look
61
71
  * like a rotation every time a token refreshes and discard the retained
62
- * lessons for nothing. Key-based rows fall back to a hash of the key, never
63
- * the key itself: this value is held for the lifetime of the entry.
72
+ * lessons for nothing. The id comes from the selection itself, not the
73
+ * session's current pin, which a concurrent request on the same session may
74
+ * have moved. Unstored keys fall back to a hash of the key, never the key
75
+ * itself: this value is held for the lifetime of the entry.
64
76
  */
65
- export function resolveGatewayAccount(
66
- storage: AuthStorage,
67
- provider: string,
68
- sessionId: string,
69
- apiKey: string,
70
- ): string {
71
- const identity = storage.oauth.identity(provider, sessionId);
72
- if (identity) {
73
- return `oauth:${JSON.stringify([
74
- identity.accountId ?? "",
75
- identity.email ?? "",
76
- identity.projectId ?? "",
77
- identity.orgId ?? "",
78
- ])}`;
79
- }
80
- return `key:${Bun.hash(apiKey).toString(36)}`;
77
+ export function resolveGatewayAccount(resolved: ResolvedApiKey): string {
78
+ if (resolved.credentialId !== undefined) return `credential:${resolved.credentialId}`;
79
+ return `key:${Bun.hash(resolved.apiKey).toString(36)}`;
80
+ }
81
+
82
+ /** Who sent a request and which stored credential it started on, for the `auth-gateway request` log line. */
83
+ export function gatewayRequestLogFields(
84
+ opts: AuthGatewayCredentialOptions,
85
+ resolved: ResolvedApiKey,
86
+ ): { client?: string; credentialId?: number } {
87
+ const fields: { client?: string; credentialId?: number } = {};
88
+ if (opts.client) fields.client = opts.client.name;
89
+ if (resolved.credentialId !== undefined) fields.credentialId = resolved.credentialId;
90
+ return fields;
81
91
  }
82
92
 
83
93
  /**
@@ -90,7 +100,7 @@ export function resolveGatewayAccount(
90
100
  * {@link classifyGatewayError}; a provider without any credential is a 401.
91
101
  */
92
102
  export async function resolveGatewayApiKey(
93
- storage: AuthStorage,
103
+ opts: AuthGatewayCredentialOptions,
94
104
  model: Model<Api>,
95
105
  sessionId: string,
96
106
  signal: AbortSignal,
@@ -98,7 +108,11 @@ export async function resolveGatewayApiKey(
98
108
  ): Promise<ResolvedApiKey | GatewayErrorClassification> {
99
109
  let apiKey: ResolvedApiKey | undefined;
100
110
  try {
101
- apiKey = await storage.keys.getWithCredential(model.provider, sessionId, modelKeyOptions(model, signal));
111
+ apiKey = await opts.storage.keys.getWithCredential(
112
+ model.provider,
113
+ gatewayCredentialSessionId(opts, model, sessionId),
114
+ modelKeyOptions(model, signal),
115
+ );
102
116
  } catch (error) {
103
117
  const classified = classifyGatewayError(error);
104
118
  logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
@@ -197,20 +211,23 @@ function modelKeyOptions(model: Model<Api>, signal: AbortSignal): AuthApiKeyOpti
197
211
  * `lastKey` tracks the most recent bearer so the switch step invalidates the
198
212
  * credential that actually failed; a retry context's `previousKey` wins, since
199
213
  * concurrent operations sharing one resolver (split judgment chunks) each know
200
- * the bearer they sent. `onResolvedKey` observes every rotation; routes that
201
- * retain provider session state use it to re-key the account lease, one-shot
202
- * routes pass `undefined`.
214
+ * the bearer they sent. Every step resolves under the scoped client's
215
+ * credential session when `opts.client` binds the provider, so refresh,
216
+ * blocking and sibling rotation stay inside its accounts. `onResolvedKey`
217
+ * observes every rotation; routes that retain provider session state use it
218
+ * to re-key the account lease, one-shot routes pass `undefined`.
203
219
  */
204
220
  export function buildGatewayApiKeyResolver(
205
- storage: AuthStorage,
221
+ opts: AuthGatewayCredentialOptions,
206
222
  model: Model<Api>,
207
223
  sessionId: string,
208
224
  initialKey: ResolvedApiKey,
209
225
  requestSignal: AbortSignal,
210
226
  format: string,
211
227
  peer: string,
212
- onResolvedKey?: (apiKey: string) => void,
228
+ onResolvedKey?: (resolved: ResolvedApiKey) => void,
213
229
  ): ApiKeyResolver {
230
+ const { storage } = opts;
214
231
  let lastKey = initialKey.apiKey;
215
232
  return async ({ lastChance, error, signal, previousKey }) => {
216
233
  const sig = signal ?? requestSignal;
@@ -219,19 +236,23 @@ export function buildGatewayApiKeyResolver(
219
236
  return initialKey;
220
237
  }
221
238
  if (!lastChance) {
222
- const refreshed = await storage.keys.getWithCredential(model.provider, sessionId, {
223
- ...modelKeyOptions(model, sig),
224
- forceRefresh: true,
225
- refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
226
- });
239
+ const refreshed = await storage.keys.getWithCredential(
240
+ model.provider,
241
+ gatewayCredentialSessionId(opts, model, sessionId),
242
+ {
243
+ ...modelKeyOptions(model, sig),
244
+ forceRefresh: true,
245
+ refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
246
+ },
247
+ );
227
248
  lastKey = refreshed?.apiKey ?? lastKey;
228
- if (refreshed) onResolvedKey?.(refreshed.apiKey);
249
+ if (refreshed) onResolvedKey?.(refreshed);
229
250
  return refreshed;
230
251
  }
231
252
  const next = await refreshGatewayApiKeyAfterAuthError(
232
253
  storage,
233
254
  model,
234
- sessionId,
255
+ gatewayCredentialSessionId(opts, model, sessionId),
235
256
  model.provider,
236
257
  previousKey ?? lastKey,
237
258
  error,
@@ -240,11 +261,21 @@ export function buildGatewayApiKeyResolver(
240
261
  peer,
241
262
  );
242
263
  lastKey = next?.apiKey ?? lastKey;
243
- if (next) onResolvedKey?.(next.apiKey);
264
+ if (next) onResolvedKey?.(next);
244
265
  return next;
245
266
  };
246
267
  }
247
268
 
269
+ /**
270
+ * The session a request's credential lookup resolves under: a scoped client's
271
+ * own restricted session for a provider it binds. Asked on every lookup, not
272
+ * once per request, so a retry after the client's restriction is lifted fails
273
+ * instead of resolving unrestricted.
274
+ */
275
+ function gatewayCredentialSessionId(opts: AuthGatewayCredentialOptions, model: Model<Api>, sessionId: string): string {
276
+ return opts.client?.credentialSessionId(model.provider, sessionId) ?? sessionId;
277
+ }
278
+
248
279
  /**
249
280
  * Attribute one settled upstream request to the originating client via the
250
281
  * broker's observed-usage channel (`AuthStorage.usage.observe`, batched
@@ -113,13 +113,19 @@ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
113
113
 
114
114
  const TOKEN_ENCODER = new TextEncoder();
115
115
 
116
- export function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
117
- if (tokens.size === 0) return true;
116
+ /** The token of an `Authorization: Bearer <token>` header, trimmed; `undefined` without one. */
117
+ export function readBearerToken(req: Request): string | undefined {
118
118
  const header = req.headers.get("authorization");
119
- if (!header) return false;
119
+ if (!header) return undefined;
120
120
  const match = header.match(/^Bearer\s+(.+)$/i);
121
- if (!match) return false;
122
- const presented = TOKEN_ENCODER.encode(match[1].trim());
121
+ return match ? match[1].trim() : undefined;
122
+ }
123
+
124
+ export function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
125
+ if (tokens.size === 0) return true;
126
+ const token = readBearerToken(req);
127
+ if (token === undefined) return false;
128
+ const presented = TOKEN_ENCODER.encode(token);
123
129
  // Iterate every allowed token regardless of early hits so the result
124
130
  // timing reflects the full set, not the position of the match.
125
131
  let ok = false;
@@ -1,3 +1,4 @@
1
+ export * from "./clients";
1
2
  export * from "./dispatch";
2
3
  export * from "./http";
3
4
  export * from "./session-state";
@@ -6,6 +6,7 @@ import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
7
  type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
+ gatewayRequestLogFields,
9
10
  mirrorRequestAbort,
10
11
  recordGatewayUsage,
11
12
  resolveGatewayApiKey,
@@ -49,7 +50,7 @@ export async function handleEmbeddings(
49
50
 
50
51
  const client = resolveClientIdentity(req.headers);
51
52
  const sessionId = deterministicUuid(`embeddings\u0000${model.provider}/${model.id}`);
52
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
53
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
53
54
  if (controller.signal.aborted) return aborted();
54
55
  if ("status" in apiKey) {
55
56
  return embeddings.formatError(apiKey.status, apiKey.type, apiKey.message);
@@ -63,19 +64,12 @@ export async function handleEmbeddings(
63
64
  resolvedModel: model.id,
64
65
  stream: false,
65
66
  peer,
67
+ ...gatewayRequestLogFields(bootOpts, apiKey),
66
68
  });
67
69
 
68
70
  try {
69
71
  const result = await embed(model, parsed.request, {
70
- apiKey: buildGatewayApiKeyResolver(
71
- bootOpts.storage,
72
- model,
73
- sessionId,
74
- apiKey,
75
- controller.signal,
76
- "embeddings",
77
- peer,
78
- ),
72
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "embeddings", peer),
79
73
  fetch: bootOpts.fetch,
80
74
  signal: controller.signal,
81
75
  });
@@ -2,11 +2,13 @@ import { calculateCost } from "@linxiraos/pi-catalog/models";
2
2
  import { logger } from "@linxiraos/pi-utils";
3
3
  import { classifyGatewayError } from "../../error/gateway";
4
4
  import { generateImage } from "../../images";
5
+ import { validateImageInput } from "../../images/shared";
5
6
  import * as imagesServer from "../../providers/images-server";
6
7
  import { deterministicUuid } from "../../utils/deterministic-id";
7
8
  import {
8
9
  type AuthGatewayRouteOptions,
9
10
  buildGatewayApiKeyResolver,
11
+ gatewayRequestLogFields,
10
12
  mirrorRequestAbort,
11
13
  recordGatewayUsage,
12
14
  resolveGatewayApiKey,
@@ -49,6 +51,7 @@ async function handleImages(
49
51
  let parsed: imagesServer.ImagesParsedRequest;
50
52
  try {
51
53
  parsed = await imagesServer.parseRequest(body, kind);
54
+ for (const image of parsed.request.inputImages ?? []) validateImageInput(image);
52
55
  } catch (error) {
53
56
  const message = error instanceof Error ? error.message : String(error);
54
57
  return imagesServer.formatError(400, "invalid_request_error", message);
@@ -75,7 +78,7 @@ async function handleImages(
75
78
 
76
79
  const client = resolveClientIdentity(req.headers);
77
80
  const sessionId = deterministicUuid(`images\u0000${model.provider}/${model.id}`);
78
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
81
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
79
82
  if (controller.signal.aborted) return aborted();
80
83
  if ("status" in apiKey) return imagesServer.formatError(apiKey.status, apiKey.type, apiKey.message);
81
84
 
@@ -87,19 +90,12 @@ async function handleImages(
87
90
  resolvedModel: model.id,
88
91
  stream: false,
89
92
  peer,
93
+ ...gatewayRequestLogFields(bootOpts, apiKey),
90
94
  });
91
95
 
92
96
  try {
93
97
  const result = await generateImage(model, parsed.request, {
94
- apiKey: buildGatewayApiKeyResolver(
95
- bootOpts.storage,
96
- model,
97
- sessionId,
98
- apiKey,
99
- controller.signal,
100
- "images",
101
- peer,
102
- ),
98
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "images", peer),
103
99
  fetch: bootOpts.fetch,
104
100
  signal: controller.signal,
105
101
  });
@@ -6,6 +6,7 @@ import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
7
  type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
+ gatewayRequestLogFields,
9
10
  mirrorRequestAbort,
10
11
  recordGatewayUsage,
11
12
  resolveGatewayApiKey,
@@ -39,7 +40,7 @@ export async function handleRerank(bootOpts: AuthGatewayRouteOptions, req: Reque
39
40
 
40
41
  const client = resolveClientIdentity(req.headers);
41
42
  const sessionId = deterministicUuid(`rerank\u0000${model.provider}/${model.id}`);
42
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
43
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
43
44
  if (controller.signal.aborted) return aborted();
44
45
  if ("status" in apiKey) return rerankWire.formatError(apiKey.status, apiKey.type, apiKey.message);
45
46
 
@@ -51,19 +52,12 @@ export async function handleRerank(bootOpts: AuthGatewayRouteOptions, req: Reque
51
52
  resolvedModel: model.id,
52
53
  stream: false,
53
54
  peer,
55
+ ...gatewayRequestLogFields(bootOpts, apiKey),
54
56
  });
55
57
 
56
58
  try {
57
59
  const result = await rerank(model, parsed.request, {
58
- apiKey: buildGatewayApiKeyResolver(
59
- bootOpts.storage,
60
- model,
61
- sessionId,
62
- apiKey,
63
- controller.signal,
64
- "rerank",
65
- peer,
66
- ),
60
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "rerank", peer),
67
61
  fetch: bootOpts.fetch,
68
62
  signal: controller.signal,
69
63
  });
@@ -1,11 +1,13 @@
1
1
  import { logger } from "@linxiraos/pi-utils";
2
2
  import { classifyGatewayError } from "../../error/gateway";
3
+ import { ValidationError } from "../../error/validation";
3
4
  import * as speechWire from "../../providers/speech-server";
4
5
  import { isSpeechApi, synthesizeSpeech } from "../../speech";
5
6
  import { deterministicUuid } from "../../utils/deterministic-id";
6
7
  import {
7
8
  type AuthGatewayRouteOptions,
8
9
  buildGatewayApiKeyResolver,
10
+ gatewayRequestLogFields,
9
11
  mirrorRequestAbort,
10
12
  recordGatewayUsage,
11
13
  resolveGatewayApiKey,
@@ -55,7 +57,7 @@ export async function handleSpeech(bootOpts: AuthGatewayRouteOptions, req: Reque
55
57
 
56
58
  const client = resolveClientIdentity(req.headers);
57
59
  const sessionId = deterministicUuid(`speech\u0000${model.provider}/${model.id}`);
58
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
60
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
59
61
  if (controller.signal.aborted) return aborted();
60
62
  if ("status" in apiKey) return speechWire.formatError(apiKey.status, apiKey.type, apiKey.message);
61
63
 
@@ -67,19 +69,12 @@ export async function handleSpeech(bootOpts: AuthGatewayRouteOptions, req: Reque
67
69
  resolvedModel: model.id,
68
70
  stream: false,
69
71
  peer,
72
+ ...gatewayRequestLogFields(bootOpts, apiKey),
70
73
  });
71
74
 
72
75
  try {
73
76
  const result = await synthesizeSpeech(model, parsed.request, {
74
- apiKey: buildGatewayApiKeyResolver(
75
- bootOpts.storage,
76
- model,
77
- sessionId,
78
- apiKey,
79
- controller.signal,
80
- "speech",
81
- peer,
82
- ),
77
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "speech", peer),
83
78
  fetch: bootOpts.fetch,
84
79
  signal: controller.signal,
85
80
  });
@@ -94,6 +89,11 @@ export async function handleSpeech(bootOpts: AuthGatewayRouteOptions, req: Reque
94
89
  return response;
95
90
  } catch (error) {
96
91
  if (controller.signal.aborted) return aborted();
92
+ // Adapter validation is a client error: the wire schema admits values the
93
+ // adapter cannot serve, and the classifier would bill them as a 502.
94
+ if (error instanceof ValidationError) {
95
+ return speechWire.formatError(400, "invalid_request_error", error.message);
96
+ }
97
97
  const classified = classifyGatewayError(error);
98
98
  logger.warn("auth-gateway speech failed", { format: "speech", error: classified.message, peer });
99
99
  return speechWire.formatError(classified.status, classified.type, classified.message);
@@ -13,13 +13,15 @@
13
13
  */
14
14
  import { calculateCost } from "@linxiraos/pi-catalog/models";
15
15
  import { logger } from "@linxiraos/pi-utils";
16
+ import * as AIError from "../../error";
16
17
  import { classifyGatewayError } from "../../error/gateway";
17
- import { createNativeJudge, isJudgmentApi } from "../../judgment/native";
18
+ import { checkNativeJudgmentImages, createNativeJudge, isJudgmentApi } from "../../judgment/native";
18
19
  import * as systemOne from "../../providers/systemone-server";
19
20
  import { deterministicUuid } from "../../utils/deterministic-id";
20
21
  import {
21
22
  type AuthGatewayRouteOptions,
22
23
  buildGatewayApiKeyResolver,
24
+ gatewayRequestLogFields,
23
25
  mirrorRequestAbort,
24
26
  recordGatewayUsage,
25
27
  resolveGatewayApiKey,
@@ -66,26 +68,28 @@ export async function handleSystemOne(
66
68
  `Model ${parsed.modelId} does not answer System One judgments`,
67
69
  );
68
70
  }
71
+ // Model capability and dialect caps are known locally: reject before credential lookup or refresh.
72
+ try {
73
+ checkNativeJudgmentImages(model, parsed.request, parsed.imageAssets);
74
+ } catch (error) {
75
+ return systemOne.formatError(
76
+ 422,
77
+ "invalid_request_error",
78
+ error instanceof Error ? error.message : String(error),
79
+ );
80
+ }
69
81
  const client = resolveClientIdentity(req.headers);
70
82
  // Judgments carry no conversation to derive a session from. One sticky
71
83
  // session per model keeps credential selection stable across calls and
72
84
  // still lets `markUsageLimitReached` hand off to a sibling account.
73
85
  const sessionId = deterministicUuid(`systemone\u0000${model.provider}/${model.id}`);
74
86
 
75
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
87
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
76
88
  if (controller.signal.aborted) return aborted();
77
89
  if ("status" in apiKey) return systemOne.formatError(apiKey.status, apiKey.type, apiKey.message);
78
90
 
79
91
  const judge = createNativeJudge(model, {
80
- apiKey: buildGatewayApiKeyResolver(
81
- bootOpts.storage,
82
- model,
83
- sessionId,
84
- apiKey,
85
- controller.signal,
86
- "systemone",
87
- peer,
88
- ),
92
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "systemone", peer),
89
93
  fetch: bootOpts.fetch,
90
94
  });
91
95
 
@@ -97,11 +101,13 @@ export async function handleSystemOne(
97
101
  resolvedModel: model.id,
98
102
  stream: false,
99
103
  peer,
104
+ ...gatewayRequestLogFields(bootOpts, apiKey),
100
105
  });
101
106
 
102
107
  try {
103
108
  const result = await judge.judge(parsed.request, {
104
109
  signal: controller.signal,
110
+ imageAssets: parsed.imageAssets,
105
111
  // A split judgment that fails still billed the chunks that succeeded.
106
112
  onPartialUsage: usage => {
107
113
  if (usage.cost.total === 0) calculateCost(model, usage);
@@ -117,7 +123,11 @@ export async function handleSystemOne(
117
123
  return json(200, body, gatewayResponseHeaders(model, { requestId, costUsd: result.usage.cost.total, startedAt }));
118
124
  } catch (error) {
119
125
  if (controller.signal.aborted) return aborted();
120
- const classified = classifyGatewayError(error);
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ const classified =
128
+ error instanceof AIError.ValidationError
129
+ ? { status: 422, type: "invalid_request_error", message }
130
+ : classifyGatewayError(error);
121
131
  logger.warn("auth-gateway judgment failed", { format: "systemone", error: classified.message, peer });
122
132
  return systemOne.formatError(classified.status, classified.type, classified.message);
123
133
  }
@@ -6,6 +6,7 @@ import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
7
  type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
+ gatewayRequestLogFields,
9
10
  mirrorRequestAbort,
10
11
  recordGatewayUsage,
11
12
  resolveGatewayApiKey,
@@ -49,7 +50,7 @@ export async function handleTranscriptions(
49
50
 
50
51
  const client = resolveClientIdentity(req.headers);
51
52
  const sessionId = deterministicUuid(`transcriptions\u0000${model.provider}/${model.id}`);
52
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
53
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
53
54
  if (controller.signal.aborted) return aborted();
54
55
  if ("status" in apiKey) {
55
56
  return transcriptions.formatError(apiKey.status, apiKey.type, apiKey.message);
@@ -63,12 +64,13 @@ export async function handleTranscriptions(
63
64
  resolvedModel: model.id,
64
65
  stream: false,
65
66
  peer,
67
+ ...gatewayRequestLogFields(bootOpts, apiKey),
66
68
  });
67
69
 
68
70
  try {
69
71
  const result = await transcribeAudio(model, parsed.request, {
70
72
  apiKey: buildGatewayApiKeyResolver(
71
- bootOpts.storage,
73
+ bootOpts,
72
74
  model,
73
75
  sessionId,
74
76
  apiKey,
@@ -9,6 +9,7 @@ import { deterministicUuid } from "../../utils/deterministic-id";
9
9
  import {
10
10
  type AuthGatewayRouteOptions,
11
11
  buildGatewayApiKeyResolver,
12
+ gatewayRequestLogFields,
12
13
  mirrorRequestAbort,
13
14
  resolveGatewayApiKey,
14
15
  } from "../dispatch";
@@ -57,7 +58,7 @@ async function resolveVideoJob(
57
58
  );
58
59
  }
59
60
  const sessionId = deterministicUuid(`video\u0000${model.provider}/${model.id}`);
60
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
61
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
61
62
  if (controller.signal.aborted) return aborted();
62
63
  if ("status" in apiKey) return videoServer.formatError(apiKey.status, apiKey.type, apiKey.message);
63
64
  return { model, upstreamId: identity.upstreamId, sessionId, apiKey, controller };
@@ -66,7 +67,7 @@ async function resolveVideoJob(
66
67
  function videoOptions(bootOpts: AuthGatewayRouteOptions, resolved: ResolvedVideoRequest, peer: string) {
67
68
  return {
68
69
  apiKey: buildGatewayApiKeyResolver(
69
- bootOpts.storage,
70
+ bootOpts,
70
71
  resolved.model,
71
72
  resolved.sessionId,
72
73
  resolved.apiKey,
@@ -80,11 +81,13 @@ function videoOptions(bootOpts: AuthGatewayRouteOptions, resolved: ResolvedVideo
80
81
  }
81
82
 
82
83
  function logVideoRequest(
84
+ bootOpts: AuthGatewayRouteOptions,
83
85
  requestId: string,
84
86
  operation: "submit" | "poll" | "content",
85
- model: ResolvedVideoRequest["model"],
87
+ resolved: ResolvedVideoRequest,
86
88
  peer: string,
87
89
  ): void {
90
+ const { model } = resolved;
88
91
  logger.info("auth-gateway request", {
89
92
  requestId,
90
93
  format: `video-${operation}`,
@@ -93,6 +96,7 @@ function logVideoRequest(
93
96
  resolvedModel: model.id,
94
97
  stream: operation === "content",
95
98
  peer,
99
+ ...gatewayRequestLogFields(bootOpts, resolved.apiKey),
96
100
  });
97
101
  }
98
102
 
@@ -140,7 +144,7 @@ export async function handleVideoSubmit(
140
144
  );
141
145
  }
142
146
  const sessionId = deterministicUuid(`video\u0000${model.provider}/${model.id}`);
143
- const apiKey = await resolveGatewayApiKey(bootOpts.storage, model, sessionId, controller.signal, peer);
147
+ const apiKey = await resolveGatewayApiKey(bootOpts, model, sessionId, controller.signal, peer);
144
148
  if (controller.signal.aborted) return aborted();
145
149
  if ("status" in apiKey) return videoServer.formatError(apiKey.status, apiKey.type, apiKey.message);
146
150
  logger.info("auth-gateway request", {
@@ -151,18 +155,11 @@ export async function handleVideoSubmit(
151
155
  resolvedModel: model.id,
152
156
  stream: false,
153
157
  peer,
158
+ ...gatewayRequestLogFields(bootOpts, apiKey),
154
159
  });
155
160
  try {
156
161
  const job = await submitVideo(model, parsed.request, {
157
- apiKey: buildGatewayApiKeyResolver(
158
- bootOpts.storage,
159
- model,
160
- sessionId,
161
- apiKey,
162
- controller.signal,
163
- "video",
164
- peer,
165
- ),
162
+ apiKey: buildGatewayApiKeyResolver(bootOpts, model, sessionId, apiKey, controller.signal, "video", peer),
166
163
  fetch: bootOpts.fetch,
167
164
  signal: controller.signal,
168
165
  });
@@ -195,7 +192,7 @@ export async function handleVideoPoll(
195
192
  const requestId = crypto.randomUUID();
196
193
  const resolved = await resolveVideoJob(bootOpts, req, peer, gatewayId);
197
194
  if (resolved instanceof Response) return resolved;
198
- logVideoRequest(requestId, "poll", resolved.model, peer);
195
+ logVideoRequest(bootOpts, requestId, "poll", resolved, peer);
199
196
  try {
200
197
  const job = await pollVideo(resolved.model, resolved.upstreamId, videoOptions(bootOpts, resolved, peer));
201
198
  recordCompletedUsage(bootOpts, resolved, req, job);
@@ -227,7 +224,7 @@ export async function handleVideoContent(
227
224
  const requestId = crypto.randomUUID();
228
225
  const resolved = await resolveVideoJob(bootOpts, req, peer, gatewayId);
229
226
  if (resolved instanceof Response) return resolved;
230
- logVideoRequest(requestId, "content", resolved.model, peer);
227
+ logVideoRequest(bootOpts, requestId, "content", resolved, peer);
231
228
  try {
232
229
  const content = await downloadVideo(resolved.model, resolved.upstreamId, videoOptions(bootOpts, resolved, peer));
233
230
  const headers = new Headers(gatewayResponseHeaders(resolved.model, { requestId, startedAt }));