@jsonstudio/llms 0.6.2125 → 0.6.2172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/conversion/compat/actions/deepseek-web-response.js +27 -3
  2. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +1 -1
  3. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage1_tool_governance/index.js +9 -3
  4. package/dist/conversion/hub/process/chat-process.js +15 -18
  5. package/dist/conversion/responses/responses-openai-bridge.js +13 -12
  6. package/dist/conversion/shared/bridge-message-utils.js +92 -39
  7. package/dist/router/virtual-router/classifier.js +29 -5
  8. package/dist/router/virtual-router/engine/routing-pools/index.js +111 -5
  9. package/dist/router/virtual-router/engine-selection/multimodal-capability.d.ts +3 -0
  10. package/dist/router/virtual-router/engine-selection/multimodal-capability.js +26 -0
  11. package/dist/router/virtual-router/engine-selection/route-utils.js +6 -2
  12. package/dist/router/virtual-router/engine-selection/selection-deps.d.ts +1 -0
  13. package/dist/router/virtual-router/engine-selection/tier-selection.js +2 -0
  14. package/dist/router/virtual-router/engine.d.ts +2 -0
  15. package/dist/router/virtual-router/engine.js +57 -14
  16. package/dist/router/virtual-router/features.js +12 -4
  17. package/dist/router/virtual-router/message-utils.d.ts +8 -0
  18. package/dist/router/virtual-router/message-utils.js +170 -45
  19. package/dist/router/virtual-router/token-counter.js +51 -10
  20. package/dist/router/virtual-router/types.d.ts +3 -0
  21. package/dist/servertool/clock/session-scope.d.ts +3 -0
  22. package/dist/servertool/clock/session-scope.js +52 -0
  23. package/dist/servertool/engine.js +68 -8
  24. package/dist/servertool/handlers/clock-auto.js +2 -8
  25. package/dist/servertool/handlers/clock.js +3 -9
  26. package/dist/servertool/handlers/stop-message-auto/blocked-report.d.ts +16 -0
  27. package/dist/servertool/handlers/stop-message-auto/blocked-report.js +349 -0
  28. package/dist/servertool/handlers/stop-message-auto/iflow-followup.d.ts +23 -0
  29. package/dist/servertool/handlers/stop-message-auto/iflow-followup.js +503 -0
  30. package/dist/servertool/handlers/stop-message-auto/routing-state.d.ts +38 -0
  31. package/dist/servertool/handlers/stop-message-auto/routing-state.js +149 -0
  32. package/dist/servertool/handlers/stop-message-auto/runtime-utils.d.ts +67 -0
  33. package/dist/servertool/handlers/stop-message-auto/runtime-utils.js +387 -0
  34. package/dist/servertool/handlers/stop-message-auto.d.ts +1 -7
  35. package/dist/servertool/handlers/stop-message-auto.js +69 -971
  36. package/dist/servertool/handlers/web-search.js +117 -0
  37. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_ROUTE, VirtualRouterError, VirtualRouterErrorCode } from '../../types.js';
2
- import { extractExcludedProviderKeySet, extractProviderId } from '../../engine-selection/key-parsing.js';
2
+ import { extractExcludedProviderKeySet, extractKeyAlias, extractKeyIndex, extractProviderId, getProviderModelId } from '../../engine-selection/key-parsing.js';
3
+ import { providerSupportsMultimodalRequest } from '../../engine-selection/multimodal-capability.js';
3
4
  import { trySelectFromTier } from '../../engine-selection/tier-selection.js';
4
5
  import { resolveInstructionTarget } from '../../engine-selection/instruction-target.js';
5
6
  import { filterCandidatesByRoutingState } from '../../engine-selection/routing-state-filter.js';
@@ -49,7 +50,7 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
49
50
  stickyResolution = resolveInstructionTarget(state.stickyTarget, deps.providerRegistry);
50
51
  if (stickyResolution && stickyResolution.mode === 'exact') {
51
52
  const stickyKey = stickyResolution.keys[0];
52
- if (stickyProviderMatchesRequestCapabilities(stickyKey, requestedRoute, classification, features, deps.routing) &&
53
+ if (stickyProviderMatchesRequestCapabilities(stickyKey, requestedRoute, classification, features, deps.routing, deps.providerRegistry) &&
53
54
  (deps.quotaView ? true : deps.healthManager.isAvailable(stickyKey)) &&
54
55
  !excludedProviderKeys.has(stickyKey) &&
55
56
  !deps.isProviderCoolingDown(stickyKey) &&
@@ -63,7 +64,7 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
63
64
  }
64
65
  }
65
66
  if (stickyResolution && stickyResolution.mode === 'filter' && stickyResolution.keys.length > 0) {
66
- const liveKeys = stickyResolution.keys.filter((key) => stickyProviderMatchesRequestCapabilities(key, requestedRoute, classification, features, deps.routing) &&
67
+ const liveKeys = stickyResolution.keys.filter((key) => stickyProviderMatchesRequestCapabilities(key, requestedRoute, classification, features, deps.routing, deps.providerRegistry) &&
67
68
  (deps.quotaView ? true : deps.healthManager.isAvailable(key)) &&
68
69
  !excludedProviderKeys.has(key) &&
69
70
  !deps.isProviderCoolingDown(key) &&
@@ -177,7 +178,7 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
177
178
  allowAliasRotation
178
179
  });
179
180
  }
180
- function stickyProviderMatchesRequestCapabilities(providerKey, requestedRoute, classification, features, routing) {
181
+ function stickyProviderMatchesRequestCapabilities(providerKey, requestedRoute, classification, features, routing, providerRegistry) {
181
182
  if (!providerKey) {
182
183
  return false;
183
184
  }
@@ -187,6 +188,9 @@ function stickyProviderMatchesRequestCapabilities(providerKey, requestedRoute, c
187
188
  if (!supportsImageRoute) {
188
189
  return false;
189
190
  }
191
+ if (!providerSupportsMultimodalRequest(providerKey, features, providerRegistry)) {
192
+ return false;
193
+ }
190
194
  }
191
195
  if (requestRequiresSearchRoute(requestedRoute, classification, features)) {
192
196
  const supportsSearchRoute = routeTargetsIncludeProvider(routing, 'web_search', providerKey) ||
@@ -230,9 +234,13 @@ function routeTargetsIncludeProvider(routing, routeName, providerKey) {
230
234
  }
231
235
  return false;
232
236
  }
237
+ function isFinitePositiveNumber(value) {
238
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
239
+ }
233
240
  function selectFromCandidates(routes, metadata, classification, features, state, deps, options) {
234
241
  const allowedProviders = new Set(state.allowedProviders);
235
242
  const disabledProviders = new Set(state.disabledProviders);
243
+ const excludedProviderKeys = extractExcludedProviderKeySet(features.metadata);
236
244
  const disabledKeysMap = new Map(Array.from(state.disabledKeys.entries()).map(([provider, keys]) => [
237
245
  provider,
238
246
  new Set(Array.from(keys).map((k) => (typeof k === 'string' ? k : k + 1)))
@@ -242,6 +250,89 @@ function selectFromCandidates(routes, metadata, classification, features, state,
242
250
  const attempted = [];
243
251
  const visitedRoutes = new Set();
244
252
  const routeQueue = initializeRouteQueue(routes);
253
+ const healthSnapshotByProviderKey = new Map(deps.healthManager.getSnapshot().map((entry) => [entry.providerKey, entry]));
254
+ let minRecoverableCooldownMs;
255
+ const recoverableCooldownHints = [];
256
+ const recordRecoverableCooldown = (providerKey, waitMsRaw, source) => {
257
+ const waitMs = Math.max(1, Math.floor(waitMsRaw));
258
+ if (!isFinitePositiveNumber(waitMs)) {
259
+ return;
260
+ }
261
+ if (!Number.isFinite(minRecoverableCooldownMs) || waitMs < minRecoverableCooldownMs) {
262
+ minRecoverableCooldownMs = waitMs;
263
+ }
264
+ const existing = recoverableCooldownHints.find((item) => item.providerKey === providerKey && item.source === source);
265
+ if (!existing) {
266
+ recoverableCooldownHints.push({ providerKey, waitMs, source });
267
+ return;
268
+ }
269
+ if (waitMs < existing.waitMs) {
270
+ existing.waitMs = waitMs;
271
+ }
272
+ };
273
+ const collectRecoverableCooldownForKey = (providerKey) => {
274
+ const nowMs = Date.now();
275
+ if (deps.quotaView) {
276
+ const entry = deps.quotaView(providerKey);
277
+ if (!entry) {
278
+ return;
279
+ }
280
+ if (isFinitePositiveNumber(entry.blacklistUntil) && entry.blacklistUntil > nowMs) {
281
+ return;
282
+ }
283
+ if (isFinitePositiveNumber(entry.cooldownUntil) && entry.cooldownUntil > nowMs) {
284
+ recordRecoverableCooldown(providerKey, entry.cooldownUntil - nowMs, 'quota.cooldown');
285
+ }
286
+ return;
287
+ }
288
+ if (typeof deps.getProviderCooldownRemainingMs === 'function') {
289
+ const localCooldownMs = deps.getProviderCooldownRemainingMs(providerKey);
290
+ if (isFinitePositiveNumber(localCooldownMs)) {
291
+ recordRecoverableCooldown(providerKey, localCooldownMs, 'router.cooldown');
292
+ }
293
+ }
294
+ const healthState = healthSnapshotByProviderKey.get(providerKey);
295
+ if (healthState && isFinitePositiveNumber(healthState.cooldownExpiresAt) && healthState.cooldownExpiresAt > nowMs) {
296
+ recordRecoverableCooldown(providerKey, healthState.cooldownExpiresAt - nowMs, 'health.cooldown');
297
+ }
298
+ };
299
+ const isEligibleTargetForCurrentAttempt = (providerKey) => {
300
+ if (!providerKey || excludedProviderKeys.has(providerKey)) {
301
+ return false;
302
+ }
303
+ if (options.requiredProviderKeys && options.requiredProviderKeys.size > 0 && !options.requiredProviderKeys.has(providerKey)) {
304
+ return false;
305
+ }
306
+ const providerId = extractProviderId(providerKey);
307
+ if (!providerId) {
308
+ return false;
309
+ }
310
+ if (allowedProviders.size > 0 && !allowedProviders.has(providerId)) {
311
+ return false;
312
+ }
313
+ if (disabledProviders.has(providerId)) {
314
+ return false;
315
+ }
316
+ const disabledKeys = disabledKeysMap.get(providerId);
317
+ if (disabledKeys && disabledKeys.size > 0) {
318
+ const keyAlias = extractKeyAlias(providerKey);
319
+ const keyIndex = extractKeyIndex(providerKey);
320
+ if (keyAlias && disabledKeys.has(keyAlias)) {
321
+ return false;
322
+ }
323
+ if (keyIndex !== undefined && disabledKeys.has(keyIndex + 1)) {
324
+ return false;
325
+ }
326
+ }
327
+ const disabledModelSet = disabledModels.get(providerId);
328
+ if (disabledModelSet && disabledModelSet.size > 0) {
329
+ const modelId = getProviderModelId(providerKey, deps.providerRegistry);
330
+ if (modelId && disabledModelSet.has(modelId)) {
331
+ return false;
332
+ }
333
+ }
334
+ return true;
335
+ };
245
336
  const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
246
337
  ? Math.max(0, features.estimatedTokens)
247
338
  : 0;
@@ -273,8 +364,23 @@ function selectFromCandidates(routes, metadata, classification, features, state,
273
364
  if (failureHint) {
274
365
  attempted.push(failureHint);
275
366
  }
367
+ if (Array.isArray(poolTier.targets) && poolTier.targets.length > 0) {
368
+ for (const providerKey of poolTier.targets) {
369
+ if (!isEligibleTargetForCurrentAttempt(providerKey)) {
370
+ continue;
371
+ }
372
+ collectRecoverableCooldownForKey(providerKey);
373
+ }
374
+ }
276
375
  }
277
376
  }
278
377
  const requestedRoute = normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
279
- throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
378
+ const details = { routeName: requestedRoute, attempted };
379
+ if (isFinitePositiveNumber(minRecoverableCooldownMs)) {
380
+ details.minRecoverableCooldownMs = Math.floor(minRecoverableCooldownMs);
381
+ details.recoverableCooldownHints = recoverableCooldownHints
382
+ .sort((a, b) => a.waitMs - b.waitMs)
383
+ .slice(0, 8);
384
+ }
385
+ throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, details);
280
386
  }
@@ -0,0 +1,3 @@
1
+ import type { ProviderRegistry } from '../provider-registry.js';
2
+ import type { RoutingFeatures } from '../types.js';
3
+ export declare function providerSupportsMultimodalRequest(providerKey: string, features: RoutingFeatures, providerRegistry: ProviderRegistry): boolean;
@@ -0,0 +1,26 @@
1
+ import { extractProviderId, getProviderModelId } from './key-parsing.js';
2
+ function isQwen35PlusProvider(providerKey, providerRegistry) {
3
+ const providerId = (extractProviderId(providerKey) ?? '').trim().toLowerCase();
4
+ if (providerId !== 'qwen') {
5
+ return false;
6
+ }
7
+ const modelId = (getProviderModelId(providerKey, providerRegistry) ?? '').trim().toLowerCase();
8
+ if (!modelId) {
9
+ return false;
10
+ }
11
+ return modelId === 'qwen3.5-plus' || modelId === 'qwen3-5-plus' || modelId === 'qwen3_5-plus';
12
+ }
13
+ export function providerSupportsMultimodalRequest(providerKey, features, providerRegistry) {
14
+ if (!features.hasImageAttachment) {
15
+ return true;
16
+ }
17
+ if (!isQwen35PlusProvider(providerKey, providerRegistry)) {
18
+ return true;
19
+ }
20
+ if (features.hasVideoAttachment !== true) {
21
+ return true;
22
+ }
23
+ const hasRemoteVideo = features.hasRemoteVideoAttachment === true;
24
+ const hasLocalVideo = features.hasLocalVideoAttachment === true;
25
+ return hasRemoteVideo && !hasLocalVideo;
26
+ }
@@ -43,6 +43,10 @@ export function buildRouteCandidates(requestedRoute, classificationCandidates, f
43
43
  const forceVision = routeHasForceFlag('vision', routing);
44
44
  const hasMultimodalTargets = routeHasTargets(routing.multimodal);
45
45
  const hasVisionTargets = routeHasTargets(routing.vision);
46
+ const hasLocalVideoAttachment = features.hasVideoAttachment === true && features.hasLocalVideoAttachment === true;
47
+ if (features.hasImageAttachment && hasLocalVideoAttachment && hasVisionTargets) {
48
+ return ['vision'];
49
+ }
46
50
  const normalized = normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
47
51
  const baseList = [];
48
52
  if (classificationCandidates && classificationCandidates.length) {
@@ -59,9 +63,9 @@ export function buildRouteCandidates(requestedRoute, classificationCandidates, f
59
63
  baseList.unshift('multimodal');
60
64
  }
61
65
  }
62
- else if (hasVisionTargets) {
66
+ if (hasVisionTargets) {
63
67
  if (!baseList.includes('vision')) {
64
- baseList.unshift('vision');
68
+ baseList.push('vision');
65
69
  }
66
70
  }
67
71
  if (!forceVision && hasMultimodalTargets) {
@@ -10,6 +10,7 @@ export type SelectionDeps = {
10
10
  contextAdvisor: ContextAdvisor;
11
11
  loadBalancer: RouteLoadBalancer;
12
12
  isProviderCoolingDown: (providerKey: string) => boolean;
13
+ getProviderCooldownRemainingMs?: (providerKey: string) => number;
13
14
  resolveStickyKey: (metadata: RouterMetadataInput) => string | undefined;
14
15
  quotaView?: ProviderQuotaView;
15
16
  aliasQueueStore?: Map<string, string[]>;
@@ -3,6 +3,7 @@ import { resolveContextWeightedConfig } from '../context-weighted.js';
3
3
  import { resolveHealthWeightedConfig } from '../health-weighted.js';
4
4
  import { pinCandidatesByAliasQueue, resolveAliasSelectionStrategy } from './alias-selection.js';
5
5
  import { extractKeyAlias, extractKeyIndex, extractProviderId, getProviderModelId } from './key-parsing.js';
6
+ import { providerSupportsMultimodalRequest } from './multimodal-capability.js';
6
7
  import { selectProviderKeyFromCandidatePool } from './tier-selection-select.js';
7
8
  import { lookupAntigravityPinnedAliasForSessionId, unpinAntigravitySessionAliasForSessionId } from '../../../conversion/compat/antigravity-session-signature.js';
8
9
  const DEFAULT_ANTIGRAVITY_ALIAS_SESSION_COOLDOWN_MS = 5 * 60_000;
@@ -388,6 +389,7 @@ export function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, f
388
389
  targets = filtered;
389
390
  }
390
391
  if (features.hasImageAttachment && routeName === 'multimodal') {
392
+ targets = targets.filter((key) => providerSupportsMultimodalRequest(key, features, deps.providerRegistry));
391
393
  const kimiTargets = targets.filter((key) => {
392
394
  const modelId = getProviderModelId(key, deps.providerRegistry) ?? '';
393
395
  return modelId.trim().toLowerCase() === 'kimi-k2.5';
@@ -44,6 +44,7 @@ export declare class VirtualRouterEngine {
44
44
  quotaView?: ProviderQuotaView | null;
45
45
  }): void;
46
46
  private parseDirectProviderModel;
47
+ private shouldFallbackDirectModelForMedia;
47
48
  initialize(config: VirtualRouterConfig): void;
48
49
  route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
49
50
  target: TargetMetadata;
@@ -117,6 +118,7 @@ export declare class VirtualRouterEngine {
117
118
  private markProviderCooldown;
118
119
  private clearProviderCooldown;
119
120
  private isProviderCoolingDown;
121
+ private getProviderCooldownRemainingMs;
120
122
  private restoreHealthFromStore;
121
123
  private buildHealthSnapshot;
122
124
  private persistHealthSnapshot;
@@ -207,6 +207,24 @@ export class VirtualRouterEngine {
207
207
  }
208
208
  return { providerId, modelId };
209
209
  }
210
+ shouldFallbackDirectModelForMedia(direct, features) {
211
+ if (!features.hasImageAttachment) {
212
+ return false;
213
+ }
214
+ const providerId = direct.providerId.trim().toLowerCase();
215
+ const modelId = direct.modelId.trim().toLowerCase();
216
+ if (providerId !== 'qwen') {
217
+ return false;
218
+ }
219
+ const isQwen35Plus = modelId === 'qwen3.5-plus' || modelId === 'qwen3-5-plus' || modelId === 'qwen3_5-plus';
220
+ if (!isQwen35Plus) {
221
+ return false;
222
+ }
223
+ if (!(features.hasVideoAttachment === true && features.hasLocalVideoAttachment === true)) {
224
+ return false;
225
+ }
226
+ return this.routeHasTargets(this.routing.vision);
227
+ }
210
228
  initialize(config) {
211
229
  this.validateConfig(config);
212
230
  this.routing = config.routing;
@@ -638,6 +656,7 @@ export class VirtualRouterEngine {
638
656
  quotaView: this.quotaView
639
657
  };
640
658
  if (directProviderModel) {
659
+ const forceMediaFallback = this.shouldFallbackDirectModelForMedia(directProviderModel, features);
641
660
  const providerKeys = this.providerRegistry.listProviderKeys(directProviderModel.providerId);
642
661
  let hasModel = false;
643
662
  for (const key of providerKeys) {
@@ -655,19 +674,26 @@ export class VirtualRouterEngine {
655
674
  if (!hasModel) {
656
675
  throw new VirtualRouterError(`Unknown model ${directProviderModel.modelId} for provider ${directProviderModel.providerId}`, VirtualRouterErrorCode.CONFIG_ERROR, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
657
676
  }
658
- classification = {
659
- routeName: 'direct',
660
- confidence: 1,
661
- reasoning: `direct_model:${directProviderModel.providerId}.${directProviderModel.modelId}`,
662
- fallback: false,
663
- candidates: ['direct']
664
- };
665
- requestedRoute = 'direct';
666
- const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
667
- if (!directSelection) {
668
- throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
677
+ if (!forceMediaFallback) {
678
+ const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
679
+ if (!directSelection) {
680
+ throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
681
+ }
682
+ classification = {
683
+ routeName: 'direct',
684
+ confidence: 1,
685
+ reasoning: `direct_model:${directProviderModel.providerId}.${directProviderModel.modelId}`,
686
+ fallback: false,
687
+ candidates: ['direct']
688
+ };
689
+ requestedRoute = 'direct';
690
+ selection = directSelection;
691
+ }
692
+ else {
693
+ classification = this.classifier.classify(features);
694
+ requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
695
+ selection = this.selectProvider(requestedRoute, metadata, classification, features, routingState);
669
696
  }
670
- selection = directSelection;
671
697
  }
672
698
  else {
673
699
  // Prefer target (from "<**!provider.model**>") is evaluated before routing classification.
@@ -1128,6 +1154,7 @@ export class VirtualRouterEngine {
1128
1154
  contextAdvisor: this.contextAdvisor,
1129
1155
  loadBalancer: this.loadBalancer,
1130
1156
  isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
1157
+ getProviderCooldownRemainingMs: (key) => this.getProviderCooldownRemainingMs(key),
1131
1158
  resolveStickyKey: (m) => this.resolveStickyKey(m),
1132
1159
  quotaView: this.quotaView,
1133
1160
  aliasQueueStore: this.stickySessionManager.getAllStores().aliasQueueStore,
@@ -1287,6 +1314,7 @@ export class VirtualRouterEngine {
1287
1314
  contextAdvisor: this.contextAdvisor,
1288
1315
  loadBalancer: this.loadBalancer,
1289
1316
  isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
1317
+ getProviderCooldownRemainingMs: (key) => this.getProviderCooldownRemainingMs(key),
1290
1318
  resolveStickyKey: (m) => this.resolveStickyKey(m),
1291
1319
  quotaView: this.quotaView,
1292
1320
  aliasQueueStore: this.stickySessionManager.getAllStores().aliasQueueStore
@@ -1421,6 +1449,10 @@ export class VirtualRouterEngine {
1421
1449
  const forceVision = this.routeHasForceFlag('vision');
1422
1450
  const hasMultimodalTargets = this.routeHasTargets(this.routing.multimodal);
1423
1451
  const hasVisionTargets = this.routeHasTargets(this.routing.vision);
1452
+ const hasLocalVideoAttachment = features.hasVideoAttachment === true && features.hasLocalVideoAttachment === true;
1453
+ if (features.hasImageAttachment && hasLocalVideoAttachment && hasVisionTargets) {
1454
+ return ['vision'];
1455
+ }
1424
1456
  const normalized = this.normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
1425
1457
  const baseList = [];
1426
1458
  if (classificationCandidates && classificationCandidates.length) {
@@ -1437,9 +1469,9 @@ export class VirtualRouterEngine {
1437
1469
  baseList.unshift('multimodal');
1438
1470
  }
1439
1471
  }
1440
- else if (hasVisionTargets) {
1472
+ if (hasVisionTargets) {
1441
1473
  if (!baseList.includes('vision')) {
1442
- baseList.unshift('vision');
1474
+ baseList.push('vision');
1443
1475
  }
1444
1476
  }
1445
1477
  if (!forceVision && hasMultimodalTargets) {
@@ -1629,6 +1661,17 @@ export class VirtualRouterEngine {
1629
1661
  isProviderCoolingDown(providerKey) {
1630
1662
  return this.cooldownManager.isProviderCoolingDown(providerKey);
1631
1663
  }
1664
+ getProviderCooldownRemainingMs(providerKey) {
1665
+ if (!providerKey) {
1666
+ return 0;
1667
+ }
1668
+ const expiry = this.providerCooldowns.get(providerKey);
1669
+ if (!expiry || !Number.isFinite(expiry)) {
1670
+ return 0;
1671
+ }
1672
+ const remaining = Math.floor(expiry - Date.now());
1673
+ return remaining > 0 ? remaining : 0;
1674
+ }
1632
1675
  restoreHealthFromStore() {
1633
1676
  this.cooldownManager.restoreHealthFromStore();
1634
1677
  }
@@ -1,4 +1,4 @@
1
- import { detectExtendedThinkingKeyword, detectImageAttachment, detectKeyword, extractMessageText, getLatestMessageRole, getLatestUserMessage } from './message-utils.js';
1
+ import { analyzeMediaAttachments, detectExtendedThinkingKeyword, detectKeyword, extractMessageText, getLatestMessageRole, } from './message-utils.js';
2
2
  import { extractAntigravityGeminiSessionId } from '../../conversion/compat/antigravity-session-signature.js';
3
3
  import { detectCodingTool, detectLastAssistantToolCategory, detectVisionTool, detectWebSearchToolDeclared, detectWebTool, extractMeaningfulDeclaredToolNames } from './tool-signals.js';
4
4
  import { computeRequestTokens } from './token-estimator.js';
@@ -18,10 +18,14 @@ export function buildRoutingFeatures(request, metadata) {
18
18
  return undefined;
19
19
  }
20
20
  })();
21
- const latestUserMessage = getLatestUserMessage(request.messages);
22
21
  const latestMessageRole = getLatestMessageRole(request.messages);
22
+ const latestMessage = Array.isArray(request.messages) && request.messages.length
23
+ ? request.messages[request.messages.length - 1]
24
+ : undefined;
23
25
  const assistantMessages = request.messages.filter((msg) => msg.role === 'assistant');
24
- const latestUserText = latestUserMessage ? extractMessageText(latestUserMessage) : '';
26
+ const latestUserText = latestMessageRole === 'user' && latestMessage
27
+ ? extractMessageText(latestMessage)
28
+ : '';
25
29
  const normalizedUserText = latestUserText.toLowerCase();
26
30
  const meaningfulDeclaredTools = extractMeaningfulDeclaredToolNames(request.tools);
27
31
  const hasTools = meaningfulDeclaredTools.length > 0;
@@ -31,7 +35,8 @@ export function buildRoutingFeatures(request, metadata) {
31
35
  const hasVisionTool = detectVisionTool(request);
32
36
  // Vision routing must only trigger for the current user turn (latest message),
33
37
  // not for historical user messages carrying images during tool/assistant followups.
34
- const hasImageAttachment = latestMessageRole === 'user' && detectImageAttachment(latestUserMessage);
38
+ const mediaSignals = latestMessageRole === 'user' ? analyzeMediaAttachments(latestMessage) : analyzeMediaAttachments(undefined);
39
+ const hasImageAttachment = mediaSignals.hasAnyMedia;
35
40
  const hasCodingTool = detectCodingTool(request);
36
41
  const hasWebTool = detectWebTool(request);
37
42
  const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
@@ -58,6 +63,9 @@ export function buildRoutingFeatures(request, metadata) {
58
63
  hasToolCallResponses,
59
64
  hasVisionTool,
60
65
  hasImageAttachment,
66
+ hasVideoAttachment: mediaSignals.hasVideo,
67
+ hasRemoteVideoAttachment: mediaSignals.hasRemoteVideo,
68
+ hasLocalVideoAttachment: mediaSignals.hasLocalVideo,
61
69
  hasWebTool,
62
70
  hasWebSearchToolDeclared: detectWebSearchToolDeclared(request),
63
71
  hasCodingTool,
@@ -4,4 +4,12 @@ export declare function getLatestMessageRole(messages: StandardizedMessage[]): s
4
4
  export declare function extractMessageText(message: StandardizedMessage): string;
5
5
  export declare function detectKeyword(text: string, keywords: string[]): boolean;
6
6
  export declare function detectExtendedThinkingKeyword(text: string): boolean;
7
+ export interface MediaAttachmentSignals {
8
+ hasAnyMedia: boolean;
9
+ hasImage: boolean;
10
+ hasVideo: boolean;
11
+ hasRemoteVideo: boolean;
12
+ hasLocalVideo: boolean;
13
+ }
14
+ export declare function analyzeMediaAttachments(message: StandardizedMessage | undefined): MediaAttachmentSignals;
7
15
  export declare function detectImageAttachment(message: StandardizedMessage | undefined): boolean;