@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.
- package/CHANGELOG.md +2 -6
- package/dist/browser/neurolink.min.js +397 -397
- package/dist/cli/commands/auth.js +6 -2
- package/dist/cli/commands/proxy.d.ts +4 -0
- package/dist/cli/commands/proxy.js +119 -39
- package/dist/cli/index.js +8 -18
- package/dist/cli/lifecycle.d.ts +8 -0
- package/dist/cli/lifecycle.js +90 -0
- package/dist/core/baseProvider.d.ts +6 -3
- package/dist/core/baseProvider.js +7 -3
- package/dist/providers/amazonBedrock/client.d.ts +7 -5
- package/dist/providers/amazonBedrock/client.js +125 -16
- package/dist/providers/cohere.d.ts +2 -2
- package/dist/providers/cohere.js +5 -1
- package/dist/providers/googleAiStudio/client.d.ts +3 -3
- package/dist/providers/googleAiStudio/client.js +6 -2
- package/dist/providers/googleVertex/client.d.ts +2 -2
- package/dist/providers/googleVertex/client.js +5 -1
- package/dist/providers/jina.d.ts +2 -2
- package/dist/providers/jina.js +5 -1
- package/dist/providers/litellm/client.d.ts +2 -2
- package/dist/providers/litellm/client.js +5 -1
- package/dist/providers/ollama/client.d.ts +2 -2
- package/dist/providers/ollama/client.js +5 -1
- package/dist/providers/openAI/client.d.ts +3 -3
- package/dist/providers/openAI/client.js +6 -2
- package/dist/providers/voyage.d.ts +2 -2
- package/dist/providers/voyage.js +5 -1
- package/dist/proxy/codexAccountUsage.js +18 -6
- package/dist/proxy/codexFallback.js +17 -10
- package/dist/proxy/codexUsage.js +3 -0
- package/dist/proxy/proxyAnalysis.js +14 -11
- package/dist/proxy/proxyTokenUsage.d.ts +7 -0
- package/dist/proxy/proxyTokenUsage.js +21 -0
- package/dist/proxy/proxyTracer.d.ts +2 -0
- package/dist/proxy/proxyTracer.js +21 -40
- package/dist/proxy/quotaHeaders.js +7 -3
- package/dist/rag/document/MDocument.d.ts +19 -1
- package/dist/rag/document/MDocument.js +30 -3
- package/dist/rag/document/imageLoader.d.ts +55 -0
- package/dist/rag/document/imageLoader.js +183 -0
- package/dist/rag/index.d.ts +1 -0
- package/dist/rag/index.js +2 -0
- package/dist/rag/pipeline/RAGPipeline.d.ts +54 -1
- package/dist/rag/pipeline/RAGPipeline.js +358 -1
- package/dist/rag/ragIntegration.js +146 -25
- package/dist/server/routes/claudeProxyRoutes.js +35 -1
- package/dist/server/routes/codexProxyRoutes.d.ts +5 -9
- package/dist/server/routes/codexProxyRoutes.js +106 -18
- package/dist/types/codex.d.ts +10 -0
- package/dist/types/providers.d.ts +21 -1
- package/dist/types/proxy.d.ts +24 -6
- package/dist/types/rag.d.ts +144 -0
- package/dist/types/rag.js +12 -1
- package/dist/utils/pricing.js +4 -2
- package/docs-site/static/search-index.json +1 -1
- 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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
203
|
-
const
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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 = {
|
|
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
|
package/dist/types/codex.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|