@jsonstudio/llms 0.6.1643 → 0.6.1739

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 (96) hide show
  1. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.d.ts +10 -0
  2. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.js +121 -0
  3. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.d.ts +10 -0
  4. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.js +80 -0
  5. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.d.ts +7 -0
  6. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.js +161 -0
  7. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.d.ts +12 -0
  8. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.js +67 -0
  9. package/dist/conversion/compat/actions/iflow-response-body-unwrap.d.ts +9 -0
  10. package/dist/conversion/compat/actions/iflow-response-body-unwrap.js +140 -0
  11. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.d.ts +10 -0
  12. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.js +59 -0
  13. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.d.ts +14 -0
  14. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.js +125 -0
  15. package/dist/conversion/compat/actions/normalize-tool-call-ids.d.ts +11 -0
  16. package/dist/conversion/compat/actions/normalize-tool-call-ids.js +140 -0
  17. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.d.ts +2 -0
  18. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +152 -0
  19. package/dist/conversion/compat/antigravity-session-signature.d.ts +1 -1
  20. package/dist/conversion/compat/antigravity-session-signature.js +5 -4
  21. package/dist/conversion/compat/profiles/chat-iflow.json +6 -0
  22. package/dist/conversion/compat/profiles/chat-lmstudio.json +7 -1
  23. package/dist/conversion/hub/operation-table/operation-table-runner.js +1 -1
  24. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +19 -2
  25. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +101 -5
  26. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.d.ts +2 -0
  27. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.js +63 -0
  28. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +18 -0
  29. package/dist/conversion/hub/pipeline/hub-pipeline.js +1 -1
  30. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +8 -5
  31. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage3_compat/index.js +5 -1
  32. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +113 -0
  33. package/dist/conversion/hub/pipeline/target-utils.js +3 -0
  34. package/dist/conversion/hub/response/provider-response.js +27 -1
  35. package/dist/conversion/responses/responses-openai-bridge.js +32 -6
  36. package/dist/conversion/shared/anthropic-message-utils.js +20 -5
  37. package/dist/conversion/shared/bridge-id-utils.d.ts +2 -0
  38. package/dist/conversion/shared/bridge-id-utils.js +52 -15
  39. package/dist/conversion/shared/responses-conversation-store.js +40 -5
  40. package/dist/conversion/shared/responses-output-builder.js +23 -7
  41. package/dist/conversion/shared/responses-tool-utils.d.ts +1 -0
  42. package/dist/conversion/shared/responses-tool-utils.js +30 -13
  43. package/dist/conversion/shared/text-markup-normalizer.d.ts +1 -0
  44. package/dist/conversion/shared/text-markup-normalizer.js +269 -1
  45. package/dist/router/virtual-router/bootstrap.js +31 -7
  46. package/dist/router/virtual-router/classifier.js +1 -1
  47. package/dist/router/virtual-router/engine/antigravity/alias-lease.d.ts +33 -0
  48. package/dist/router/virtual-router/engine/antigravity/alias-lease.js +247 -0
  49. package/dist/router/virtual-router/engine/health/index.d.ts +23 -0
  50. package/dist/router/virtual-router/engine/health/index.js +720 -0
  51. package/dist/router/virtual-router/engine/provider-key/parse.d.ts +6 -0
  52. package/dist/router/virtual-router/engine/provider-key/parse.js +43 -0
  53. package/dist/router/virtual-router/engine/routing-pools/index.d.ts +13 -0
  54. package/dist/router/virtual-router/engine/routing-pools/index.js +225 -0
  55. package/dist/router/virtual-router/engine/routing-state/keys.d.ts +3 -0
  56. package/dist/router/virtual-router/engine/routing-state/keys.js +30 -0
  57. package/dist/router/virtual-router/engine/routing-state/metadata.d.ts +6 -0
  58. package/dist/router/virtual-router/engine/routing-state/metadata.js +132 -0
  59. package/dist/router/virtual-router/engine/routing-state/store.d.ts +11 -0
  60. package/dist/router/virtual-router/engine/routing-state/store.js +107 -0
  61. package/dist/router/virtual-router/engine-health.d.ts +1 -23
  62. package/dist/router/virtual-router/engine-health.js +1 -720
  63. package/dist/router/virtual-router/engine-selection/route-utils.js +57 -0
  64. package/dist/router/virtual-router/engine-selection/tier-selection-select.js +8 -48
  65. package/dist/router/virtual-router/engine-selection/tier-selection.js +34 -17
  66. package/dist/router/virtual-router/engine-selection.d.ts +1 -13
  67. package/dist/router/virtual-router/engine-selection.js +1 -225
  68. package/dist/router/virtual-router/engine.d.ts +2 -23
  69. package/dist/router/virtual-router/engine.js +130 -603
  70. package/dist/router/virtual-router/message-utils.js +15 -5
  71. package/dist/servertool/engine.js +4 -4
  72. package/dist/servertool/handlers/followup-request-builder.js +46 -0
  73. package/dist/servertool/handlers/gemini-empty-reply-continue.js +48 -47
  74. package/dist/servertool/handlers/stop-message-auto.js +64 -7
  75. package/dist/servertool/handlers/vision.js +10 -0
  76. package/dist/servertool/types.d.ts +3 -0
  77. package/dist/sse/sse-to-json/builders/response-builder.js +6 -0
  78. package/dist/sse/sse-to-json/chat-sse-to-json-converter.js +32 -2
  79. package/dist/sse/sse-to-json/parsers/sse-parser.js +34 -0
  80. package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -0
  81. package/dist/sse/sse-to-json/responses-sse-to-json-converter.js +33 -1
  82. package/dist/tools/apply-patch/args-normalizer/default-actions.d.ts +2 -0
  83. package/dist/tools/apply-patch/args-normalizer/default-actions.js +12 -0
  84. package/dist/tools/apply-patch/args-normalizer/extract-patch.d.ts +2 -0
  85. package/dist/tools/apply-patch/args-normalizer/extract-patch.js +15 -0
  86. package/dist/tools/apply-patch/args-normalizer/index.d.ts +2 -0
  87. package/dist/tools/apply-patch/args-normalizer/index.js +164 -0
  88. package/dist/tools/apply-patch/args-normalizer/structured-builders.d.ts +7 -0
  89. package/dist/tools/apply-patch/args-normalizer/structured-builders.js +85 -0
  90. package/dist/tools/apply-patch/args-normalizer/types.d.ts +54 -0
  91. package/dist/tools/apply-patch/args-normalizer/types.js +1 -0
  92. package/dist/tools/apply-patch/patch-text/looks-like-patch.js +1 -0
  93. package/dist/tools/apply-patch/patch-text/normalize.js +104 -5
  94. package/dist/tools/apply-patch/structured/coercion.js +28 -4
  95. package/dist/tools/apply-patch/validator.js +7 -146
  96. package/package.json +1 -1
@@ -4,17 +4,18 @@ import { RouteLoadBalancer } from './load-balancer.js';
4
4
  import { RoutingClassifier } from './classifier.js';
5
5
  import { buildRoutingFeatures } from './features.js';
6
6
  import { ContextAdvisor } from './context-advisor.js';
7
- import fs from 'node:fs';
8
- import os from 'node:os';
9
- import path from 'node:path';
10
7
  import { DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
11
8
  import { getStatsCenter } from '../../telemetry/stats-center.js';
12
9
  import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
13
10
  import { loadRoutingInstructionStateSync, saveRoutingInstructionStateAsync, saveRoutingInstructionStateSync } from './sticky-session-store.js';
14
11
  import { buildHitReason, formatVirtualRouterHit } from './engine-logging.js';
15
- import { selectDirectProviderModel, selectFromStickyPool, selectProviderImpl } from './engine-selection.js';
16
- import { applyQuotaDepletedImpl, applyQuotaRecoveryImpl, applySeriesCooldownImpl, applyAntigravityRiskPolicyImpl, handleProviderFailureImpl, mapProviderErrorImpl } from './engine-health.js';
17
- import { mergeStopMessageFromPersisted } from './stop-message-state-sync.js';
12
+ import { selectDirectProviderModel, selectFromStickyPool, selectProviderImpl } from './engine/routing-pools/index.js';
13
+ import { applyQuotaDepletedImpl, applyQuotaRecoveryImpl, applySeriesCooldownImpl, applyAntigravityRiskPolicyImpl, handleProviderFailureImpl, mapProviderErrorImpl } from './engine/health/index.js';
14
+ import { hydrateAntigravityAliasLeaseStoreIfNeeded, recordAntigravitySessionLease, resolveAntigravityAliasReuseCooldownMs } from './engine/antigravity/alias-lease.js';
15
+ import { buildMetadataInstructions, resolveRoutingMode } from './engine/routing-state/metadata.js';
16
+ import { getRoutingInstructionState, persistRoutingInstructionState, resolveStopMessageScope } from './engine/routing-state/store.js';
17
+ import { extractKeyAlias, extractKeyIndex, extractProviderId, getProviderModelId } from './engine/provider-key/parse.js';
18
+ import { resolveSessionScope as resolveSessionScopeImpl, resolveStickyKey as resolveStickyKeyImpl } from './engine/routing-state/keys.js';
18
19
  export class VirtualRouterEngine {
19
20
  routing = {};
20
21
  providerRegistry = new ProviderRegistry();
@@ -122,8 +123,13 @@ export class VirtualRouterEngine {
122
123
  this.providerCooldowns.clear();
123
124
  this.restoreHealthFromStore();
124
125
  this.loadBalancer = new RouteLoadBalancer(config.loadBalancing);
125
- this.antigravityAliasReuseCooldownMs = this.resolveAntigravityAliasReuseCooldownMs(config);
126
- this.hydrateAntigravityAliasLeaseStoreIfNeeded(true);
126
+ this.antigravityAliasReuseCooldownMs = resolveAntigravityAliasReuseCooldownMs(config);
127
+ hydrateAntigravityAliasLeaseStoreIfNeeded({
128
+ force: true,
129
+ leaseStore: this.antigravityAliasLeaseStore,
130
+ persistence: this.antigravityLeasePersistence,
131
+ aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs
132
+ });
127
133
  this.classifier = new RoutingClassifier(config.classifier);
128
134
  this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
129
135
  this.contextAdvisor.configure(this.contextRouting);
@@ -136,13 +142,13 @@ export class VirtualRouterEngine {
136
142
  route(request, metadata) {
137
143
  const stickyKey = this.resolveStickyKey(metadata);
138
144
  const sessionScope = this.resolveSessionScope(metadata);
139
- const stopMessageScope = this.resolveStopMessageScope(metadata);
145
+ const stopMessageScope = resolveStopMessageScope(metadata);
140
146
  // Routing instructions should be session/conversation-scoped when available (including /v1/responses),
141
147
  // while auto-sticky for Responses remains request-chain scoped via resolveStickyKey().
142
148
  const stateKey = sessionScope || stickyKey || 'default';
143
- const baseState = this.getRoutingInstructionState(stateKey);
149
+ const baseState = getRoutingInstructionState(stateKey, this.routingInstructionState, this.routingStateStore);
144
150
  let routingState = baseState;
145
- const metadataInstructions = this.buildMetadataInstructions(metadata);
151
+ const metadataInstructions = buildMetadataInstructions(metadata);
146
152
  if (metadataInstructions.length > 0) {
147
153
  routingState = applyRoutingInstructions(metadataInstructions, routingState);
148
154
  }
@@ -157,7 +163,7 @@ export class VirtualRouterEngine {
157
163
  };
158
164
  }
159
165
  if (stopMessageScope) {
160
- const sessionState = this.getRoutingInstructionState(stopMessageScope);
166
+ const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
161
167
  if (typeof sessionState.stopMessageText === 'string' ||
162
168
  typeof sessionState.stopMessageMaxRepeats === 'number') {
163
169
  routingState = {
@@ -173,7 +179,7 @@ export class VirtualRouterEngine {
173
179
  const parsedInstructions = parseRoutingInstructions(request.messages);
174
180
  let instructions = parsedInstructions;
175
181
  if (stopMessageScope && parsedInstructions.length > 0) {
176
- const sessionState = this.getRoutingInstructionState(stopMessageScope);
182
+ const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
177
183
  const hasStopMessageClear = parsedInstructions.some((entry) => entry.type === 'stopMessageClear');
178
184
  const stopMessageSets = parsedInstructions.filter((entry) => entry.type === 'stopMessageSet');
179
185
  if (!hasStopMessageClear && stopMessageSets.length > 0) {
@@ -213,7 +219,7 @@ export class VirtualRouterEngine {
213
219
  if (instructions.length > 0) {
214
220
  routingState = applyRoutingInstructions(instructions, routingState);
215
221
  this.routingInstructionState.set(stateKey, routingState);
216
- this.persistRoutingInstructionState(stateKey, routingState);
222
+ persistRoutingInstructionState(stateKey, routingState, this.routingStateStore);
217
223
  // 对 stopMessage 指令补充一份基于 session/conversation 的持久化状态,
218
224
  // 便于 server-side 工具通过 session:*/conversation:* scope 读取到相同配置。
219
225
  // stopMessage is strictly session-scoped (sessionId only). Persist it under the session scope
@@ -222,7 +228,7 @@ export class VirtualRouterEngine {
222
228
  const hasStopMessageSet = instructions.some((entry) => entry.type === 'stopMessageSet');
223
229
  const hasStopMessageClear = instructions.some((entry) => entry.type === 'stopMessageClear');
224
230
  if (hasStopMessageSet || hasStopMessageClear) {
225
- const sessionState = this.getRoutingInstructionState(stopMessageScope);
231
+ const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
226
232
  let nextSessionState = {
227
233
  ...sessionState
228
234
  };
@@ -267,7 +273,7 @@ export class VirtualRouterEngine {
267
273
  }
268
274
  if (shouldPersistSessionState) {
269
275
  this.routingInstructionState.set(stopMessageScope, nextSessionState);
270
- this.persistRoutingInstructionState(stopMessageScope, nextSessionState);
276
+ persistRoutingInstructionState(stopMessageScope, nextSessionState, this.routingStateStore);
271
277
  }
272
278
  else {
273
279
  nextSessionState = sessionState;
@@ -285,7 +291,7 @@ export class VirtualRouterEngine {
285
291
  }
286
292
  }
287
293
  if (instructions.length === 0 && stopMessageScope) {
288
- const sessionState = this.getRoutingInstructionState(stopMessageScope);
294
+ const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
289
295
  if (typeof sessionState.stopMessageText === 'string' ||
290
296
  typeof sessionState.stopMessageMaxRepeats === 'number') {
291
297
  routingState.stopMessageText = sessionState.stopMessageText;
@@ -308,14 +314,14 @@ export class VirtualRouterEngine {
308
314
  for (const key of pool.targets) {
309
315
  if (typeof key !== 'string' || !key)
310
316
  continue;
311
- const providerId = this.extractProviderId(key);
317
+ const providerId = extractProviderId(key);
312
318
  if (providerId) {
313
319
  providersInRouting.add(providerId);
314
320
  }
315
321
  }
316
322
  }
317
323
  }
318
- const allowed = Array.from(routingState.allowedProviders);
324
+ const allowed = Array.from(routingState.allowedProviders).filter((provider) => typeof provider === 'string');
319
325
  const hasIntersection = allowed.some((provider) => providersInRouting.has(provider));
320
326
  if (!hasIntersection) {
321
327
  routingState = {
@@ -323,7 +329,7 @@ export class VirtualRouterEngine {
323
329
  allowedProviders: new Set()
324
330
  };
325
331
  this.routingInstructionState.set(stateKey, routingState);
326
- this.persistRoutingInstructionState(stateKey, routingState);
332
+ persistRoutingInstructionState(stateKey, routingState, this.routingStateStore);
327
333
  }
328
334
  }
329
335
  const features = buildRoutingFeatures(request, metadata);
@@ -492,7 +498,7 @@ export class VirtualRouterEngine {
492
498
  preferTarget: undefined
493
499
  };
494
500
  this.routingInstructionState.set(stateKey, routingState);
495
- this.persistRoutingInstructionState(stateKey, routingState);
501
+ persistRoutingInstructionState(stateKey, routingState, this.routingStateStore);
496
502
  }
497
503
  }
498
504
  }
@@ -517,9 +523,20 @@ export class VirtualRouterEngine {
517
523
  ...(this.webSearchForce ? { forceWebSearch: true } : {}),
518
524
  ...(forceVision ? { forceVision: true } : {})
519
525
  };
520
- this.recordAntigravitySessionLease(features.metadata, selection.providerKey, { commitSessionBinding: false });
526
+ recordAntigravitySessionLease({
527
+ metadata: features.metadata,
528
+ providerKey: selection.providerKey,
529
+ sessionKey: this.resolveSessionScope(features.metadata),
530
+ providerRegistry: this.providerRegistry,
531
+ leaseStore: this.antigravityAliasLeaseStore,
532
+ sessionAliasStore: this.antigravitySessionAliasStore,
533
+ persistence: this.antigravityLeasePersistence,
534
+ aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs,
535
+ commitSessionBinding: false,
536
+ debug: this.debug
537
+ });
521
538
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
522
- const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
539
+ const routingMode = resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
523
540
  try {
524
541
  this.statsCenter.recordVirtualRouterHit({
525
542
  requestId: metadata.requestId,
@@ -569,9 +586,13 @@ export class VirtualRouterEngine {
569
586
  }
570
587
  getStopMessageState(metadata) {
571
588
  const sessionScope = this.resolveSessionScope(metadata);
572
- const sessionState = sessionScope ? this.getRoutingInstructionState(sessionScope) : null;
589
+ const sessionState = sessionScope
590
+ ? getRoutingInstructionState(sessionScope, this.routingInstructionState, this.routingStateStore)
591
+ : null;
573
592
  const stickyKey = this.resolveStickyKey(metadata);
574
- const stickyState = stickyKey ? this.getRoutingInstructionState(stickyKey) : null;
593
+ const stickyState = stickyKey
594
+ ? getRoutingInstructionState(stickyKey, this.routingInstructionState, this.routingStateStore)
595
+ : null;
575
596
  const effectiveState = sessionState && typeof sessionState.stopMessageText === 'string' && sessionState.stopMessageText.trim()
576
597
  ? sessionState
577
598
  : stickyState;
@@ -659,7 +680,18 @@ export class VirtualRouterEngine {
659
680
  return;
660
681
  }
661
682
  if (providerKey.toLowerCase().startsWith('antigravity.')) {
662
- this.recordAntigravitySessionLease(metadata, providerKey, { commitSessionBinding: true });
683
+ recordAntigravitySessionLease({
684
+ metadata: metadata,
685
+ providerKey,
686
+ sessionKey: this.resolveSessionScope(metadata),
687
+ providerRegistry: this.providerRegistry,
688
+ leaseStore: this.antigravityAliasLeaseStore,
689
+ sessionAliasStore: this.antigravitySessionAliasStore,
690
+ persistence: this.antigravityLeasePersistence,
691
+ aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs,
692
+ commitSessionBinding: true,
693
+ debug: this.debug
694
+ });
663
695
  }
664
696
  }
665
697
  getStatus() {
@@ -720,7 +752,8 @@ export class VirtualRouterEngine {
720
752
  }
721
753
  }
722
754
  selectProvider(requestedRoute, metadata, classification, features, routingState) {
723
- const activeState = routingState || this.getRoutingInstructionState(this.resolveStickyKey(metadata));
755
+ const activeState = routingState ||
756
+ getRoutingInstructionState(this.resolveStickyKey(metadata), this.routingInstructionState, this.routingStateStore);
724
757
  return selectProviderImpl(requestedRoute, metadata, classification, features, activeState, {
725
758
  routing: this.routing,
726
759
  providerRegistry: this.providerRegistry,
@@ -749,477 +782,10 @@ export class VirtualRouterEngine {
749
782
  return this.healthManager.getConfig();
750
783
  }
751
784
  resolveStickyKey(metadata) {
752
- const providerProtocol = metadata.providerProtocol;
753
- // 对 Responses 协议的自动粘滞,仅在“单次会话链路”内生效:
754
- // - Resume/submit 调用:stickyKey = previousRequestId(指向首轮请求);
755
- // - 普通 /v1/responses 调用:stickyKey = 本次 requestId;
756
- // 这样不会把 Responses 的自动粘滞扩散到整个 session,仅在需要 save/restore
757
- // 的请求链路中复用 provider.key.model。
758
- if (providerProtocol === 'openai-responses') {
759
- const resume = metadata.responsesResume;
760
- if (resume && typeof resume.previousRequestId === 'string' && resume.previousRequestId.trim()) {
761
- return resume.previousRequestId.trim();
762
- }
763
- return metadata.requestId;
764
- }
765
- // 其它协议沿用会话级 sticky 语义:sessionId / conversationId → requestId。
766
- const sessionScope = this.resolveSessionScope(metadata);
767
- if (sessionScope) {
768
- return sessionScope;
769
- }
770
- return metadata.requestId;
785
+ return resolveStickyKeyImpl(metadata);
771
786
  }
772
787
  resolveSessionScope(metadata) {
773
- const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
774
- if (sessionId) {
775
- return `session:${sessionId}`;
776
- }
777
- const conversationId = typeof metadata.conversationId === 'string' ? metadata.conversationId.trim() : '';
778
- if (conversationId) {
779
- return `conversation:${conversationId}`;
780
- }
781
- return undefined;
782
- }
783
- resolveAntigravityAliasReuseCooldownMs(config) {
784
- const cfg = config.loadBalancing?.aliasSelection?.sessionLeaseCooldownMs;
785
- if (typeof cfg === 'number' && Number.isFinite(cfg)) {
786
- return Math.max(0, Math.floor(cfg));
787
- }
788
- return 5 * 60_000;
789
- }
790
- resolveAntigravityLeaseScope(providerKey) {
791
- try {
792
- const profile = this.providerRegistry.get(providerKey);
793
- const modelId = typeof profile?.modelId === 'string' ? profile.modelId.trim().toLowerCase() : '';
794
- if (modelId.startsWith('gemini-')) {
795
- return 'gemini';
796
- }
797
- return null;
798
- }
799
- catch {
800
- return null;
801
- }
802
- }
803
- buildScopedSessionKey(sessionKey) {
804
- return `${sessionKey}::gemini`;
805
- }
806
- buildAntigravityLeaseRuntimeKey(runtimeKey) {
807
- return `${runtimeKey}::gemini`;
808
- }
809
- extractRuntimeKey(providerKey) {
810
- const value = typeof providerKey === 'string' ? providerKey.trim() : '';
811
- if (!value)
812
- return null;
813
- const firstDot = value.indexOf('.');
814
- if (firstDot <= 0 || firstDot === value.length - 1)
815
- return null;
816
- const secondDot = value.indexOf('.', firstDot + 1);
817
- if (secondDot <= firstDot + 1)
818
- return null;
819
- const providerId = value.slice(0, firstDot);
820
- const alias = value.slice(firstDot + 1, secondDot);
821
- if (!providerId || !alias)
822
- return null;
823
- return `${providerId}.${alias}`;
824
- }
825
- recordAntigravitySessionLease(metadata, providerKey, opts) {
826
- // Per-request runtime override: allow callers (e.g. servertool followups) to disable
827
- // Antigravity gemini session binding/lease without changing global config.
828
- try {
829
- const rt = metadata?.__rt;
830
- if (rt && typeof rt === 'object' && !Array.isArray(rt)) {
831
- const disable = rt.disableAntigravitySessionBinding;
832
- const mode = rt.antigravitySessionBinding;
833
- if (disable === true || mode === false) {
834
- return;
835
- }
836
- if (typeof mode === 'string' && ['0', 'false', 'off', 'disabled', 'none'].includes(mode.trim().toLowerCase())) {
837
- return;
838
- }
839
- }
840
- }
841
- catch {
842
- // ignore metadata parse errors
843
- }
844
- const sessionKey = this.resolveSessionScope(metadata);
845
- if (!sessionKey) {
846
- return;
847
- }
848
- const runtimeKeyBase = this.extractRuntimeKey(providerKey);
849
- if (!runtimeKeyBase || !runtimeKeyBase.startsWith('antigravity.')) {
850
- return;
851
- }
852
- const commitSessionBinding = opts?.commitSessionBinding === true;
853
- const scope = this.resolveAntigravityLeaseScope(providerKey);
854
- // Policy: antigravity alias leasing/binding applies only to Gemini models.
855
- if (scope !== 'gemini') {
856
- return;
857
- }
858
- const scopedSessionKey = this.buildScopedSessionKey(sessionKey);
859
- const runtimeKey = this.buildAntigravityLeaseRuntimeKey(runtimeKeyBase);
860
- const now = Date.now();
861
- const cooldownMs = this.antigravityAliasReuseCooldownMs;
862
- const existing = this.antigravityAliasLeaseStore.get(runtimeKey);
863
- // Do not steal a lease from another active session. This prevents a "forced fallback" route
864
- // (e.g. default route bypass) from evicting the original session and causing churn.
865
- if (existing && existing.sessionKey !== scopedSessionKey && cooldownMs > 0 && now - existing.lastSeenAt < cooldownMs) {
866
- return;
867
- }
868
- this.antigravityAliasLeaseStore.set(runtimeKey, { sessionKey: scopedSessionKey, lastSeenAt: now });
869
- if (commitSessionBinding) {
870
- const previous = this.antigravitySessionAliasStore.get(scopedSessionKey);
871
- this.antigravitySessionAliasStore.set(scopedSessionKey, runtimeKey);
872
- try {
873
- const raw = String(process.env.ROUTECODEX_STAGE_LOG || process.env.RCC_STAGE_LOG || '').trim().toLowerCase();
874
- const enabled = raw !== '' && raw !== '0' && raw !== 'false' && raw !== 'no';
875
- if (enabled && previous !== runtimeKey) {
876
- this.debug?.log?.('[virtual-router][antigravity-session-binding] commit', JSON.stringify({ sessionKey: scopedSessionKey, runtimeKey, prev: previous ?? null }));
877
- }
878
- }
879
- catch {
880
- // ignore logging errors
881
- }
882
- }
883
- this.scheduleAntigravityAliasLeaseStoreFlush();
884
- // Opportunistic cleanup: keep only active leases within cooldown window.
885
- if (cooldownMs <= 0) {
886
- return;
887
- }
888
- for (const [key, entry] of this.antigravityAliasLeaseStore.entries()) {
889
- if (now - entry.lastSeenAt >= cooldownMs) {
890
- this.antigravityAliasLeaseStore.delete(key);
891
- // Clear session mapping if it still points at the expired runtimeKey.
892
- if (this.antigravitySessionAliasStore.get(entry.sessionKey) === key) {
893
- this.antigravitySessionAliasStore.delete(entry.sessionKey);
894
- }
895
- }
896
- }
897
- }
898
- resolveAntigravityAliasLeasePersistPath() {
899
- try {
900
- const enabledRaw = process.env.ROUTECODEX_ANTIGRAVITY_ALIAS_LEASE_PERSIST;
901
- const enabled = typeof enabledRaw === 'string' &&
902
- ['1', 'true', 'yes', 'on'].includes(enabledRaw.trim().toLowerCase());
903
- if (!enabled) {
904
- return null;
905
- }
906
- const home = os.homedir();
907
- if (!home)
908
- return null;
909
- return path.join(home, '.routecodex', 'state', 'antigravity-alias-leases.json');
910
- }
911
- catch {
912
- return null;
913
- }
914
- }
915
- hydrateAntigravityAliasLeaseStoreIfNeeded(force = false) {
916
- const filePath = this.resolveAntigravityAliasLeasePersistPath();
917
- if (!filePath) {
918
- return;
919
- }
920
- let stat = null;
921
- try {
922
- stat = fs.statSync(filePath);
923
- }
924
- catch {
925
- this.antigravityLeasePersistence.loadedOnce = true;
926
- this.antigravityLeasePersistence.loadedMtimeMs = null;
927
- return;
928
- }
929
- const mtimeMs = typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : stat.mtime.getTime();
930
- if (!force && this.antigravityLeasePersistence.loadedOnce && this.antigravityLeasePersistence.loadedMtimeMs === mtimeMs) {
931
- return;
932
- }
933
- let parsed;
934
- try {
935
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
936
- }
937
- catch {
938
- this.antigravityLeasePersistence.loadedOnce = true;
939
- this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
940
- return;
941
- }
942
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
943
- this.antigravityLeasePersistence.loadedOnce = true;
944
- this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
945
- return;
946
- }
947
- const leasesRaw = parsed.leases;
948
- if (!leasesRaw || typeof leasesRaw !== 'object' || Array.isArray(leasesRaw)) {
949
- this.antigravityLeasePersistence.loadedOnce = true;
950
- this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
951
- return;
952
- }
953
- const now = Date.now();
954
- const cooldownMs = this.antigravityAliasReuseCooldownMs;
955
- const nextLeaseStore = new Map();
956
- for (const [runtimeKeyRaw, entryRaw] of Object.entries(leasesRaw)) {
957
- const runtimeKeyBase = typeof runtimeKeyRaw === 'string' ? runtimeKeyRaw.trim() : '';
958
- if (!runtimeKeyBase || !runtimeKeyBase.startsWith('antigravity.'))
959
- continue;
960
- if (!entryRaw || typeof entryRaw !== 'object' || Array.isArray(entryRaw))
961
- continue;
962
- const rawSessionKey = typeof entryRaw.sessionKey === 'string' ? String(entryRaw.sessionKey).trim() : '';
963
- const lastSeenAt = typeof entryRaw.lastSeenAt === 'number' && Number.isFinite(entryRaw.lastSeenAt)
964
- ? Math.floor(entryRaw.lastSeenAt)
965
- : 0;
966
- if (!rawSessionKey || !lastSeenAt)
967
- continue;
968
- if (cooldownMs > 0 && now - lastSeenAt >= cooldownMs)
969
- continue;
970
- // Backwards compatible: legacy lease keys were unscoped ("antigravity.<alias>").
971
- // Policy: only keep Gemini-scoped leases. Drop any non-gemini scoped entries.
972
- const hasExplicitScope = runtimeKeyBase.includes('::');
973
- if (hasExplicitScope && !runtimeKeyBase.endsWith('::gemini'))
974
- continue;
975
- const runtimeKey = hasExplicitScope ? runtimeKeyBase : this.buildAntigravityLeaseRuntimeKey(runtimeKeyBase);
976
- if (rawSessionKey.includes('::') && !rawSessionKey.endsWith('::gemini'))
977
- continue;
978
- const sessionKey = rawSessionKey.includes('::') ? rawSessionKey : this.buildScopedSessionKey(rawSessionKey);
979
- nextLeaseStore.set(runtimeKey, { sessionKey, lastSeenAt });
980
- }
981
- this.antigravityAliasLeaseStore.clear();
982
- for (const [key, val] of nextLeaseStore.entries()) {
983
- this.antigravityAliasLeaseStore.set(key, val);
984
- }
985
- this.antigravityLeasePersistence.loadedOnce = true;
986
- this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
987
- }
988
- scheduleAntigravityAliasLeaseStoreFlush() {
989
- if (this.antigravityLeasePersistence.flushTimer) {
990
- return;
991
- }
992
- this.antigravityLeasePersistence.flushTimer = setTimeout(() => {
993
- this.antigravityLeasePersistence.flushTimer = null;
994
- this.flushAntigravityAliasLeaseStoreSync();
995
- }, 250);
996
- this.antigravityLeasePersistence.flushTimer.unref?.();
997
- }
998
- flushAntigravityAliasLeaseStoreSync() {
999
- const filePath = this.resolveAntigravityAliasLeasePersistPath();
1000
- if (!filePath) {
1001
- return;
1002
- }
1003
- try {
1004
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1005
- }
1006
- catch {
1007
- // ignore
1008
- }
1009
- const now = Date.now();
1010
- const cooldownMs = this.antigravityAliasReuseCooldownMs;
1011
- const leases = {};
1012
- for (const [runtimeKey, entry] of this.antigravityAliasLeaseStore.entries()) {
1013
- if (!runtimeKey || !entry)
1014
- continue;
1015
- if (cooldownMs > 0 && now - entry.lastSeenAt >= cooldownMs)
1016
- continue;
1017
- if (!entry.sessionKey || !entry.lastSeenAt)
1018
- continue;
1019
- leases[runtimeKey] = { sessionKey: entry.sessionKey, lastSeenAt: entry.lastSeenAt };
1020
- }
1021
- try {
1022
- const tmpPath = `${filePath}.tmp.${process.pid}.${now}`;
1023
- fs.writeFileSync(tmpPath, JSON.stringify({ version: 1, updatedAt: now, leases }, null, 2), 'utf8');
1024
- fs.renameSync(tmpPath, filePath);
1025
- try {
1026
- const stat = fs.statSync(filePath);
1027
- const mtimeMs = typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : stat.mtime.getTime();
1028
- this.antigravityLeasePersistence.loadedOnce = true;
1029
- this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
1030
- }
1031
- catch {
1032
- // ignore
1033
- }
1034
- }
1035
- catch {
1036
- // best-effort persistence: must not affect routing
1037
- }
1038
- }
1039
- resolveStopMessageScope(metadata) {
1040
- const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
1041
- if (sessionId) {
1042
- return `session:${sessionId}`;
1043
- }
1044
- return undefined;
1045
- }
1046
- getRoutingInstructionState(stickyKey) {
1047
- const key = stickyKey || 'default';
1048
- const existing = this.routingInstructionState.get(key);
1049
- // 对 session:/conversation: 作用域,在每次读取时尝试从磁盘刷新 stopMessage 相关字段,
1050
- // 确保 servertool(如 stop_message_auto)通过 sticky-session-store 更新的使用次数
1051
- // 能在 VirtualRouter 日志中实时反映出来。
1052
- if (existing && (key.startsWith('session:') || key.startsWith('conversation:'))) {
1053
- try {
1054
- const persisted = this.routingStateStore.loadSync(key);
1055
- const merged = mergeStopMessageFromPersisted(existing, persisted);
1056
- existing.stopMessageSource = merged.stopMessageSource;
1057
- existing.stopMessageText = merged.stopMessageText;
1058
- existing.stopMessageMaxRepeats = merged.stopMessageMaxRepeats;
1059
- existing.stopMessageUsed = merged.stopMessageUsed;
1060
- existing.stopMessageUpdatedAt = merged.stopMessageUpdatedAt;
1061
- existing.stopMessageLastUsedAt = merged.stopMessageLastUsedAt;
1062
- }
1063
- catch {
1064
- // 刷新失败不影响原有内存状态
1065
- }
1066
- return existing;
1067
- }
1068
- let initial = null;
1069
- // 仅对 session:/conversation: 作用域的 key 尝试从磁盘恢复持久化状态
1070
- if (key.startsWith('session:') || key.startsWith('conversation:')) {
1071
- initial = this.routingStateStore.loadSync(key);
1072
- }
1073
- if (!initial) {
1074
- initial = {
1075
- forcedTarget: undefined,
1076
- stickyTarget: undefined,
1077
- allowedProviders: new Set(),
1078
- disabledProviders: new Set(),
1079
- disabledKeys: new Map(),
1080
- disabledModels: new Map(),
1081
- stopMessageSource: undefined,
1082
- stopMessageText: undefined,
1083
- stopMessageMaxRepeats: undefined,
1084
- stopMessageUsed: undefined,
1085
- stopMessageUpdatedAt: undefined,
1086
- stopMessageLastUsedAt: undefined
1087
- };
1088
- }
1089
- this.routingInstructionState.set(key, initial);
1090
- return initial;
1091
- }
1092
- buildMetadataInstructions(metadata) {
1093
- const instructions = [];
1094
- const forcedProviderKeyRaw = metadata
1095
- .__shadowCompareForcedProviderKey;
1096
- const forcedProviderKey = this.parseMetadataForceProviderKey(forcedProviderKeyRaw);
1097
- if (forcedProviderKey) {
1098
- instructions.push({ type: 'force', ...forcedProviderKey });
1099
- }
1100
- if (Array.isArray(metadata.disabledProviderKeyAliases)) {
1101
- for (const entry of metadata.disabledProviderKeyAliases) {
1102
- const parsed = this.parseMetadataDisableDescriptor(entry);
1103
- if (parsed) {
1104
- instructions.push({ type: 'disable', ...parsed });
1105
- }
1106
- }
1107
- }
1108
- return instructions;
1109
- }
1110
- parseMetadataDisableDescriptor(entry) {
1111
- if (typeof entry !== 'string') {
1112
- return null;
1113
- }
1114
- const trimmed = entry.trim();
1115
- if (!trimmed) {
1116
- return null;
1117
- }
1118
- const parts = trimmed.split('.');
1119
- if (parts.length < 2) {
1120
- return null;
1121
- }
1122
- const provider = parts[0];
1123
- const alias = parts[1];
1124
- if (!provider || !alias) {
1125
- return null;
1126
- }
1127
- if (/^\d+$/.test(alias)) {
1128
- return { provider, keyIndex: Number.parseInt(alias, 10) };
1129
- }
1130
- return { provider, keyAlias: alias };
1131
- }
1132
- parseMetadataForceProviderKey(entry) {
1133
- if (typeof entry !== 'string') {
1134
- return null;
1135
- }
1136
- const trimmed = entry.trim();
1137
- if (!trimmed) {
1138
- return null;
1139
- }
1140
- // Accept the bracket notation used in virtual-router-hit logs: provider[alias].model
1141
- // - provider[].model means provider.model across all aliases
1142
- const bracketMatch = trimmed.match(/^([a-zA-Z0-9_-]+)\[([a-zA-Z0-9_-]*)\](?:\.(.+))?$/);
1143
- if (bracketMatch) {
1144
- const provider = bracketMatch[1]?.trim() || '';
1145
- const keyAlias = bracketMatch[2]?.trim() || '';
1146
- const model = typeof bracketMatch[3] === 'string' ? bracketMatch[3].trim() : '';
1147
- if (!provider) {
1148
- return null;
1149
- }
1150
- if (keyAlias) {
1151
- return {
1152
- provider,
1153
- keyAlias,
1154
- ...(model ? { model } : {}),
1155
- pathLength: 3
1156
- };
1157
- }
1158
- if (model) {
1159
- return {
1160
- provider,
1161
- model,
1162
- pathLength: 2
1163
- };
1164
- }
1165
- return { provider, pathLength: 1 };
1166
- }
1167
- // Accept provider.keyAlias.model and provider.model (model may contain dots when keyAlias is explicit).
1168
- const parts = trimmed.split('.').map((part) => part.trim()).filter(Boolean);
1169
- if (parts.length === 0) {
1170
- return null;
1171
- }
1172
- const provider = parts[0] || '';
1173
- if (!provider) {
1174
- return null;
1175
- }
1176
- if (parts.length === 1) {
1177
- return { provider, pathLength: 1 };
1178
- }
1179
- if (parts.length === 2) {
1180
- const second = parts[1] || '';
1181
- if (!second) {
1182
- return null;
1183
- }
1184
- if (/^\d+$/.test(second)) {
1185
- const keyIndex = Number.parseInt(second, 10);
1186
- return Number.isFinite(keyIndex) && keyIndex > 0 ? { provider, keyIndex, pathLength: 2 } : null;
1187
- }
1188
- return { provider, model: second, pathLength: 2 };
1189
- }
1190
- const keyAlias = parts[1] || '';
1191
- const model = parts.slice(2).join('.').trim();
1192
- if (!keyAlias) {
1193
- return null;
1194
- }
1195
- return {
1196
- provider,
1197
- keyAlias,
1198
- ...(model ? { model } : {}),
1199
- pathLength: 3
1200
- };
1201
- }
1202
- resolveRoutingMode(instructions, state) {
1203
- const hasForce = instructions.some((inst) => inst.type === 'force');
1204
- const hasAllow = instructions.some((inst) => inst.type === 'allow');
1205
- const hasClear = instructions.some((inst) => inst.type === 'clear');
1206
- const hasPrefer = instructions.some((inst) => inst.type === 'prefer');
1207
- if (hasClear) {
1208
- return 'none';
1209
- }
1210
- if (hasAllow || state.allowedProviders.size > 0) {
1211
- return 'sticky';
1212
- }
1213
- if (hasForce || state.forcedTarget) {
1214
- return 'force';
1215
- }
1216
- if (hasPrefer || state.preferTarget) {
1217
- return 'sticky';
1218
- }
1219
- if (state.stickyTarget) {
1220
- return 'sticky';
1221
- }
1222
- return 'none';
788
+ return resolveSessionScopeImpl(metadata);
1223
789
  }
1224
790
  resolveInstructionTarget(target) {
1225
791
  if (!target || !target.provider) {
@@ -1247,7 +813,7 @@ export class VirtualRouterEngine {
1247
813
  if (target.model && target.model.trim()) {
1248
814
  const normalizedModel = target.model.trim();
1249
815
  const matchingKeys = providerKeys.filter((key) => {
1250
- const modelId = this.getProviderModelId(key);
816
+ const modelId = getProviderModelId(key, this.providerRegistry);
1251
817
  return modelId === normalizedModel;
1252
818
  });
1253
819
  if (matchingKeys.length > 0) {
@@ -1282,7 +848,7 @@ export class VirtualRouterEngine {
1282
848
  continue;
1283
849
  }
1284
850
  for (const providerKey of pool.targets) {
1285
- const providerId = this.extractProviderId(providerKey);
851
+ const providerId = extractProviderId(providerKey);
1286
852
  // console.log('[filter] checking', providerKey, 'id=', providerId);
1287
853
  if (!providerId)
1288
854
  continue;
@@ -1295,8 +861,8 @@ export class VirtualRouterEngine {
1295
861
  }
1296
862
  const disabledKeys = state.disabledKeys.get(providerId);
1297
863
  if (disabledKeys && disabledKeys.size > 0) {
1298
- const keyAlias = this.extractKeyAlias(providerKey);
1299
- const keyIndex = this.extractKeyIndex(providerKey);
864
+ const keyAlias = extractKeyAlias(providerKey);
865
+ const keyIndex = extractKeyIndex(providerKey);
1300
866
  if (keyAlias && disabledKeys.has(keyAlias)) {
1301
867
  continue;
1302
868
  }
@@ -1306,7 +872,7 @@ export class VirtualRouterEngine {
1306
872
  }
1307
873
  const disabledModels = state.disabledModels.get(providerId);
1308
874
  if (disabledModels && disabledModels.size > 0) {
1309
- const modelId = this.getProviderModelId(providerKey);
875
+ const modelId = getProviderModelId(providerKey, this.providerRegistry);
1310
876
  if (modelId && disabledModels.has(modelId)) {
1311
877
  continue;
1312
878
  }
@@ -1331,12 +897,6 @@ export class VirtualRouterEngine {
1331
897
  aliasQueueStore: this.aliasQueueStore
1332
898
  }, { routingState: state });
1333
899
  }
1334
- extractProviderId(providerKey) {
1335
- const firstDot = providerKey.indexOf('.');
1336
- if (firstDot <= 0)
1337
- return null;
1338
- return providerKey.substring(0, firstDot);
1339
- }
1340
900
  /**
1341
901
  * 在已有候选路由集合上,筛选出真正挂载了 sticky 池内 providerKey 的路由,
1342
902
  * 并按 ROUTE_PRIORITY 进行排序;同时显式排除 tools 路由,保证一旦进入
@@ -1410,27 +970,27 @@ export class VirtualRouterEngine {
1410
970
  // 应用 provider 白名单 / 黑名单
1411
971
  if (allowedProviders.size > 0) {
1412
972
  candidates = candidates.filter((key) => {
1413
- const providerId = this.extractProviderId(key);
973
+ const providerId = extractProviderId(key);
1414
974
  return providerId && allowedProviders.has(providerId);
1415
975
  });
1416
976
  }
1417
977
  if (disabledProviders.size > 0) {
1418
978
  candidates = candidates.filter((key) => {
1419
- const providerId = this.extractProviderId(key);
979
+ const providerId = extractProviderId(key);
1420
980
  return providerId && !disabledProviders.has(providerId);
1421
981
  });
1422
982
  }
1423
983
  // 应用 key / model 级别黑名单
1424
984
  if (disabledKeysMap.size > 0 || disabledModels.size > 0) {
1425
985
  candidates = candidates.filter((key) => {
1426
- const providerId = this.extractProviderId(key);
986
+ const providerId = extractProviderId(key);
1427
987
  if (!providerId) {
1428
988
  return true;
1429
989
  }
1430
990
  const disabledKeys = disabledKeysMap.get(providerId);
1431
991
  if (disabledKeys && disabledKeys.size > 0) {
1432
- const keyAlias = this.extractKeyAlias(key);
1433
- const keyIndex = this.extractKeyIndex(key);
992
+ const keyAlias = extractKeyAlias(key);
993
+ const keyIndex = extractKeyIndex(key);
1434
994
  if (keyAlias && disabledKeys.has(keyAlias)) {
1435
995
  return false;
1436
996
  }
@@ -1440,7 +1000,7 @@ export class VirtualRouterEngine {
1440
1000
  }
1441
1001
  const disabledModelSet = disabledModels.get(providerId);
1442
1002
  if (disabledModelSet && disabledModelSet.size > 0) {
1443
- const modelId = this.getProviderModelId(key);
1003
+ const modelId = getProviderModelId(key, this.providerRegistry);
1444
1004
  if (modelId && disabledModelSet.has(modelId)) {
1445
1005
  return false;
1446
1006
  }
@@ -1458,43 +1018,6 @@ export class VirtualRouterEngine {
1458
1018
  // delegate to selection module
1459
1019
  return null;
1460
1020
  }
1461
- extractKeyAlias(providerKey) {
1462
- const parts = providerKey.split('.');
1463
- if (parts.length === 3) {
1464
- return this.normalizeAliasDescriptor(parts[1]);
1465
- }
1466
- return null;
1467
- }
1468
- normalizeAliasDescriptor(alias) {
1469
- if (/^\d+-/.test(alias)) {
1470
- return alias.replace(/^\d+-/, '');
1471
- }
1472
- return alias;
1473
- }
1474
- extractKeyIndex(providerKey) {
1475
- const parts = providerKey.split('.');
1476
- if (parts.length === 2) {
1477
- const index = parseInt(parts[1], 10);
1478
- if (!isNaN(index) && index > 0) {
1479
- return index;
1480
- }
1481
- }
1482
- return undefined;
1483
- }
1484
- getProviderModelId(providerKey) {
1485
- const profile = this.providerRegistry.get(providerKey);
1486
- if (profile.modelId) {
1487
- return profile.modelId;
1488
- }
1489
- const parts = providerKey.split('.');
1490
- if (parts.length === 2) {
1491
- return parts[1] || null;
1492
- }
1493
- if (parts.length === 3) {
1494
- return parts[2] || null;
1495
- }
1496
- return null;
1497
- }
1498
1021
  // mapProviderError/applySeriesCooldown moved to engine-health.ts
1499
1022
  extractExcludedProviderKeySet(metadata) {
1500
1023
  if (!metadata) {
@@ -1531,10 +1054,27 @@ export class VirtualRouterEngine {
1531
1054
  }
1532
1055
  }
1533
1056
  }
1057
+ if (features.hasImageAttachment) {
1058
+ const allRouteNames = Object.keys(this.routing);
1059
+ for (const routeName of allRouteNames) {
1060
+ if (!this.routeHasTargets(this.routing[routeName])) {
1061
+ continue;
1062
+ }
1063
+ if (!this.routeSupportsModel(routeName, 'kimi-k2.5')) {
1064
+ continue;
1065
+ }
1066
+ if (!baseList.includes(routeName)) {
1067
+ baseList.push(routeName);
1068
+ }
1069
+ }
1070
+ }
1534
1071
  let ordered = this.sortByPriority(baseList);
1535
1072
  if (features.hasImageAttachment && !forceVision) {
1536
1073
  ordered = this.reorderForInlineVision(ordered);
1537
1074
  }
1075
+ if (features.hasImageAttachment) {
1076
+ ordered = this.reorderForPreferredModel(ordered, 'kimi-k2.5');
1077
+ }
1538
1078
  const deduped = [];
1539
1079
  for (const routeName of ordered) {
1540
1080
  if (routeName && !deduped.includes(routeName)) {
@@ -1574,6 +1114,46 @@ export class VirtualRouterEngine {
1574
1114
  }
1575
1115
  return [...inlinePreferred, ...remaining];
1576
1116
  }
1117
+ reorderForPreferredModel(routeNames, modelId) {
1118
+ const unique = Array.from(new Set(routeNames.filter(Boolean)));
1119
+ if (!unique.length) {
1120
+ return unique;
1121
+ }
1122
+ const preferred = unique.filter((routeName) => this.routeSupportsModel(routeName, modelId));
1123
+ if (!preferred.length) {
1124
+ return unique;
1125
+ }
1126
+ const remaining = unique.filter((routeName) => !preferred.includes(routeName));
1127
+ return [...preferred, ...remaining];
1128
+ }
1129
+ routeSupportsModel(routeName, modelId) {
1130
+ const normalizedModel = modelId.trim().toLowerCase();
1131
+ if (!normalizedModel) {
1132
+ return false;
1133
+ }
1134
+ const pools = this.routing[routeName];
1135
+ if (!Array.isArray(pools)) {
1136
+ return false;
1137
+ }
1138
+ for (const pool of pools) {
1139
+ if (!Array.isArray(pool.targets)) {
1140
+ continue;
1141
+ }
1142
+ for (const providerKey of pool.targets) {
1143
+ try {
1144
+ const profile = this.providerRegistry.get(providerKey);
1145
+ const candidate = typeof profile.modelId === 'string' ? profile.modelId.trim().toLowerCase() : '';
1146
+ if (candidate === normalizedModel) {
1147
+ return true;
1148
+ }
1149
+ }
1150
+ catch {
1151
+ // ignore unknown provider keys during capability probing
1152
+ }
1153
+ }
1154
+ }
1155
+ return false;
1156
+ }
1577
1157
  routeSupportsInlineVision(routeName) {
1578
1158
  const pools = this.routing[routeName];
1579
1159
  if (!Array.isArray(pools)) {
@@ -1657,59 +1237,6 @@ export class VirtualRouterEngine {
1657
1237
  }
1658
1238
  return flattened;
1659
1239
  }
1660
- isRoutingStateEmpty(state) {
1661
- if (!state) {
1662
- return true;
1663
- }
1664
- const noForced = !state.forcedTarget;
1665
- const noSticky = !state.stickyTarget;
1666
- const noPrefer = !state.preferTarget;
1667
- const noAllowed = state.allowedProviders.size === 0;
1668
- const noDisabledProviders = state.disabledProviders.size === 0;
1669
- const noDisabledKeys = state.disabledKeys.size === 0;
1670
- const noDisabledModels = state.disabledModels.size === 0;
1671
- const noStopMessage = (!state.stopMessageText || !state.stopMessageText.trim()) &&
1672
- (typeof state.stopMessageMaxRepeats !== 'number' || !Number.isFinite(state.stopMessageMaxRepeats)) &&
1673
- (typeof state.stopMessageUsed !== 'number' || !Number.isFinite(state.stopMessageUsed)) &&
1674
- (typeof state.stopMessageUpdatedAt !== 'number' || !Number.isFinite(state.stopMessageUpdatedAt)) &&
1675
- (typeof state.stopMessageLastUsedAt !== 'number' || !Number.isFinite(state.stopMessageLastUsedAt));
1676
- return (noForced &&
1677
- noSticky &&
1678
- noPrefer &&
1679
- noAllowed &&
1680
- noDisabledProviders &&
1681
- noDisabledKeys &&
1682
- noDisabledModels &&
1683
- noStopMessage);
1684
- }
1685
- persistRoutingInstructionState(key, state) {
1686
- if (!key || (!key.startsWith('session:') && !key.startsWith('conversation:'))) {
1687
- return;
1688
- }
1689
- const supportsSync = typeof this.routingStateStore.saveSync === 'function';
1690
- const prefersSync = supportsSync &&
1691
- key.startsWith('session:') &&
1692
- (Boolean(state.stopMessageText && state.stopMessageText.trim()) ||
1693
- (typeof state.stopMessageMaxRepeats === 'number' && Number.isFinite(state.stopMessageMaxRepeats)) ||
1694
- (typeof state.stopMessageUsed === 'number' && Number.isFinite(state.stopMessageUsed)) ||
1695
- (typeof state.stopMessageUpdatedAt === 'number' && Number.isFinite(state.stopMessageUpdatedAt)) ||
1696
- (typeof state.stopMessageLastUsedAt === 'number' && Number.isFinite(state.stopMessageLastUsedAt)));
1697
- if (this.isRoutingStateEmpty(state)) {
1698
- if (prefersSync) {
1699
- this.routingStateStore.saveSync(key, null);
1700
- }
1701
- else {
1702
- this.routingStateStore.saveAsync(key, null);
1703
- }
1704
- return;
1705
- }
1706
- if (prefersSync) {
1707
- this.routingStateStore.saveSync(key, state);
1708
- }
1709
- else {
1710
- this.routingStateStore.saveAsync(key, state);
1711
- }
1712
- }
1713
1240
  markProviderCooldown(providerKey, cooldownMs) {
1714
1241
  if (!providerKey) {
1715
1242
  return;