@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.
@@ -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",
@@ -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
- // OTEL span for provider-level generate tracing
1065
- // Use startActiveSpan pattern via context.with() so child spans become descendants
1066
- const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", {
1067
- kind: SpanKind.CLIENT,
1068
- attributes: {
1069
- [ATTR.GEN_AI_SYSTEM]: this.providerName || "unknown",
1070
- [ATTR.GEN_AI_MODEL]: this.modelName || options.model || "unknown",
1071
- [ATTR.GEN_AI_OPERATION]: "generate",
1072
- [ATTR.NL_PROVIDER]: this.providerName || "unknown",
1073
- },
1074
- });
1075
- // Set this span as the active context so child spans (GenerationHandler, etc.) become descendants
1076
- const activeCtx = trace.setSpan(context.active(), otelSpan);
1077
- const otelSpanState = { ended: false };
1078
- return await context.with(activeCtx, async () => this.runGenerateInActiveContext(options, startTime, otelSpan, otelSpanState));
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": { "input": 0.35, "output": 0.75 },
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": { "input": 0.99, "output": 1.49 },
26
- "vision": false,
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": { "date": "2026-08-27", "result": "4/4" },
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
  }