@juspay/neurolink 12.14.21 → 12.15.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 (57) hide show
  1. package/CHANGELOG.md +2 -6
  2. package/dist/browser/neurolink.min.js +397 -397
  3. package/dist/cli/commands/auth.js +6 -2
  4. package/dist/cli/commands/proxy.d.ts +4 -0
  5. package/dist/cli/commands/proxy.js +119 -39
  6. package/dist/cli/index.js +8 -18
  7. package/dist/cli/lifecycle.d.ts +8 -0
  8. package/dist/cli/lifecycle.js +90 -0
  9. package/dist/core/baseProvider.d.ts +6 -3
  10. package/dist/core/baseProvider.js +7 -3
  11. package/dist/providers/amazonBedrock/client.d.ts +7 -5
  12. package/dist/providers/amazonBedrock/client.js +125 -16
  13. package/dist/providers/cohere.d.ts +2 -2
  14. package/dist/providers/cohere.js +5 -1
  15. package/dist/providers/googleAiStudio/client.d.ts +3 -3
  16. package/dist/providers/googleAiStudio/client.js +6 -2
  17. package/dist/providers/googleVertex/client.d.ts +2 -2
  18. package/dist/providers/googleVertex/client.js +5 -1
  19. package/dist/providers/jina.d.ts +2 -2
  20. package/dist/providers/jina.js +5 -1
  21. package/dist/providers/litellm/client.d.ts +2 -2
  22. package/dist/providers/litellm/client.js +5 -1
  23. package/dist/providers/ollama/client.d.ts +2 -2
  24. package/dist/providers/ollama/client.js +5 -1
  25. package/dist/providers/openAI/client.d.ts +3 -3
  26. package/dist/providers/openAI/client.js +6 -2
  27. package/dist/providers/voyage.d.ts +2 -2
  28. package/dist/providers/voyage.js +5 -1
  29. package/dist/proxy/codexAccountUsage.js +18 -6
  30. package/dist/proxy/codexFallback.js +17 -10
  31. package/dist/proxy/codexUsage.js +3 -0
  32. package/dist/proxy/proxyAnalysis.js +14 -11
  33. package/dist/proxy/proxyTokenUsage.d.ts +7 -0
  34. package/dist/proxy/proxyTokenUsage.js +21 -0
  35. package/dist/proxy/proxyTracer.d.ts +2 -0
  36. package/dist/proxy/proxyTracer.js +21 -40
  37. package/dist/proxy/quotaHeaders.js +7 -3
  38. package/dist/rag/document/MDocument.d.ts +19 -1
  39. package/dist/rag/document/MDocument.js +30 -3
  40. package/dist/rag/document/imageLoader.d.ts +55 -0
  41. package/dist/rag/document/imageLoader.js +183 -0
  42. package/dist/rag/index.d.ts +1 -0
  43. package/dist/rag/index.js +2 -0
  44. package/dist/rag/pipeline/RAGPipeline.d.ts +54 -1
  45. package/dist/rag/pipeline/RAGPipeline.js +358 -1
  46. package/dist/rag/ragIntegration.js +146 -25
  47. package/dist/server/routes/claudeProxyRoutes.js +35 -1
  48. package/dist/server/routes/codexProxyRoutes.d.ts +5 -9
  49. package/dist/server/routes/codexProxyRoutes.js +106 -18
  50. package/dist/types/codex.d.ts +10 -0
  51. package/dist/types/providers.d.ts +21 -1
  52. package/dist/types/proxy.d.ts +24 -6
  53. package/dist/types/rag.d.ts +144 -0
  54. package/dist/types/rag.js +12 -1
  55. package/dist/utils/pricing.js +4 -2
  56. package/docs-site/static/search-index.json +1 -1
  57. package/package.json +2 -1
@@ -3,8 +3,11 @@ import { extname, resolve } from "path";
3
3
  import { z } from "zod";
4
4
  import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js";
5
5
  import { logger } from "../utils/logger.js";
6
+ import { redactUrlForError } from "../utils/logSanitize.js";
6
7
  import { createChunker } from "./ChunkerFactory.js";
7
8
  import { createVectorQueryTool, InMemoryVectorStore, } from "./retrieval/vectorQueryTool.js";
9
+ import { ImageLoader } from "./document/imageLoader.js";
10
+ import { IMAGE_EXTENSIONS } from "../processors/config/index.js";
8
11
  import { withSpan } from "../telemetry/withSpan.js";
9
12
  import { tracers } from "../telemetry/tracers.js";
10
13
  /**
@@ -43,6 +46,8 @@ function detectStrategy(filePath) {
43
46
  const ext = extname(filePath).toLowerCase();
44
47
  return EXTENSION_TO_STRATEGY[ext] || "recursive";
45
48
  }
49
+ /** Embedding dimension used by {@link generateSimpleEmbedding}. */
50
+ const EMBEDDING_DIMENSION = 128;
46
51
  /**
47
52
  * Simple hash function for strings (FNV-1a variant).
48
53
  * Maps a word to a bucket index deterministically.
@@ -177,14 +182,62 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
177
182
  if (!files || files.length === 0) {
178
183
  throw new Error("RAG config requires at least one file path in 'files'");
179
184
  }
180
- // 1. Load files
185
+ // 1. Load files — separate text and image files
181
186
  const fileContents = [];
187
+ const imageFiles = [];
182
188
  for (const filePath of files) {
189
+ // HTTP(S) sources are handled by ImageLoader directly and must not go
190
+ // through resolve()/existsSync(), which would mangle the URL into a
191
+ // bogus local path and always report it as missing.
192
+ if (/^https?:\/\//i.test(filePath)) {
193
+ // `new URL()` throws on a string the guarding regex still accepts —
194
+ // "https://" is the minimal case. Uncaught, it leaves this loop, is
195
+ // rethrown by prepareRAGTool, and disables RAG for the WHOLE call, so
196
+ // one bad entry discards every file already loaded. Skip the source
197
+ // instead, matching how an unreadable local file is handled below.
198
+ let ext;
199
+ try {
200
+ ext = extname(new URL(filePath).pathname).toLowerCase();
201
+ }
202
+ catch {
203
+ logger.warn(`[RAG] Malformed URL source, skipping: ${redactUrlForError(filePath)}`);
204
+ continue;
205
+ }
206
+ if (ext === ".svg") {
207
+ // IMAGE_EXTENSIONS includes .svg, but every consumer downstream of
208
+ // here treats an image as raster bytes: ImageLoader returns the file
209
+ // verbatim, captioning base64s it into a vision call, and the Bedrock
210
+ // embed path ships it as-is — Nova rejects the format outright and
211
+ // Titan accepts markup it cannot embed meaningfully. Skipping is
212
+ // honest; supporting it needs a rasterize-or-sanitize step in
213
+ // ImageLoader, not a classification change here.
214
+ logger.warn(`[RAG] SVG is not supported as a RAG image source, skipping: ${redactUrlForError(filePath)}`);
215
+ continue;
216
+ }
217
+ if (IMAGE_EXTENSIONS.includes(ext)) {
218
+ imageFiles.push({ path: filePath, ext });
219
+ }
220
+ else {
221
+ logger.warn(`[RAG] Unsupported URL source, skipping: ${redactUrlForError(filePath)}`);
222
+ }
223
+ continue;
224
+ }
183
225
  const resolvedPath = resolve(filePath);
184
226
  if (!existsSync(resolvedPath)) {
185
227
  logger.warn(`[RAG] File not found, skipping: ${resolvedPath}`);
186
228
  continue;
187
229
  }
230
+ // Detect image files by extension
231
+ const ext = extname(resolvedPath).toLowerCase();
232
+ if (ext === ".svg") {
233
+ // See the URL branch above for why SVG is excluded from the raster path.
234
+ logger.warn(`[RAG] SVG is not supported as a RAG image source, skipping: ${resolvedPath}`);
235
+ continue;
236
+ }
237
+ if (IMAGE_EXTENSIONS.includes(ext)) {
238
+ imageFiles.push({ path: resolvedPath, ext });
239
+ continue;
240
+ }
188
241
  try {
189
242
  const content = readFileSync(resolvedPath, "utf-8");
190
243
  const strategy = userStrategy || detectStrategy(resolvedPath);
@@ -195,11 +248,12 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
195
248
  }
196
249
  }
197
250
  // Auto-increase topK for multi-file scenarios to ensure coverage
198
- // (computed after loading so it reflects only files that actually exist)
199
- const topK = fileContents.length > 1
200
- ? Math.max(userTopK, fileContents.length * 3)
201
- : userTopK;
202
- if (fileContents.length === 0) {
251
+ // (computed after loading so it reflects only files that actually exist).
252
+ // Image files count toward the file total so image-only RAG gets a topK
253
+ // that covers every source.
254
+ const totalSources = fileContents.length + imageFiles.length;
255
+ const topK = totalSources > 1 ? Math.max(userTopK, totalSources * 3) : userTopK;
256
+ if (fileContents.length === 0 && imageFiles.length === 0) {
203
257
  throw new Error("RAG: No files could be loaded. Check that file paths exist and are readable.");
204
258
  }
205
259
  logger.info(`[RAG] Loaded ${fileContents.length} files for indexing`);
@@ -230,9 +284,44 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
230
284
  });
231
285
  }
232
286
  }
233
- logger.info(`[RAG] Created ${allChunks.length} chunks from ${fileContents.length} files`);
287
+ logger.info(`[RAG] Created ${allChunks.length} chunks from ${fileContents.length} text files`);
288
+ // 2b. Load image files (multi-modal RAG).
289
+ //
290
+ // Deliberately NOT embedded here. Images are vectorised below through the
291
+ // same `embedFn` as the text chunks, because index-wide a single embedding
292
+ // space is the whole precondition for similarity to mean anything. Embedding
293
+ // them here — as this path originally did, with a hash of the caption —
294
+ // produced a 128-dimension image vector sitting in an index whose text
295
+ // vectors came from a provider at 768 or 1536, which is not a worse ranking
296
+ // but an incomparable one.
297
+ const imageChunks = [];
298
+ if (imageFiles.length > 0) {
299
+ const imageLoader = new ImageLoader();
300
+ for (const { path: imgPath } of imageFiles) {
301
+ try {
302
+ const imageDoc = await imageLoader.load(imgPath);
303
+ // Use filename-based text representation for simple embedding
304
+ const imageText = imageDoc.text;
305
+ imageChunks.push({
306
+ id: `img-chunk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
307
+ text: imageText,
308
+ metadata: {
309
+ text: imageText,
310
+ source: imgPath,
311
+ hasImage: true,
312
+ mimeType: imageDoc.mimeType,
313
+ imageWidth: imageDoc.metadata.width,
314
+ imageHeight: imageDoc.metadata.height,
315
+ },
316
+ });
317
+ }
318
+ catch (error) {
319
+ logger.warn(`[RAG] Failed to load image: ${imgPath}: ${error instanceof Error ? error.message : String(error)}`);
320
+ }
321
+ }
322
+ logger.info(`[RAG] Loaded ${imageChunks.length} image files for multi-modal RAG`);
323
+ }
234
324
  // 3. Generate embeddings and store in vector store
235
- const EMBEDDING_DIMENSION = 128;
236
325
  const vectorStore = new InMemoryVectorStore();
237
326
  const indexName = "rag-index";
238
327
  // When the caller configured an embedding provider/model, embed BOTH the
@@ -262,9 +351,19 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
262
351
  logger.warn("[RAG] Failed to create embedding provider; falling back to hash embeddings", { error: error instanceof Error ? error.message : String(error) });
263
352
  }
264
353
  }
354
+ // Text and image captions embed together, in one call and under one
355
+ // fallback. Splitting them into two try/catch blocks would let the text side
356
+ // succeed on the provider while the image side fell back to the hash, which
357
+ // is the mixed-space index this is trying to prevent.
265
358
  let chunkVectors;
359
+ let imageVectors;
266
360
  try {
267
- chunkVectors = await Promise.all(allChunks.map((chunk) => embedFn(chunk.text)));
361
+ const allVectors = await Promise.all([
362
+ ...allChunks.map((chunk) => embedFn(chunk.text)),
363
+ ...imageChunks.map((chunk) => embedFn(chunk.text)),
364
+ ]);
365
+ chunkVectors = allVectors.slice(0, allChunks.length);
366
+ imageVectors = allVectors.slice(allChunks.length);
268
367
  }
269
368
  catch (error) {
270
369
  // One failed chunk must not leave a mixed-space index — flip the whole
@@ -272,17 +371,36 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
272
371
  logger.warn("[RAG] Provider embedding failed mid-index; falling back to hash embeddings for index and queries", { error: error instanceof Error ? error.message : String(error) });
273
372
  embedFn = (text) => Promise.resolve(generateSimpleEmbedding(text, EMBEDDING_DIMENSION));
274
373
  chunkVectors = allChunks.map((chunk) => generateSimpleEmbedding(chunk.text, EMBEDDING_DIMENSION));
374
+ imageVectors = imageChunks.map((chunk) => generateSimpleEmbedding(chunk.text, EMBEDDING_DIMENSION));
375
+ }
376
+ const items = [
377
+ ...allChunks.map((chunk, i) => ({
378
+ id: `rag-chunk-${i}`,
379
+ vector: chunkVectors[i],
380
+ metadata: {
381
+ text: chunk.text,
382
+ ...chunk.metadata,
383
+ },
384
+ })),
385
+ ...imageChunks.map((chunk, i) => ({
386
+ id: chunk.id,
387
+ vector: imageVectors[i],
388
+ metadata: chunk.metadata,
389
+ })),
390
+ ];
391
+ // The guard near the top of this function fires when no source was found
392
+ // at all. This one covers the other way to end up with an empty index:
393
+ // sources WERE found and every one of them failed to load — an image over
394
+ // the size ceiling, a corrupt file, an unreachable URL. Each of those is
395
+ // caught and warned individually, so without this the call returns
396
+ // `chunksIndexed: 0` alongside a positive `filesLoaded` and reports
397
+ // success, and the caller gets a silently empty knowledge base.
398
+ if (items.length === 0) {
399
+ throw new Error("RAG: every source failed to load, so nothing was indexed. " +
400
+ "Check the warnings above for the per-source reason.");
275
401
  }
276
- const items = allChunks.map((chunk, i) => ({
277
- id: `rag-chunk-${i}`,
278
- vector: chunkVectors[i],
279
- metadata: {
280
- text: chunk.text,
281
- ...chunk.metadata,
282
- },
283
- }));
284
402
  await vectorStore.upsert(indexName, items);
285
- logger.info(`[RAG] Indexed ${items.length} chunks in vector store`);
403
+ logger.info(`[RAG] Indexed ${items.length} chunks (${allChunks.length} text + ${imageChunks.length} images) in vector store`);
286
404
  // 4. Create the search tool
287
405
  // Determine embedding provider/model for the query tool
288
406
  const provider = embeddingProvider || fallbackProvider || "vertex";
@@ -315,15 +433,16 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
315
433
  // Query through the same embedding space the index was built in —
316
434
  // provider embeddings when configured (and healthy), else the hash.
317
435
  const queryEmbedding = await embedFn(query).catch(() => generateSimpleEmbedding(query, EMBEDDING_DIMENSION));
318
- // Fetch more candidates than needed so diversity can select across files
319
- const fetchK = fileContents.length > 1 ? topK * 3 : topK;
436
+ // Fetch more candidates than needed so diversity can select across files and images
437
+ const loadedSources = fileContents.length + imageChunks.length;
438
+ const fetchK = loadedSources > 1 ? topK * 3 : topK;
320
439
  const rawResults = await vectorStore.query({
321
440
  indexName,
322
441
  queryVector: queryEmbedding,
323
442
  topK: fetchK,
324
443
  });
325
- // Apply source-file diversity for multi-file RAG
326
- const results = fileContents.length > 1
444
+ // Apply source-file diversity for multi-file/multi-image RAG
445
+ const results = loadedSources > 1
327
446
  ? diversifyResults(rawResults, topK)
328
447
  : rawResults.slice(0, topK);
329
448
  if (results.length === 0) {
@@ -335,7 +454,8 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
335
454
  };
336
455
  }
337
456
  const relevantContext = results
338
- .map((r, i) => `[${i + 1}] ${r.metadata?.text || r.text || ""}`)
457
+ .map((r, i) => `[${i + 1}] ${r.metadata?.text || r.text || ""}` +
458
+ (r.metadata?.hasImage ? " [image]" : ""))
339
459
  .join("\n\n");
340
460
  span.setAttribute("rag.results_count", results.length);
341
461
  return {
@@ -345,6 +465,7 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
345
465
  score: r.score,
346
466
  source: r.metadata?.source,
347
467
  text: (r.metadata?.text || r.text || "").slice(0, 200),
468
+ hasImage: r.metadata?.hasImage ?? false,
348
469
  })),
349
470
  totalResults: results.length,
350
471
  };
@@ -354,8 +475,8 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
354
475
  return {
355
476
  tool: aiTool,
356
477
  toolName,
357
- chunksIndexed: allChunks.length,
358
- filesLoaded: fileContents.length,
478
+ chunksIndexed: allChunks.length + imageChunks.length,
479
+ filesLoaded: fileContents.length + imageFiles.length,
359
480
  };
360
481
  }
361
482
  /** @internal Exported for testing only */
@@ -3693,6 +3693,25 @@ async function executeClaudeCodexFallback(args) {
3693
3693
  registerProxyResponseObserver(ctx.metadata, {
3694
3694
  onTerminal: () => childAccounting,
3695
3695
  });
3696
+ const recordCodexUsage = (result) => {
3697
+ ctx.metadata.codexFallbackModel = model;
3698
+ try {
3699
+ tracer?.setModelSubstitution(body.model, model, "openai");
3700
+ tracer?.setServedAccount(codexHeaders["x-neurolink-account"] ?? "unknown", "codex-oauth");
3701
+ if (result?.usage) {
3702
+ tracer?.setUsage({
3703
+ inputTokens: result.usage.input,
3704
+ outputTokens: result.usage.output,
3705
+ cacheReadTokens: result.usage.cacheReadTokens ?? 0,
3706
+ cacheCreationTokens: result.usage.cacheCreationTokens ?? 0,
3707
+ reasoningTokens: result.usage.reasoning,
3708
+ });
3709
+ }
3710
+ }
3711
+ catch {
3712
+ // Instrumentation must never turn completed output into a failed stream.
3713
+ }
3714
+ };
3696
3715
  const codexHeaders = { ...(codexCtx.responseHeaders ?? {}) };
3697
3716
  if (body.stream) {
3698
3717
  const bridge = await createCodexFallbackStream(codexResponse, body.model);
@@ -3712,12 +3731,14 @@ async function executeClaudeCodexFallback(args) {
3712
3731
  if (status >= 400) {
3713
3732
  ctx.metadata.terminalErrorType = errorType;
3714
3733
  }
3734
+ recordCodexUsage(result);
3715
3735
  tracer?.end(status, Date.now() - requestStartTime);
3716
3736
  logFinalRequest(status, account, accountType, errorType, message, {
3717
3737
  inputTokens: result?.usage?.input,
3718
3738
  outputTokens: result?.usage?.output,
3719
3739
  cacheCreationTokens: result?.usage?.cacheCreationTokens,
3720
3740
  cacheReadTokens: result?.usage?.cacheReadTokens,
3741
+ reasoningTokens: result?.usage?.reasoning,
3721
3742
  });
3722
3743
  recordFallbackAttempt({
3723
3744
  provider: "codex",
@@ -3862,6 +3883,7 @@ async function executeClaudeCodexFallback(args) {
3862
3883
  ...(parsed.usage ? { usage: parsed.usage } : {}),
3863
3884
  toolCalls: parsed.toolCalls,
3864
3885
  };
3886
+ recordCodexUsage(parsed);
3865
3887
  tracer?.end(200, Date.now() - requestStartTime);
3866
3888
  const clientResponse = serializeClaudeResponse(internal, body.model);
3867
3889
  observeClaudeJsonOutput(ctx.metadata, clientResponse);
@@ -3870,6 +3892,7 @@ async function executeClaudeCodexFallback(args) {
3870
3892
  outputTokens: parsed.usage?.output,
3871
3893
  cacheCreationTokens: parsed.usage?.cacheCreationTokens,
3872
3894
  cacheReadTokens: parsed.usage?.cacheReadTokens,
3895
+ reasoningTokens: parsed.usage?.reasoning,
3873
3896
  });
3874
3897
  const clientResponseText = JSON.stringify(clientResponse);
3875
3898
  logProxyBody({
@@ -6009,7 +6032,15 @@ function createClaudeRequestRuntimeContext(args) {
6009
6032
  requestId: ctx.requestId,
6010
6033
  method: ctx.method,
6011
6034
  path: ctx.path,
6012
- model: body.model,
6035
+ model: typeof ctx.metadata.codexFallbackModel === "string"
6036
+ ? ctx.metadata.codexFallbackModel
6037
+ : body.model,
6038
+ ...(typeof ctx.metadata.codexFallbackModel === "string"
6039
+ ? {
6040
+ requestedModel: body.model,
6041
+ inputIncludesCachedTokens: false,
6042
+ }
6043
+ : {}),
6013
6044
  stream: !!body.stream,
6014
6045
  toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
6015
6046
  account: finalAccountLabel ?? "",
@@ -6042,6 +6073,9 @@ function createClaudeRequestRuntimeContext(args) {
6042
6073
  ...(extra?.cacheReadTokens !== undefined
6043
6074
  ? { cacheReadTokens: extra.cacheReadTokens }
6044
6075
  : {}),
6076
+ ...(extra?.reasoningTokens !== undefined
6077
+ ? { reasoningTokens: extra.reasoningTokens }
6078
+ : {}),
6045
6079
  ...(traceCtx
6046
6080
  ? { traceId: traceCtx.traceId, spanId: traceCtx.spanId }
6047
6081
  : {}),
@@ -35,19 +35,15 @@ declare function refreshCodexTokenOnce(key: string, refreshToken: string): Promi
35
35
  */
36
36
  declare function loadCodexProxyAccounts(): Promise<CodexRuntimeAccount[]>;
37
37
  /**
38
- * Order accounts fill-first by quota: eligible (not cooling) first, then least
39
- * session utilization, treating unknown quota as "probe first" so a fresh
40
- * account gets observed rather than starved.
38
+ * Order non-cooling accounts ahead of cooling ones, then defer rejected quota.
39
+ * Within each group, probe unknown quota first and otherwise fill the least-used
40
+ * session so fresh accounts get observed rather than starved.
41
41
  */
42
42
  declare function orderCodexAccounts(accounts: CodexRuntimeAccount[], now: number): CodexRuntimeAccount[];
43
43
  /** Build the upstream request headers, replacing client auth with the account's. */
44
44
  declare function buildCodexUpstreamHeaders(clientHeaders: Record<string, string>, account: CodexRuntimeAccount): Record<string, string>;
45
- /**
46
- * Classify a 429 into a cooldown plan. Codex reports primary (session) and
47
- * secondary (weekly) windows; a rejected window cools until its reset, a plain
48
- * burst limit cools briefly and clamps to 15 min.
49
- */
50
- declare function planCodexCooldown(quota: AccountQuota | null, retryAfterMs: number, now: number): {
45
+ /** Honor known quota resets while bounding unscoped or transient account cooldowns. */
46
+ declare function planCodexCooldown(quota: AccountQuota | null, retryAfterMs: number, now: number, errorText?: string): {
51
47
  coolingUntil: number;
52
48
  reason: RateLimitCoolingReason;
53
49
  };
@@ -188,9 +188,9 @@ async function loadCodexProxyAccounts() {
188
188
  return accounts;
189
189
  }
190
190
  /**
191
- * Order accounts fill-first by quota: eligible (not cooling) first, then least
192
- * session utilization, treating unknown quota as "probe first" so a fresh
193
- * account gets observed rather than starved.
191
+ * Order non-cooling accounts ahead of cooling ones, then defer rejected quota.
192
+ * Within each group, probe unknown quota first and otherwise fill the least-used
193
+ * session so fresh accounts get observed rather than starved.
194
194
  */
195
195
  function orderCodexAccounts(accounts, now) {
196
196
  return [...accounts].sort((a, b) => {
@@ -199,8 +199,13 @@ function orderCodexAccounts(accounts, now) {
199
199
  if (aCooling !== bCooling) {
200
200
  return aCooling ? 1 : -1;
201
201
  }
202
- const aUsed = a.quota ? a.quota.sessionUsed : -1;
203
- const bUsed = b.quota ? b.quota.sessionUsed : -1;
202
+ const aRejected = a.quota?.unifiedStatus === "rejected";
203
+ const bRejected = b.quota?.unifiedStatus === "rejected";
204
+ if (aRejected !== bRejected) {
205
+ return aRejected ? 1 : -1;
206
+ }
207
+ const aUsed = a.quota && a.quota.sessionStatus !== "unknown" ? a.quota.sessionUsed : -1;
208
+ const bUsed = b.quota && b.quota.sessionStatus !== "unknown" ? b.quota.sessionUsed : -1;
204
209
  return aUsed - bUsed;
205
210
  });
206
211
  }
@@ -231,12 +236,51 @@ function buildCodexUpstreamHeaders(clientHeaders, account) {
231
236
  }
232
237
  return headers;
233
238
  }
234
- /**
235
- * Classify a 429 into a cooldown plan. Codex reports primary (session) and
236
- * secondary (weekly) windows; a rejected window cools until its reset, a plain
237
- * burst limit cools briefly and clamps to 15 min.
238
- */
239
- function planCodexCooldown(quota, retryAfterMs, now) {
239
+ /** Extract explicit plan-exhaustion evidence without guessing account-wide scope. */
240
+ function parseCodexQuotaError(errorText, now) {
241
+ try {
242
+ const payload = JSON.parse(errorText);
243
+ const error = payload?.error;
244
+ if (!error ||
245
+ typeof error !== "object" ||
246
+ (error.type !== "usage_limit_reached" &&
247
+ error.code !== "usage_limit_reached")) {
248
+ return null;
249
+ }
250
+ const absolute = error.resets_at ?? error.reset_at;
251
+ const relative = error.resets_in_seconds ?? error.reset_after_seconds;
252
+ const resetAt = typeof absolute === "number" && Number.isFinite(absolute) && absolute > 0
253
+ ? absolute > 4_102_444_800
254
+ ? absolute
255
+ : absolute * 1000
256
+ : typeof relative === "number" &&
257
+ Number.isFinite(relative) &&
258
+ relative > 0
259
+ ? now + relative * 1000
260
+ : 0;
261
+ // Only explicit account-wide window evidence permits a long cooldown.
262
+ // A model/surface-scoped or unscoped rejection must not park the entire pool.
263
+ const window = error.limit_type;
264
+ const scoped = error.model || (error.scope !== undefined && error.scope !== "account");
265
+ const scope = scoped
266
+ ? "unknown"
267
+ : window === "primary" || window === "session"
268
+ ? "session"
269
+ : window === "secondary" || window === "weekly"
270
+ ? "weekly"
271
+ : "unknown";
272
+ return {
273
+ errorCode: "usage_limit_reached",
274
+ resetAt: Number.isFinite(resetAt) ? resetAt : 0,
275
+ scope,
276
+ };
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
282
+ /** Honor known quota resets while bounding unscoped or transient account cooldowns. */
283
+ function planCodexCooldown(quota, retryAfterMs, now, errorText = "") {
240
284
  // A reported reset can already be in the past — a stale header, or a clock
241
285
  // skew. Taken literally the account is eligible again immediately and the
242
286
  // pool re-sends to something the provider just rejected.
@@ -255,8 +299,25 @@ function planCodexCooldown(quota, retryAfterMs, now) {
255
299
  };
256
300
  }
257
301
  }
302
+ const bodyQuota = parseCodexQuotaError(errorText, now);
303
+ if (bodyQuota) {
304
+ if (bodyQuota.scope !== "unknown" && bodyQuota.resetAt > 0) {
305
+ return {
306
+ coolingUntil: Math.max(bodyQuota.resetAt, floor),
307
+ reason: bodyQuota.scope,
308
+ };
309
+ }
310
+ return {
311
+ coolingUntil: now +
312
+ Math.min(MAX_TRANSIENT_COOLDOWN_MS, Math.max(bodyQuota.resetAt - now, retryAfterMs, DEFAULT_TRANSIENT_COOLDOWN_MS)),
313
+ reason: "unified",
314
+ };
315
+ }
258
316
  const delay = Math.min(MAX_TRANSIENT_COOLDOWN_MS, Math.max(retryAfterMs, DEFAULT_TRANSIENT_COOLDOWN_MS));
259
- return { coolingUntil: now + delay, reason: "transient" };
317
+ return {
318
+ coolingUntil: now + delay,
319
+ reason: quota?.unifiedStatus === "rejected" ? "unified" : "transient",
320
+ };
260
321
  }
261
322
  /** Set the x-neurolink-* attribution headers on the context. */
262
323
  function publishCodexHeaders(ctx, account, attempt, quota) {
@@ -268,9 +329,14 @@ function publishCodexHeaders(ctx, account, attempt, quota) {
268
329
  ctx.responseHeaders["x-neurolink-served-by"] = "codex";
269
330
  ctx.responseHeaders["x-neurolink-attempt"] = String(attempt);
270
331
  ctx.responseHeaders["x-neurolink-quota-source"] = quota ? "live" : "none";
271
- if (quota) {
332
+ if (quota && quota.sessionStatus !== "unknown") {
272
333
  ctx.responseHeaders["x-neurolink-quota-session-left-pct"] = String(Math.round((1 - quota.sessionUsed) * 100));
273
- ctx.responseHeaders["x-neurolink-weekly-left-pct"] = String(Math.round((1 - quota.weeklyUsed) * 100));
334
+ }
335
+ if (quota && quota.weeklyStatus !== "unknown") {
336
+ const remaining = String(Math.round((1 - quota.weeklyUsed) * 100));
337
+ ctx.responseHeaders["x-neurolink-quota-weekly-left-pct"] = remaining;
338
+ // Preserve the original Codex header for existing clients.
339
+ ctx.responseHeaders["x-neurolink-weekly-left-pct"] = remaining;
274
340
  }
275
341
  }
276
342
  /** Core pooled handler for POST /backend-api/codex/responses. */
@@ -389,6 +455,7 @@ async function executeCodexResponsesRequest(ctx) {
389
455
  // This is the cost provider. accountKey and the response header identify
390
456
  // the actual Codex pool that supplied the credential.
391
457
  provider: "openai",
458
+ inputIncludesCachedTokens: true,
392
459
  ...tracer?.getTraceContext(),
393
460
  ...(reasoningEffort ? { reasoningEffort } : {}),
394
461
  firstUsefulOutputStatus: extra.firstUsefulOutputMs !== undefined ? "observed" : "not_observed",
@@ -679,11 +746,20 @@ async function executeCodexResponsesRequest(ctx) {
679
746
  outputTokens: usage.outputTokens,
680
747
  cacheReadTokens: usage.cacheReadTokens,
681
748
  cacheCreationTokens: usage.cacheCreationTokens,
749
+ reasoningTokens: usage.reasoningTokensObserved
750
+ ? usage.reasoningTokens
751
+ : undefined,
682
752
  }
683
753
  : {};
684
754
  if (usage) {
685
755
  try {
686
- tracer?.setUsage(usage);
756
+ tracer?.setUsage({
757
+ ...usage,
758
+ reasoningTokens: usage.reasoningTokensObserved
759
+ ? usage.reasoningTokens
760
+ : undefined,
761
+ inputIncludesCachedTokens: true,
762
+ });
687
763
  }
688
764
  catch {
689
765
  // Pricing/metrics must never change the stream outcome.
@@ -825,7 +901,9 @@ async function executeCodexResponsesRequest(ctx) {
825
901
  saveAccountQuota(account.key, quota).catch(() => undefined);
826
902
  }
827
903
  const retryAfterMs = parseRetryAfterMs(upstream.headers.get("retry-after"));
828
- const plan = planCodexCooldown(quota, retryAfterMs, Date.now());
904
+ const now = Date.now();
905
+ const bodyQuota = parseCodexQuotaError(errText, now);
906
+ const plan = planCodexCooldown(quota, retryAfterMs, now, errText);
829
907
  await saveAccountCooldown(account.key, plan.coolingUntil, plan.reason).catch(() => undefined);
830
908
  const rateLimitKind = plan.reason === "transient" ? "transient" : "quota";
831
909
  const errorMessage = summarizeCodexUpstreamError(errText, "Codex account rate-limited");
@@ -833,11 +911,21 @@ async function executeCodexResponsesRequest(ctx) {
833
911
  writeAttempt(account, attempt, attemptStartedAt, upstream.status, {
834
912
  errorType: "rate_limit_error",
835
913
  errorMessage,
836
- retryable: true,
914
+ retryable: rateLimitKind === "transient",
837
915
  rateLimitKind,
838
916
  cooldownReason: plan.reason,
917
+ ...(bodyQuota
918
+ ? {
919
+ errorCode: bodyQuota.errorCode,
920
+ quotaResetAt: bodyQuota.resetAt || undefined,
921
+ quotaScope: bodyQuota.scope,
922
+ }
923
+ : {}),
839
924
  });
840
- lastFailure = { errorType: "rate_limit_error" };
925
+ lastFailure = {
926
+ errorType: "rate_limit_error",
927
+ errorCode: bodyQuota?.errorCode,
928
+ };
841
929
  lastErrorStatus = 429;
842
930
  lastErrorMessage = "Codex account rate-limited";
843
931
  break; // rotate
@@ -64,6 +64,8 @@ export type CodexRateLimitWindow = {
64
64
  };
65
65
  /** Codex rate-limit block: a primary (short) and secondary (long) window. */
66
66
  export type CodexRateLimits = {
67
+ allowed?: boolean;
68
+ limit_reached?: boolean;
67
69
  primary?: CodexRateLimitWindow | null;
68
70
  secondary?: CodexRateLimitWindow | null;
69
71
  };
@@ -73,6 +75,8 @@ export type CodexUsageResponse = {
73
75
  rate_limits?: CodexRateLimits | null;
74
76
  /** Current ChatGPT WHAM account-usage payload. */
75
77
  rate_limit?: {
78
+ allowed?: boolean;
79
+ limit_reached?: boolean;
76
80
  primary_window?: CodexRateLimitWindow | null;
77
81
  secondary_window?: CodexRateLimitWindow | null;
78
82
  } | null;
@@ -86,6 +90,12 @@ export type CodexUsageFetchResult = {
86
90
  ok: false;
87
91
  reason: "not_oauth" | "auth" | "rate_limited" | "http" | "network" | "parse";
88
92
  };
93
+ /** Explicit plan-exhaustion evidence from a Codex error response. */
94
+ export type CodexQuotaError = {
95
+ errorCode: string;
96
+ resetAt: number;
97
+ scope: "session" | "weekly" | "unknown";
98
+ };
89
99
  /** A Codex account with its runtime cooldown/quota state hydrated from disk. */
90
100
  export type CodexRuntimeAccount = {
91
101
  key: string;
@@ -28,6 +28,18 @@ export type SupportedModelName = BedrockModels | DeepSeekModels | OpenAIModels |
28
28
  * Extract provider names from enum
29
29
  */
30
30
  export type ProviderName = (typeof AIProviderName)[keyof typeof AIProviderName];
31
+ /**
32
+ * Multi-modal embedding input — accepts text, image, or both.
33
+ * Used by providers that support multi-modal embeddings (e.g. Bedrock Titan Image, Nova Multimodal).
34
+ */
35
+ export type EmbedInput = {
36
+ /** Text content to embed */
37
+ text?: string;
38
+ /** Image data as Buffer or base64 string */
39
+ image?: Buffer | string;
40
+ /** MIME type of the image (e.g. "image/png", "image/jpeg") */
41
+ mimeType?: string;
42
+ };
31
43
  /**
32
44
  * Provider status information
33
45
  */
@@ -741,7 +753,15 @@ export type AIProvider = {
741
753
  stream(optionsOrPrompt: StreamOptions | string, analysisSchema?: ValidationSchema): Promise<StreamResult>;
742
754
  generate(optionsOrPrompt: TextGenerationOptions | string, analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
743
755
  gen(optionsOrPrompt: TextGenerationOptions | string, analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
744
- embed(text: string, modelName?: string): Promise<number[]>;
756
+ /**
757
+ * Generate an embedding vector for text or multi-modal input.
758
+ * Accepts either a plain string (text-only) or an EmbedInput object
759
+ * for multi-modal embeddings (text + image).
760
+ */
761
+ embed(input: string | EmbedInput, modelName?: string): Promise<number[]>;
762
+ /**
763
+ * Generate embedding vectors for multiple text inputs in batch.
764
+ */
745
765
  embedMany(texts: string[], modelName?: string): Promise<number[][]>;
746
766
  setupToolExecutor(sdk: {
747
767
  customTools: Map<string, unknown>;