@juspay/neurolink 12.9.0 → 12.9.2
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 +3 -3
- package/dist/browser/neurolink.min.js +344 -344
- package/dist/cli/commands/proxy.js +35 -2
- package/dist/constants/enums.d.ts +17 -2
- package/dist/constants/enums.js +17 -2
- package/dist/core/baseProvider.d.ts +56 -0
- package/dist/core/baseProvider.js +308 -17
- package/dist/providers/catalog/cerebras.json +15 -5
- package/dist/providers/catalog/cloudflare.json +17 -13
- package/dist/providers/catalog/fireworks.json +74 -43
- package/dist/providers/catalog/groq.json +81 -32
- package/dist/providers/catalog/loader.js +3 -0
- package/dist/providers/catalog/mistral.json +27 -23
- package/dist/providers/catalog/provider-catalog.schema.json +5 -0
- package/dist/providers/catalog/sambanova.json +36 -11
- package/dist/providers/catalog/schema.d.ts +2 -1
- package/dist/providers/catalog/schema.js +1 -0
- package/dist/providers/catalog/xai.json +39 -26
- package/dist/providers/configuredOpenAICompat.d.ts +18 -1
- package/dist/providers/configuredOpenAICompat.js +48 -0
- package/dist/providers/openaiChatCompletionsBase.d.ts +15 -0
- package/dist/providers/openaiChatCompletionsBase.js +20 -0
- package/dist/proxy/proxyActivity.d.ts +2 -0
- package/dist/proxy/proxyActivity.js +18 -0
- package/dist/server/routes/codexProxyRoutes.js +3 -2
- package/dist/types/providerCatalog.d.ts +6 -0
- package/dist/types/providers.d.ts +3 -0
- package/package.json +2 -1
|
@@ -26,7 +26,7 @@ import { ProxyRuntimeConfigStore } from "../../proxy/runtimeConfig.js";
|
|
|
26
26
|
import { startProxyLogCleanupScheduler } from "../../proxy/logCleanupScheduler.js";
|
|
27
27
|
import { anthropicAccountKeysEqual, createAccountAllowlist, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
28
28
|
import { resolveProxyStatusAccountIdentity } from "../../proxy/codexAccountUsage.js";
|
|
29
|
-
import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../proxy/proxyActivity.js";
|
|
29
|
+
import { beginProxyRequest, getProxyActivitySnapshot, takeProxyResponseObservers, trackProxyResponse, } from "../../proxy/proxyActivity.js";
|
|
30
30
|
import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
|
|
31
31
|
import { describeInstallFailure, getGlobalInstallArgs, isTransientInstallFailure, resolveGlobalInstaller, validateInstalledVersion, } from "../../proxy/globalInstaller.js";
|
|
32
32
|
import { startUpdaterWorkerSupervisor } from "../../proxy/updaterSupervisor.js";
|
|
@@ -1120,6 +1120,27 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1120
1120
|
responseStatus,
|
|
1121
1121
|
elapsedMs: performance.now() - startedMonotonicMs,
|
|
1122
1122
|
});
|
|
1123
|
+
const routeResponseObservers = takeProxyResponseObservers(metadata);
|
|
1124
|
+
const notifyRouteFirstChunk = (details) => {
|
|
1125
|
+
for (const observer of routeResponseObservers) {
|
|
1126
|
+
try {
|
|
1127
|
+
observer.onFirstChunk?.(details);
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
// Route-level accounting must never interfere with the relay.
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
const notifyRouteTerminal = (details) => {
|
|
1135
|
+
for (const observer of routeResponseObservers) {
|
|
1136
|
+
try {
|
|
1137
|
+
observer.onTerminal?.(details);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
// Route-level accounting must never interfere with the relay.
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1123
1144
|
c.res = trackProxyResponse(c.res, finish, {
|
|
1124
1145
|
onFirstChunk: ({ observedBodyBytes, responseChunks }) => {
|
|
1125
1146
|
logProxyLifecycleEvent({
|
|
@@ -1137,6 +1158,10 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1137
1158
|
responseChunks,
|
|
1138
1159
|
elapsedMs: performance.now() - startedMonotonicMs,
|
|
1139
1160
|
});
|
|
1161
|
+
notifyRouteFirstChunk({
|
|
1162
|
+
observedBodyBytes,
|
|
1163
|
+
responseChunks,
|
|
1164
|
+
});
|
|
1140
1165
|
},
|
|
1141
1166
|
onTerminal: ({ outcome, observedBodyBytes, responseChunks }) => {
|
|
1142
1167
|
logProxyLifecycleEvent({
|
|
@@ -1157,6 +1182,11 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1157
1182
|
errorType: metadata.terminalErrorType,
|
|
1158
1183
|
errorCode: metadata.terminalErrorCode,
|
|
1159
1184
|
});
|
|
1185
|
+
notifyRouteTerminal({
|
|
1186
|
+
outcome,
|
|
1187
|
+
observedBodyBytes,
|
|
1188
|
+
responseChunks,
|
|
1189
|
+
});
|
|
1160
1190
|
},
|
|
1161
1191
|
});
|
|
1162
1192
|
}
|
|
@@ -1484,7 +1514,10 @@ export async function createProxyStartApp(params) {
|
|
|
1484
1514
|
neurolink: params.neurolink,
|
|
1485
1515
|
toolRegistry: params.neurolink.getToolRegistry(),
|
|
1486
1516
|
timestamp: Date.now(),
|
|
1487
|
-
metadata
|
|
1517
|
+
// Keep route terminal observers on the runtime request metadata so the
|
|
1518
|
+
// outer response tracker can notify them without adding a second body
|
|
1519
|
+
// wrapper to streaming routes.
|
|
1520
|
+
metadata: (metadata ?? {}),
|
|
1488
1521
|
// Route handlers publish limit/quota headers here. Only the streaming
|
|
1489
1522
|
// paths build their own Response (and set headers directly); every
|
|
1490
1523
|
// JSON and error path returns a plain object, so without this the
|
|
@@ -823,17 +823,29 @@ export declare enum CloudflareModels {
|
|
|
823
823
|
GEMMA_2B_IT_LORA = "@cf/google/gemma-2b-it-lora"
|
|
824
824
|
}
|
|
825
825
|
export declare enum FireworksModels {
|
|
826
|
+
KIMI_K2P6 = "accounts/fireworks/models/kimi-k2p6",
|
|
827
|
+
GPT_OSS_120B = "accounts/fireworks/models/gpt-oss-120b",
|
|
828
|
+
KIMI_K3 = "accounts/fireworks/models/kimi-k3",
|
|
829
|
+
QWEN3P8_MAX = "accounts/fireworks/models/qwen3p8-max",
|
|
830
|
+
GLM_5P3 = "accounts/fireworks/models/glm-5p3",
|
|
831
|
+
MINIMAX_M3 = "accounts/fireworks/models/minimax-m3",
|
|
832
|
+
DEEPSEEK_V4_FLASH_0731 = "accounts/fireworks/models/deepseek-v4-flash-0731",
|
|
826
833
|
DEEPSEEK_V4_PRO = "accounts/fireworks/models/deepseek-v4-pro",
|
|
827
834
|
GLM_5P1 = "accounts/fireworks/models/glm-5p1",
|
|
828
835
|
GLM_5 = "accounts/fireworks/models/glm-5",
|
|
829
|
-
KIMI_K2P6 = "accounts/fireworks/models/kimi-k2p6",
|
|
830
836
|
KIMI_K2P5 = "accounts/fireworks/models/kimi-k2p5",
|
|
831
|
-
GPT_OSS_120B = "accounts/fireworks/models/gpt-oss-120b",
|
|
832
837
|
LLAMA_V3P2_90B_VISION_INSTRUCT = "accounts/fireworks/models/llama-v3p2-90b-vision-instruct",
|
|
833
838
|
LLAMA_V3P2_11B_VISION_INSTRUCT = "accounts/fireworks/models/llama-v3p2-11b-vision-instruct",
|
|
834
839
|
PHI_3_VISION_128K_INSTRUCT = "accounts/fireworks/models/phi-3-vision-128k-instruct"
|
|
835
840
|
}
|
|
836
841
|
export declare enum GroqModels {
|
|
842
|
+
GPT_OSS_120B = "openai/gpt-oss-120b",
|
|
843
|
+
GPT_OSS_20B = "openai/gpt-oss-20b",
|
|
844
|
+
QWEN_3_8_27B = "qwen/qwen3.8-27b",
|
|
845
|
+
QWEN_3_6_27B = "qwen/qwen3.6-27b",
|
|
846
|
+
COMPOUND = "groq/compound",
|
|
847
|
+
COMPOUND_MINI = "groq/compound-mini",
|
|
848
|
+
ALLAM_2_7B = "allam-2-7b",
|
|
837
849
|
LLAMA_3_3_70B_VERSATILE = "llama-3.3-70b-versatile",
|
|
838
850
|
LLAMA_3_1_8B_INSTANT = "llama-3.1-8b-instant",
|
|
839
851
|
GEMMA_2_9B_IT = "gemma2-9b-it",
|
|
@@ -907,6 +919,9 @@ export declare enum TogetherAIModels {
|
|
|
907
919
|
WIZARDLM_2_8X22B = "microsoft/WizardLM-2-8x22B"
|
|
908
920
|
}
|
|
909
921
|
export declare enum XaiModels {
|
|
922
|
+
GROK_4_6 = "grok-4.6",
|
|
923
|
+
GROK_4_5 = "grok-4.5",
|
|
924
|
+
GROK_4_3 = "grok-4.3",
|
|
910
925
|
GROK_3 = "grok-3",
|
|
911
926
|
GROK_3_MINI = "grok-3-mini",
|
|
912
927
|
GROK_2_LATEST = "grok-2-latest",
|
package/dist/constants/enums.js
CHANGED
|
@@ -1074,18 +1074,30 @@ export var CloudflareModels;
|
|
|
1074
1074
|
})(CloudflareModels || (CloudflareModels = {}));
|
|
1075
1075
|
export var FireworksModels;
|
|
1076
1076
|
(function (FireworksModels) {
|
|
1077
|
+
FireworksModels["KIMI_K2P6"] = "accounts/fireworks/models/kimi-k2p6";
|
|
1078
|
+
FireworksModels["GPT_OSS_120B"] = "accounts/fireworks/models/gpt-oss-120b";
|
|
1079
|
+
FireworksModels["KIMI_K3"] = "accounts/fireworks/models/kimi-k3";
|
|
1080
|
+
FireworksModels["QWEN3P8_MAX"] = "accounts/fireworks/models/qwen3p8-max";
|
|
1081
|
+
FireworksModels["GLM_5P3"] = "accounts/fireworks/models/glm-5p3";
|
|
1082
|
+
FireworksModels["MINIMAX_M3"] = "accounts/fireworks/models/minimax-m3";
|
|
1083
|
+
FireworksModels["DEEPSEEK_V4_FLASH_0731"] = "accounts/fireworks/models/deepseek-v4-flash-0731";
|
|
1077
1084
|
FireworksModels["DEEPSEEK_V4_PRO"] = "accounts/fireworks/models/deepseek-v4-pro";
|
|
1078
1085
|
FireworksModels["GLM_5P1"] = "accounts/fireworks/models/glm-5p1";
|
|
1079
1086
|
FireworksModels["GLM_5"] = "accounts/fireworks/models/glm-5";
|
|
1080
|
-
FireworksModels["KIMI_K2P6"] = "accounts/fireworks/models/kimi-k2p6";
|
|
1081
1087
|
FireworksModels["KIMI_K2P5"] = "accounts/fireworks/models/kimi-k2p5";
|
|
1082
|
-
FireworksModels["GPT_OSS_120B"] = "accounts/fireworks/models/gpt-oss-120b";
|
|
1083
1088
|
FireworksModels["LLAMA_V3P2_90B_VISION_INSTRUCT"] = "accounts/fireworks/models/llama-v3p2-90b-vision-instruct";
|
|
1084
1089
|
FireworksModels["LLAMA_V3P2_11B_VISION_INSTRUCT"] = "accounts/fireworks/models/llama-v3p2-11b-vision-instruct";
|
|
1085
1090
|
FireworksModels["PHI_3_VISION_128K_INSTRUCT"] = "accounts/fireworks/models/phi-3-vision-128k-instruct";
|
|
1086
1091
|
})(FireworksModels || (FireworksModels = {}));
|
|
1087
1092
|
export var GroqModels;
|
|
1088
1093
|
(function (GroqModels) {
|
|
1094
|
+
GroqModels["GPT_OSS_120B"] = "openai/gpt-oss-120b";
|
|
1095
|
+
GroqModels["GPT_OSS_20B"] = "openai/gpt-oss-20b";
|
|
1096
|
+
GroqModels["QWEN_3_8_27B"] = "qwen/qwen3.8-27b";
|
|
1097
|
+
GroqModels["QWEN_3_6_27B"] = "qwen/qwen3.6-27b";
|
|
1098
|
+
GroqModels["COMPOUND"] = "groq/compound";
|
|
1099
|
+
GroqModels["COMPOUND_MINI"] = "groq/compound-mini";
|
|
1100
|
+
GroqModels["ALLAM_2_7B"] = "allam-2-7b";
|
|
1089
1101
|
GroqModels["LLAMA_3_3_70B_VERSATILE"] = "llama-3.3-70b-versatile";
|
|
1090
1102
|
GroqModels["LLAMA_3_1_8B_INSTANT"] = "llama-3.1-8b-instant";
|
|
1091
1103
|
GroqModels["GEMMA_2_9B_IT"] = "gemma2-9b-it";
|
|
@@ -1164,6 +1176,9 @@ export var TogetherAIModels;
|
|
|
1164
1176
|
})(TogetherAIModels || (TogetherAIModels = {}));
|
|
1165
1177
|
export var XaiModels;
|
|
1166
1178
|
(function (XaiModels) {
|
|
1179
|
+
XaiModels["GROK_4_6"] = "grok-4.6";
|
|
1180
|
+
XaiModels["GROK_4_5"] = "grok-4.5";
|
|
1181
|
+
XaiModels["GROK_4_3"] = "grok-4.3";
|
|
1167
1182
|
XaiModels["GROK_3"] = "grok-3";
|
|
1168
1183
|
XaiModels["GROK_3_MINI"] = "grok-3-mini";
|
|
1169
1184
|
XaiModels["GROK_2_LATEST"] = "grok-2-latest";
|
|
@@ -52,6 +52,37 @@ export declare abstract class BaseProvider implements AIProvider {
|
|
|
52
52
|
* When tools are involved, falls back to generate() with synthetic streaming
|
|
53
53
|
*/
|
|
54
54
|
stream(optionsOrPrompt: StreamOptions | string, analysisSchema?: ValidationSchema): Promise<StreamResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Swap in a fallback model when a stream dies on a retired id before it has
|
|
57
|
+
* produced anything.
|
|
58
|
+
*
|
|
59
|
+
* OpenAI-compatible streaming is lazy: executeStream() returns a
|
|
60
|
+
* StreamResult without touching the network, and the request only goes out
|
|
61
|
+
* on the consumer's first pull. A retired model therefore does NOT fail in
|
|
62
|
+
* stream()'s try/catch — it fails deep inside iteration. This wrapper sits
|
|
63
|
+
* UNDER wrapStreamWithLifecycleCallbacks precisely so that a failed first
|
|
64
|
+
* attempt is invisible: onChunk, onFinish and onError all belong to the
|
|
65
|
+
* layer above and never observe it.
|
|
66
|
+
*
|
|
67
|
+
* The `yielded` guards are the safety property. A stream is only retried
|
|
68
|
+
* while it has emitted nothing; once a single chunk has reached the
|
|
69
|
+
* consumer the output is committed, and any later failure propagates
|
|
70
|
+
* untouched rather than replaying a half-delivered response.
|
|
71
|
+
*/
|
|
72
|
+
private withStreamModelFallback;
|
|
73
|
+
/**
|
|
74
|
+
* The eager half of the retired-model stream fallback, for providers whose
|
|
75
|
+
* executeStream() reaches the network before returning (see
|
|
76
|
+
* runGenerateWithModelFallback for the generate() half and the reasoning).
|
|
77
|
+
*
|
|
78
|
+
* Returns a working StreamResult when a fallback model succeeds, or
|
|
79
|
+
* undefined to mean "not recoverable — carry on with the original error".
|
|
80
|
+
* Returning rather than throwing is deliberate: every existing path in the
|
|
81
|
+
* caller's catch (the broad fake-streaming fallback, the terminal-error
|
|
82
|
+
* re-throw, the onError firing) is left exactly as it was, so behaviour
|
|
83
|
+
* changes only when a fallback actually succeeds.
|
|
84
|
+
*/
|
|
85
|
+
private retryStreamWithFallbackModel;
|
|
55
86
|
/**
|
|
56
87
|
* Wrap a StreamResult with consumer-facing lifecycle callbacks.
|
|
57
88
|
*
|
|
@@ -212,6 +243,31 @@ export declare abstract class BaseProvider implements AIProvider {
|
|
|
212
243
|
*/
|
|
213
244
|
ensureModelLimits(): Promise<void>;
|
|
214
245
|
generate(optionsOrPrompt: TextGenerationOptions | string, _analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
|
|
246
|
+
/**
|
|
247
|
+
* Models the catalog names are the models a vendor served when the entry was
|
|
248
|
+
* last verified. Vendors retire them without warning, and until this existed
|
|
249
|
+
* the runtime had no answer for that: an InvalidModelError is classified
|
|
250
|
+
* non-retryable (correctly — another *provider* cannot fix a bad model id),
|
|
251
|
+
* so the fallback chain stopped dead and the caller got an error even though
|
|
252
|
+
* the same provider was still serving four other models listed right there
|
|
253
|
+
* in the catalog's `fallbacks`.
|
|
254
|
+
*
|
|
255
|
+
* So on an invalid-model error only, walk this provider's fallbacks. Every
|
|
256
|
+
* switch is a loud WARN: the caller asked for one model and is getting
|
|
257
|
+
* another, which they must be able to see in the logs. Any other error type
|
|
258
|
+
* propagates untouched on the first attempt.
|
|
259
|
+
*
|
|
260
|
+
* stream() gets the same treatment via retryStreamWithFallbackModel, which
|
|
261
|
+
* is safe for the same reason it is cheap: the retry happens before any
|
|
262
|
+
* lifecycle callback has fired and before a chunk has reached the consumer,
|
|
263
|
+
* so there is no observable output to replay.
|
|
264
|
+
*/
|
|
265
|
+
private runGenerateWithModelFallback;
|
|
266
|
+
/**
|
|
267
|
+
* Model ids to try when this provider rejects its current model as invalid.
|
|
268
|
+
* Empty by default; catalog-driven providers return their `fallbacks`.
|
|
269
|
+
*/
|
|
270
|
+
protected getModelFallbacks(): string[];
|
|
215
271
|
/**
|
|
216
272
|
* Alias for generate method - implements AIProvider interface
|
|
217
273
|
*/
|
|
@@ -7,7 +7,18 @@ import { modelSupports } from "../models/modelRegistry.js";
|
|
|
7
7
|
import { resolveRequestKind } from "./resolveRequestKind.js";
|
|
8
8
|
import { ATTR, tracers } from "../telemetry/index.js";
|
|
9
9
|
import { ERROR_CODES, isAbortError, NeuroLinkError, } from "../utils/errorHandling.js";
|
|
10
|
-
import { ProviderError } from "../types/index.js";
|
|
10
|
+
import { InvalidModelError, ProviderError } from "../types/index.js";
|
|
11
|
+
/**
|
|
12
|
+
* `instanceof` is not reliable here. The published bundle and the source tree
|
|
13
|
+
* are separate module graphs, so an InvalidModelError built by the error
|
|
14
|
+
* classifier can fail an `instanceof` against the class this file imported —
|
|
15
|
+
* silently, with a clean typecheck. The constructor-name check is the same
|
|
16
|
+
* approach the OTel error-type mapping below already uses.
|
|
17
|
+
*/
|
|
18
|
+
function isInvalidModelError(error) {
|
|
19
|
+
return (error instanceof InvalidModelError ||
|
|
20
|
+
(error instanceof Error && error.constructor.name === "InvalidModelError"));
|
|
21
|
+
}
|
|
11
22
|
import { sanitizeErrorCause } from "../utils/logSanitize.js";
|
|
12
23
|
import { createAnalytics as buildAnalytics } from "./analytics.js";
|
|
13
24
|
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
|
|
@@ -288,9 +299,18 @@ export class BaseProvider {
|
|
|
288
299
|
// anything that doesn't go through streamText) bypass it. Wrapping
|
|
289
300
|
// here makes the callbacks fire for every provider, regardless of
|
|
290
301
|
// streaming implementation.
|
|
291
|
-
return this.wrapStreamWithLifecycleCallbacks(realStreamResult, options);
|
|
302
|
+
return this.wrapStreamWithLifecycleCallbacks(this.withStreamModelFallback(realStreamResult, options, analysisSchema), options);
|
|
292
303
|
}
|
|
293
304
|
catch (realStreamError) {
|
|
305
|
+
// Retired-model fallback runs FIRST, before any lifecycle callback has
|
|
306
|
+
// fired and before a single chunk has reached the consumer: onChunk and
|
|
307
|
+
// onFinish are only wired on the success path above, and onError fires
|
|
308
|
+
// further down this same catch. That ordering is what makes retrying a
|
|
309
|
+
// stream safe at all — nothing observable has happened yet.
|
|
310
|
+
const recovered = await this.retryStreamWithFallbackModel(realStreamError, options, analysisSchema);
|
|
311
|
+
if (recovered) {
|
|
312
|
+
return recovered;
|
|
313
|
+
}
|
|
294
314
|
// The fallback is BROAD, not narrow: only the terminal errors listed
|
|
295
315
|
// below (abort, timeout, 401/403, quota, rate limit, authentication)
|
|
296
316
|
// re-throw. Every other failure — including a genuine configuration or
|
|
@@ -337,6 +357,203 @@ export class BaseProvider {
|
|
|
337
357
|
}
|
|
338
358
|
}
|
|
339
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Swap in a fallback model when a stream dies on a retired id before it has
|
|
362
|
+
* produced anything.
|
|
363
|
+
*
|
|
364
|
+
* OpenAI-compatible streaming is lazy: executeStream() returns a
|
|
365
|
+
* StreamResult without touching the network, and the request only goes out
|
|
366
|
+
* on the consumer's first pull. A retired model therefore does NOT fail in
|
|
367
|
+
* stream()'s try/catch — it fails deep inside iteration. This wrapper sits
|
|
368
|
+
* UNDER wrapStreamWithLifecycleCallbacks precisely so that a failed first
|
|
369
|
+
* attempt is invisible: onChunk, onFinish and onError all belong to the
|
|
370
|
+
* layer above and never observe it.
|
|
371
|
+
*
|
|
372
|
+
* The `yielded` guards are the safety property. A stream is only retried
|
|
373
|
+
* while it has emitted nothing; once a single chunk has reached the
|
|
374
|
+
* consumer the output is committed, and any later failure propagates
|
|
375
|
+
* untouched rather than replaying a half-delivered response.
|
|
376
|
+
*/
|
|
377
|
+
withStreamModelFallback(result, options, analysisSchema) {
|
|
378
|
+
// The caller owns fallback order (the Claude proxy sets this): hand the
|
|
379
|
+
// stream back untouched so an invalid model surfaces as exactly that.
|
|
380
|
+
if (options.disableInternalFallback === true) {
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
const provider = this;
|
|
384
|
+
const source = result.stream;
|
|
385
|
+
// Iterate through explicit iterators so a consumer break can be forwarded
|
|
386
|
+
// to the one that is actually in flight (see the cancel hook below): a
|
|
387
|
+
// generator's own return() is queued behind a pending next(), and a
|
|
388
|
+
// source that is waiting on a slow vendor would never see the break.
|
|
389
|
+
const sourceIterator = source[Symbol.asyncIterator]();
|
|
390
|
+
let activeStream = source;
|
|
391
|
+
let activeIterator = sourceIterator;
|
|
392
|
+
const iterableOf = (iterator) => ({
|
|
393
|
+
[Symbol.asyncIterator]: () => iterator,
|
|
394
|
+
});
|
|
395
|
+
// A failing stream still emits one chunk before it throws: the no-output
|
|
396
|
+
// sentinel, {content: "", metadata: {noOutput: true, ...}}. That is a
|
|
397
|
+
// marker, not output, so it must not count as committed — otherwise the
|
|
398
|
+
// guard below blocks every retry it exists to allow. Only that sentinel
|
|
399
|
+
// and a bare empty text chunk are withheld; everything else — reasoning
|
|
400
|
+
// deltas ({content: "", reasoning}), tool-call deltas, audio and image
|
|
401
|
+
// chunks — IS output, commits the stream, and is yielded immediately so
|
|
402
|
+
// real-time streaming is untouched. Withheld chunks are released in order
|
|
403
|
+
// once real output arrives, at end of stream, or before any error that is
|
|
404
|
+
// not retried; they are dropped only when a fallback model takes over.
|
|
405
|
+
const isWithheld = (chunk) => {
|
|
406
|
+
if (typeof chunk === "string") {
|
|
407
|
+
return chunk.length === 0;
|
|
408
|
+
}
|
|
409
|
+
if (typeof chunk !== "object" || chunk === null) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
const shape = chunk;
|
|
413
|
+
if (shape.metadata?.noOutput === true) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return shape.content === "" && Object.keys(shape).length === 1;
|
|
417
|
+
};
|
|
418
|
+
async function* withFallback() {
|
|
419
|
+
let committed = false;
|
|
420
|
+
const held = [];
|
|
421
|
+
try {
|
|
422
|
+
for await (const chunk of iterableOf(sourceIterator)) {
|
|
423
|
+
if (isWithheld(chunk)) {
|
|
424
|
+
held.push(chunk);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
committed = true;
|
|
428
|
+
while (held.length > 0) {
|
|
429
|
+
yield held.shift();
|
|
430
|
+
}
|
|
431
|
+
yield chunk;
|
|
432
|
+
}
|
|
433
|
+
while (held.length > 0) {
|
|
434
|
+
yield held.shift();
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (committed ||
|
|
440
|
+
!isInvalidModelError(provider.formatProviderError(error))) {
|
|
441
|
+
// Not retrying: release what was withheld — the sentinel included —
|
|
442
|
+
// so the consumer sees exactly what the unwrapped stream produced
|
|
443
|
+
// before the error.
|
|
444
|
+
while (held.length > 0) {
|
|
445
|
+
yield held.shift();
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
const requestedModel = provider.modelName;
|
|
450
|
+
const candidates = provider
|
|
451
|
+
.getModelFallbacks()
|
|
452
|
+
.filter((model) => model !== requestedModel);
|
|
453
|
+
for (const candidate of candidates) {
|
|
454
|
+
logger.warn(`[${provider.providerName}] model "${requestedModel}" was rejected as invalid — retrying stream with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
|
|
455
|
+
provider.refreshHandlersForModel(candidate);
|
|
456
|
+
let retryCommitted = false;
|
|
457
|
+
const retryHeld = [];
|
|
458
|
+
try {
|
|
459
|
+
const retry = await provider.executeStream(options, analysisSchema);
|
|
460
|
+
const retryIterator = retry.stream[Symbol.asyncIterator]();
|
|
461
|
+
activeStream = retry.stream;
|
|
462
|
+
activeIterator = retryIterator;
|
|
463
|
+
for await (const chunk of iterableOf(retryIterator)) {
|
|
464
|
+
if (isWithheld(chunk)) {
|
|
465
|
+
retryHeld.push(chunk);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
retryCommitted = true;
|
|
469
|
+
while (retryHeld.length > 0) {
|
|
470
|
+
yield retryHeld.shift();
|
|
471
|
+
}
|
|
472
|
+
yield chunk;
|
|
473
|
+
}
|
|
474
|
+
while (retryHeld.length > 0) {
|
|
475
|
+
yield retryHeld.shift();
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
catch (retryError) {
|
|
480
|
+
// Same rule as above: once this candidate has emitted real
|
|
481
|
+
// content its output is committed, and moving to another model
|
|
482
|
+
// would splice two different responses together.
|
|
483
|
+
if (retryCommitted) {
|
|
484
|
+
throw retryError;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
provider.refreshHandlersForModel(requestedModel);
|
|
489
|
+
// Every fallback failed too: release the original attempt's withheld
|
|
490
|
+
// chunks, then surface the ORIGINAL error — it names the model the
|
|
491
|
+
// caller asked for.
|
|
492
|
+
while (held.length > 0) {
|
|
493
|
+
yield held.shift();
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const wrapped = withFallback();
|
|
499
|
+
// Same contract as wrapStreamWithLifecycleCallbacks: a consumer break
|
|
500
|
+
// (cancelStream on the outer stream) must close the live upstream
|
|
501
|
+
// iterator directly, not wait for this generator to reach its next
|
|
502
|
+
// yield. Without this, the TTS early-break path — and any consumer that
|
|
503
|
+
// stops mid-stream — would leave the vendor request open until the next
|
|
504
|
+
// chunk arrived.
|
|
505
|
+
attachStreamCancel(wrapped, () => {
|
|
506
|
+
cancelStream(activeStream);
|
|
507
|
+
releaseIterator(activeIterator);
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
...result,
|
|
511
|
+
stream: wrapped,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The eager half of the retired-model stream fallback, for providers whose
|
|
516
|
+
* executeStream() reaches the network before returning (see
|
|
517
|
+
* runGenerateWithModelFallback for the generate() half and the reasoning).
|
|
518
|
+
*
|
|
519
|
+
* Returns a working StreamResult when a fallback model succeeds, or
|
|
520
|
+
* undefined to mean "not recoverable — carry on with the original error".
|
|
521
|
+
* Returning rather than throwing is deliberate: every existing path in the
|
|
522
|
+
* caller's catch (the broad fake-streaming fallback, the terminal-error
|
|
523
|
+
* re-throw, the onError firing) is left exactly as it was, so behaviour
|
|
524
|
+
* changes only when a fallback actually succeeds.
|
|
525
|
+
*/
|
|
526
|
+
async retryStreamWithFallbackModel(error, options, analysisSchema) {
|
|
527
|
+
if (options.disableInternalFallback === true) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
// executeStream() surfaces the raw transport error, so classify it the
|
|
531
|
+
// way this provider would before deciding. formatProviderError is
|
|
532
|
+
// contractually return-only, never throw.
|
|
533
|
+
if (!isInvalidModelError(this.formatProviderError(error))) {
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
const requestedModel = this.modelName;
|
|
537
|
+
const candidates = this.getModelFallbacks().filter((model) => model !== requestedModel);
|
|
538
|
+
for (const candidate of candidates) {
|
|
539
|
+
logger.warn(`[${this.providerName}] model "${requestedModel}" was rejected as invalid — retrying stream with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
|
|
540
|
+
this.refreshHandlersForModel(candidate);
|
|
541
|
+
try {
|
|
542
|
+
const result = await this.executeStream(options, analysisSchema);
|
|
543
|
+
return this.wrapStreamWithLifecycleCallbacks(result, options);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Any failure on a candidate — stale id or otherwise — just moves to
|
|
547
|
+
// the next one. Nothing is reported from here: if none succeed the
|
|
548
|
+
// caller still handles the ORIGINAL error, which names the model the
|
|
549
|
+
// caller actually asked for.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Restore the caller's model so a failed request does not leave this
|
|
553
|
+
// instance silently pointing at the last fallback it tried.
|
|
554
|
+
this.refreshHandlersForModel(requestedModel);
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
340
557
|
/**
|
|
341
558
|
* Wrap a StreamResult with consumer-facing lifecycle callbacks.
|
|
342
559
|
*
|
|
@@ -1061,21 +1278,95 @@ export class BaseProvider {
|
|
|
1061
1278
|
const options = this.normalizeTextOptions(optionsOrPrompt);
|
|
1062
1279
|
this.validateOptions(options);
|
|
1063
1280
|
const startTime = Date.now();
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1281
|
+
// One span per attempt. runGenerateInActiveContext ends the span on the
|
|
1282
|
+
// way out (success or failure), so a model-fallback retry must not reuse
|
|
1283
|
+
// it — an ended span would swallow the retry's attributes and report the
|
|
1284
|
+
// model that failed rather than the one that served.
|
|
1285
|
+
const attempt = async () => {
|
|
1286
|
+
// OTEL span for provider-level generate tracing
|
|
1287
|
+
// Use startActiveSpan pattern via context.with() so child spans become descendants
|
|
1288
|
+
const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", {
|
|
1289
|
+
kind: SpanKind.CLIENT,
|
|
1290
|
+
attributes: {
|
|
1291
|
+
[ATTR.GEN_AI_SYSTEM]: this.providerName || "unknown",
|
|
1292
|
+
[ATTR.GEN_AI_MODEL]: this.modelName || options.model || "unknown",
|
|
1293
|
+
[ATTR.GEN_AI_OPERATION]: "generate",
|
|
1294
|
+
[ATTR.NL_PROVIDER]: this.providerName || "unknown",
|
|
1295
|
+
},
|
|
1296
|
+
});
|
|
1297
|
+
// Set this span as the active context so child spans (GenerationHandler, etc.) become descendants
|
|
1298
|
+
const activeCtx = trace.setSpan(context.active(), otelSpan);
|
|
1299
|
+
const otelSpanState = { ended: false };
|
|
1300
|
+
return await context.with(activeCtx, async () => this.runGenerateInActiveContext(options, startTime, otelSpan, otelSpanState));
|
|
1301
|
+
};
|
|
1302
|
+
// TextGenerationOptions does not declare the flag (it lives on
|
|
1303
|
+
// StreamOptions), but callers that own fallback order pass it on both
|
|
1304
|
+
// paths, so honour it here as well.
|
|
1305
|
+
const callerOwnsFallback = "disableInternalFallback" in options &&
|
|
1306
|
+
options.disableInternalFallback === true;
|
|
1307
|
+
return await this.runGenerateWithModelFallback(attempt, callerOwnsFallback);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Models the catalog names are the models a vendor served when the entry was
|
|
1311
|
+
* last verified. Vendors retire them without warning, and until this existed
|
|
1312
|
+
* the runtime had no answer for that: an InvalidModelError is classified
|
|
1313
|
+
* non-retryable (correctly — another *provider* cannot fix a bad model id),
|
|
1314
|
+
* so the fallback chain stopped dead and the caller got an error even though
|
|
1315
|
+
* the same provider was still serving four other models listed right there
|
|
1316
|
+
* in the catalog's `fallbacks`.
|
|
1317
|
+
*
|
|
1318
|
+
* So on an invalid-model error only, walk this provider's fallbacks. Every
|
|
1319
|
+
* switch is a loud WARN: the caller asked for one model and is getting
|
|
1320
|
+
* another, which they must be able to see in the logs. Any other error type
|
|
1321
|
+
* propagates untouched on the first attempt.
|
|
1322
|
+
*
|
|
1323
|
+
* stream() gets the same treatment via retryStreamWithFallbackModel, which
|
|
1324
|
+
* is safe for the same reason it is cheap: the retry happens before any
|
|
1325
|
+
* lifecycle callback has fired and before a chunk has reached the consumer,
|
|
1326
|
+
* so there is no observable output to replay.
|
|
1327
|
+
*/
|
|
1328
|
+
async runGenerateWithModelFallback(attempt, callerOwnsFallback) {
|
|
1329
|
+
const requestedModel = this.modelName;
|
|
1330
|
+
try {
|
|
1331
|
+
return await attempt();
|
|
1332
|
+
}
|
|
1333
|
+
catch (error) {
|
|
1334
|
+
if (callerOwnsFallback || !isInvalidModelError(error)) {
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
const candidates = this.getModelFallbacks().filter((model) => model !== requestedModel);
|
|
1338
|
+
if (candidates.length === 0) {
|
|
1339
|
+
throw error;
|
|
1340
|
+
}
|
|
1341
|
+
for (const candidate of candidates) {
|
|
1342
|
+
logger.warn(`[${this.providerName}] model "${requestedModel}" was rejected as invalid — retrying with fallback "${candidate}". This provider's catalog entry is stale; run "pnpm run check:models".`);
|
|
1343
|
+
this.refreshHandlersForModel(candidate);
|
|
1344
|
+
try {
|
|
1345
|
+
return await attempt();
|
|
1346
|
+
}
|
|
1347
|
+
catch (retryError) {
|
|
1348
|
+
if (!isInvalidModelError(retryError)) {
|
|
1349
|
+
// Restore the caller's model: this failure is unrelated to the
|
|
1350
|
+
// model id, so the instance must not be left on a fallback.
|
|
1351
|
+
this.refreshHandlersForModel(requestedModel);
|
|
1352
|
+
throw retryError;
|
|
1353
|
+
}
|
|
1354
|
+
// This fallback is stale too — keep walking the list.
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// Every fallback was rejected as well. Restore the caller's model so a
|
|
1358
|
+
// failed request does not leave this instance silently repointed, and
|
|
1359
|
+
// surface the ORIGINAL error: it names the model the caller asked for.
|
|
1360
|
+
this.refreshHandlersForModel(requestedModel);
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Model ids to try when this provider rejects its current model as invalid.
|
|
1366
|
+
* Empty by default; catalog-driven providers return their `fallbacks`.
|
|
1367
|
+
*/
|
|
1368
|
+
getModelFallbacks() {
|
|
1369
|
+
return [];
|
|
1079
1370
|
}
|
|
1080
1371
|
/**
|
|
1081
1372
|
* Alias for generate method - implements AIProvider interface
|
|
@@ -15,19 +15,26 @@
|
|
|
15
15
|
"catalog": {
|
|
16
16
|
"gpt-oss-120b": {
|
|
17
17
|
"contextWindow": 65536,
|
|
18
|
-
"pricingPerMTok": {
|
|
18
|
+
"pricingPerMTok": {
|
|
19
|
+
"input": 0.35,
|
|
20
|
+
"output": 0.75
|
|
21
|
+
},
|
|
19
22
|
"vision": false,
|
|
20
23
|
"status": "production",
|
|
21
24
|
"description": "Recommended - OpenAI GPT-OSS 120B (open-weight); wafer-scale speed"
|
|
22
25
|
},
|
|
23
26
|
"gemma-4-31b": {
|
|
24
27
|
"contextWindow": 65536,
|
|
25
|
-
"pricingPerMTok": {
|
|
26
|
-
|
|
28
|
+
"pricingPerMTok": {
|
|
29
|
+
"input": 0.99,
|
|
30
|
+
"output": 1.49
|
|
31
|
+
},
|
|
32
|
+
"vision": true,
|
|
27
33
|
"status": "production",
|
|
28
|
-
"description": "Google Gemma 4 31B"
|
|
34
|
+
"description": "Google Gemma 4 31B — vision-capable"
|
|
29
35
|
}
|
|
30
36
|
},
|
|
37
|
+
"visionModel": "gemma-4-31b",
|
|
31
38
|
"topModels": ["gpt-oss-120b", "gemma-4-31b"]
|
|
32
39
|
},
|
|
33
40
|
"capabilities": {
|
|
@@ -69,7 +76,10 @@
|
|
|
69
76
|
"status": 401,
|
|
70
77
|
"code": "wrong_api_key"
|
|
71
78
|
},
|
|
72
|
-
"liveMatrix": {
|
|
79
|
+
"liveMatrix": {
|
|
80
|
+
"date": "2026-08-30",
|
|
81
|
+
"result": "Both ids answer. Vision re-tested with a valid 64x64 PNG: gemma-4-31b describes it correctly, so its vision flag is now true (it was false); gpt-oss-120b answers \"Content type 'image_url' is not supported by selected model\"."
|
|
82
|
+
},
|
|
73
83
|
"addedInPR": "https://github.com/juspay/neurolink/pull/1561"
|
|
74
84
|
}
|
|
75
85
|
}
|