@jsonstudio/llms 0.6.1449 → 0.6.1643

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 (71) hide show
  1. package/dist/conversion/codecs/gemini-openai-codec.js +6 -1
  2. package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.d.ts +4 -6
  3. package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.js +179 -41
  4. package/dist/conversion/compat/actions/antigravity-thought-signature-cache.js +73 -14
  5. package/dist/conversion/compat/actions/antigravity-thought-signature-prepare.js +165 -10
  6. package/dist/conversion/compat/actions/gemini-cli-request.js +72 -13
  7. package/dist/conversion/compat/antigravity-session-signature.d.ts +68 -1
  8. package/dist/conversion/compat/antigravity-session-signature.js +833 -21
  9. package/dist/conversion/compat/profiles/anthropic-claude-code.json +17 -0
  10. package/dist/conversion/compat/profiles/chat-gemini-cli.json +1 -0
  11. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +33 -8
  12. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +17 -1
  13. package/dist/conversion/hub/pipeline/compat/compat-profile-store.js +12 -3
  14. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +1 -0
  15. package/dist/conversion/hub/pipeline/hub-pipeline.js +24 -0
  16. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +20 -0
  17. package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +26 -1
  18. package/dist/conversion/hub/process/chat-process.js +300 -67
  19. package/dist/conversion/hub/response/provider-response.js +4 -3
  20. package/dist/conversion/shared/gemini-tool-utils.js +134 -9
  21. package/dist/conversion/shared/text-markup-normalizer.js +90 -1
  22. package/dist/conversion/shared/thought-signature-validator.d.ts +1 -1
  23. package/dist/conversion/shared/thought-signature-validator.js +2 -1
  24. package/dist/quota/apikey-reset.d.ts +17 -0
  25. package/dist/quota/apikey-reset.js +43 -0
  26. package/dist/quota/index.d.ts +2 -0
  27. package/dist/quota/index.js +1 -0
  28. package/dist/quota/quota-manager.d.ts +44 -0
  29. package/dist/quota/quota-manager.js +491 -0
  30. package/dist/quota/quota-state.d.ts +6 -0
  31. package/dist/quota/quota-state.js +167 -0
  32. package/dist/quota/types.d.ts +61 -0
  33. package/dist/quota/types.js +1 -0
  34. package/dist/router/virtual-router/bootstrap.js +103 -6
  35. package/dist/router/virtual-router/engine-health.js +104 -0
  36. package/dist/router/virtual-router/engine-selection/selection-deps.d.ts +18 -0
  37. package/dist/router/virtual-router/engine-selection/tier-priority.d.ts +1 -2
  38. package/dist/router/virtual-router/engine-selection/tier-priority.js +2 -2
  39. package/dist/router/virtual-router/engine-selection/tier-selection-select.js +34 -10
  40. package/dist/router/virtual-router/engine-selection/tier-selection.js +250 -6
  41. package/dist/router/virtual-router/engine-selection.js +2 -2
  42. package/dist/router/virtual-router/engine.d.ts +16 -1
  43. package/dist/router/virtual-router/engine.js +320 -42
  44. package/dist/router/virtual-router/features.js +20 -2
  45. package/dist/router/virtual-router/success-center.d.ts +10 -0
  46. package/dist/router/virtual-router/success-center.js +32 -0
  47. package/dist/router/virtual-router/types.d.ts +48 -0
  48. package/dist/servertool/clock/config.d.ts +2 -0
  49. package/dist/servertool/clock/config.js +10 -2
  50. package/dist/servertool/clock/daemon.js +3 -0
  51. package/dist/servertool/clock/ntp.d.ts +18 -0
  52. package/dist/servertool/clock/ntp.js +318 -0
  53. package/dist/servertool/clock/paths.d.ts +1 -0
  54. package/dist/servertool/clock/paths.js +3 -0
  55. package/dist/servertool/clock/state.d.ts +2 -0
  56. package/dist/servertool/clock/state.js +15 -2
  57. package/dist/servertool/clock/tasks.d.ts +1 -0
  58. package/dist/servertool/clock/tasks.js +24 -1
  59. package/dist/servertool/clock/types.d.ts +21 -0
  60. package/dist/servertool/engine.js +105 -1
  61. package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.d.ts +1 -0
  62. package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.js +201 -0
  63. package/dist/servertool/handlers/clock-auto.js +39 -4
  64. package/dist/servertool/handlers/clock.js +145 -16
  65. package/dist/servertool/handlers/followup-request-builder.js +84 -0
  66. package/dist/servertool/handlers/stop-message-auto.js +1 -1
  67. package/dist/servertool/server-side-tools.d.ts +1 -0
  68. package/dist/servertool/server-side-tools.js +1 -0
  69. package/dist/servertool/types.d.ts +2 -0
  70. package/dist/tools/apply-patch/execution-capturer.js +24 -3
  71. package/package.json +3 -2
@@ -4,6 +4,9 @@ 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';
7
10
  import { DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
8
11
  import { getStatsCenter } from '../../telemetry/stats-center.js';
9
12
  import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
@@ -25,6 +28,13 @@ export class VirtualRouterEngine {
25
28
  // Alias selection state (global within this VirtualRouterEngine instance).
26
29
  // Used by alias-selection strategies to avoid rapid cross-alias switching.
27
30
  aliasQueueStore = new Map();
31
+ // Antigravity alias session lease store:
32
+ // - Enforces that one auth alias is not shared across different sessions within a cooldown window.
33
+ // - Tracks lastSeenAt per alias runtimeKey (providerId.keyAlias).
34
+ antigravityAliasLeaseStore = new Map();
35
+ antigravitySessionAliasStore = new Map();
36
+ antigravityAliasReuseCooldownMs = 5 * 60_000;
37
+ antigravityLeasePersistence = { loadedOnce: false, loadedMtimeMs: null, flushTimer: null };
28
38
  debug = console; // thin hook; host may monkey-patch for colored logging
29
39
  healthConfig = null;
30
40
  statsCenter = getStatsCenter();
@@ -68,7 +78,19 @@ export class VirtualRouterEngine {
68
78
  this.routingInstructionState.clear();
69
79
  }
70
80
  if ('quotaView' in deps) {
81
+ const prevQuotaEnabled = Boolean(this.quotaView);
71
82
  this.quotaView = deps.quotaView ?? undefined;
83
+ const nextQuotaEnabled = Boolean(this.quotaView);
84
+ // When quotaView is enabled, health/cooldown decisions must be driven by quotaView only.
85
+ // - Enabling quotaView: clear any legacy router-local cooldown TTLs immediately.
86
+ // - Disabling quotaView: reload legacy cooldown state from health snapshots.
87
+ if (!prevQuotaEnabled && nextQuotaEnabled) {
88
+ this.providerCooldowns.clear();
89
+ }
90
+ else if (prevQuotaEnabled && !nextQuotaEnabled) {
91
+ this.providerCooldowns.clear();
92
+ this.restoreHealthFromStore();
93
+ }
72
94
  }
73
95
  }
74
96
  parseDirectProviderModel(model) {
@@ -100,6 +122,8 @@ export class VirtualRouterEngine {
100
122
  this.providerCooldowns.clear();
101
123
  this.restoreHealthFromStore();
102
124
  this.loadBalancer = new RouteLoadBalancer(config.loadBalancing);
125
+ this.antigravityAliasReuseCooldownMs = this.resolveAntigravityAliasReuseCooldownMs(config);
126
+ this.hydrateAntigravityAliasLeaseStoreIfNeeded(true);
103
127
  this.classifier = new RoutingClassifier(config.classifier);
104
128
  this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
105
129
  this.contextAdvisor.configure(this.contextRouting);
@@ -394,13 +418,13 @@ export class VirtualRouterEngine {
394
418
  const quotaView = selectionDeps.quotaView;
395
419
  const now = quotaView ? Date.now() : 0;
396
420
  return candidateKeys.filter((key) => {
397
- if (this.isProviderCoolingDown(key)) {
398
- return false;
399
- }
400
- if (!this.healthManager.isAvailable(key)) {
401
- return false;
402
- }
403
421
  if (!quotaView) {
422
+ if (this.isProviderCoolingDown(key)) {
423
+ return false;
424
+ }
425
+ if (!this.healthManager.isAvailable(key)) {
426
+ return false;
427
+ }
404
428
  return true;
405
429
  }
406
430
  const entry = quotaView(key);
@@ -493,6 +517,7 @@ export class VirtualRouterEngine {
493
517
  ...(this.webSearchForce ? { forceWebSearch: true } : {}),
494
518
  ...(forceVision ? { forceVision: true } : {})
495
519
  };
520
+ this.recordAntigravitySessionLease(features.metadata, selection.providerKey, { commitSessionBinding: false });
496
521
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
497
522
  const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
498
523
  try {
@@ -520,37 +545,6 @@ export class VirtualRouterEngine {
520
545
  this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
521
546
  }
522
547
  const didFallback = selection.routeUsed !== requestedRoute;
523
- // 自动 sticky:对需要上下文 save/restore 的 Responses 会话,强制同一个 provider.key.model。
524
- // 其它协议不启用粘滞,仅显式 routing 指令才会写入 stickyTarget。
525
- const providerProtocol = metadata?.providerProtocol;
526
- const serverToolRequired = metadata?.serverToolRequired === true;
527
- const disableSticky = metadata &&
528
- typeof metadata === 'object' &&
529
- metadata.disableStickyRoutes === true;
530
- const shouldAutoStickyForResponses = providerProtocol === 'openai-responses' && serverToolRequired && !disableSticky;
531
- if (shouldAutoStickyForResponses) {
532
- const stickyKeyForState = this.resolveStickyKey(metadata);
533
- if (stickyKeyForState) {
534
- const stateKey = stickyKeyForState;
535
- const state = this.getRoutingInstructionState(stateKey);
536
- if (!state.stickyTarget) {
537
- const providerId = this.extractProviderId(selection.providerKey);
538
- if (providerId) {
539
- const parts = selection.providerKey.split('.');
540
- const keyAlias = parts.length >= 3 ? parts[1] : undefined;
541
- const modelId = target.modelId;
542
- state.stickyTarget = {
543
- provider: providerId,
544
- keyAlias,
545
- model: modelId,
546
- // pathLength=3 表示 provider.key.model 形式,对应 alias 显式 sticky;
547
- // 若缺少别名或模型,则退化为更短 pathLength。
548
- pathLength: keyAlias && modelId ? 3 : keyAlias ? 2 : 1
549
- };
550
- }
551
- }
552
- }
553
- }
554
548
  return {
555
549
  target,
556
550
  decision: {
@@ -623,15 +617,16 @@ export class VirtualRouterEngine {
623
617
  // ignore persistence errors
624
618
  }
625
619
  }
626
- // Antigravity account safety policy should apply even when quotaView is enabled.
627
- // It uses router-local cooldown TTLs (not quotaView) to temporarily remove Antigravity from selection.
628
- applyAntigravityRiskPolicyImpl(event, this.providerRegistry, this.healthManager, (key, ttl) => this.markProviderCooldown(key, ttl), this.debug);
620
+ // Quota routing mode: health/cooldown must be driven by quotaView only (host/core quota center).
621
+ // VirtualRouter must not produce or persist its own cooldown state in this mode.
629
622
  // 当 Host 注入 quotaView 时,VirtualRouter 的入池/优先级决策应以 quota 为准;
630
623
  // 此时不再在 engine-health 内部进行 429/backoff/series cooldown 等健康决策,
631
624
  // 以避免与 daemon/quota-center 的长期熔断策略重复维护并导致日志噪声。
632
625
  if (this.quotaView) {
633
626
  return;
634
627
  }
628
+ // Antigravity account safety policy uses router-local cooldown TTLs; only applies when quota routing is disabled.
629
+ applyAntigravityRiskPolicyImpl(event, this.providerRegistry, this.healthManager, (key, ttl) => this.markProviderCooldown(key, ttl), this.debug);
635
630
  // 配额恢复事件优先处理:一旦识别到 virtualRouterQuotaRecovery,
636
631
  // 直接清理健康状态/冷却 TTL,避免继续走常规错误映射逻辑。
637
632
  const handledByQuota = applyQuotaRecoveryImpl(event, this.healthManager, (key) => this.clearProviderCooldown(key), this.debug);
@@ -649,6 +644,24 @@ export class VirtualRouterEngine {
649
644
  }
650
645
  this.handleProviderFailure(derived);
651
646
  }
647
+ handleProviderSuccess(event) {
648
+ if (!event || typeof event !== 'object') {
649
+ return;
650
+ }
651
+ const providerKey = event.runtime && typeof event.runtime.providerKey === 'string' ? event.runtime.providerKey.trim() : '';
652
+ if (!providerKey) {
653
+ return;
654
+ }
655
+ const metadata = event.metadata && typeof event.metadata === 'object' && !Array.isArray(event.metadata)
656
+ ? event.metadata
657
+ : null;
658
+ if (!metadata) {
659
+ return;
660
+ }
661
+ if (providerKey.toLowerCase().startsWith('antigravity.')) {
662
+ this.recordAntigravitySessionLease(metadata, providerKey, { commitSessionBinding: true });
663
+ }
664
+ }
652
665
  getStatus() {
653
666
  const routes = {};
654
667
  for (const [route, pools] of Object.entries(this.routing)) {
@@ -717,7 +730,10 @@ export class VirtualRouterEngine {
717
730
  isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
718
731
  resolveStickyKey: (m) => this.resolveStickyKey(m),
719
732
  quotaView: this.quotaView,
720
- aliasQueueStore: this.aliasQueueStore
733
+ aliasQueueStore: this.aliasQueueStore,
734
+ antigravityAliasLeaseStore: this.antigravityAliasLeaseStore,
735
+ antigravitySessionAliasStore: this.antigravitySessionAliasStore,
736
+ antigravityAliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs
721
737
  }, { routingState });
722
738
  }
723
739
  incrementRouteStat(routeName, providerKey) {
@@ -764,6 +780,262 @@ export class VirtualRouterEngine {
764
780
  }
765
781
  return undefined;
766
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
+ }
767
1039
  resolveStopMessageScope(metadata) {
768
1040
  const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
769
1041
  if (sessionId) {
@@ -1133,7 +1405,8 @@ export class VirtualRouterEngine {
1133
1405
  ]));
1134
1406
  const disabledModels = new Map(Array.from(state.disabledModels.entries()).map(([provider, models]) => [provider, new Set(models)]));
1135
1407
  // 初始候选集合:sticky 池中的所有 key
1136
- let candidates = Array.from(stickyKeySet).filter((key) => !this.isProviderCoolingDown(key));
1408
+ // In quota routing mode, cooldown is controlled by quotaView only.
1409
+ let candidates = Array.from(stickyKeySet).filter((key) => (this.quotaView ? true : !this.isProviderCoolingDown(key)));
1137
1410
  // 应用 provider 白名单 / 黑名单
1138
1411
  if (allowedProviders.size > 0) {
1139
1412
  candidates = candidates.filter((key) => {
@@ -1474,6 +1747,11 @@ export class VirtualRouterEngine {
1474
1747
  if (!this.healthStore || typeof this.healthStore.loadInitialSnapshot !== 'function') {
1475
1748
  return;
1476
1749
  }
1750
+ // When quotaView is enabled, health/cooldown must be driven by quotaView only.
1751
+ // Do not restore legacy router-local cooldown TTLs from health snapshots.
1752
+ if (this.quotaView) {
1753
+ return;
1754
+ }
1477
1755
  let snapshot = null;
1478
1756
  try {
1479
1757
  snapshot = this.healthStore.loadInitialSnapshot();
@@ -1,8 +1,23 @@
1
1
  import { detectExtendedThinkingKeyword, detectImageAttachment, detectKeyword, extractMessageText, getLatestMessageRole, getLatestUserMessage } from './message-utils.js';
2
+ import { extractAntigravityGeminiSessionId } from '../../conversion/compat/antigravity-session-signature.js';
2
3
  import { detectCodingTool, detectLastAssistantToolCategory, detectVisionTool, detectWebTool, extractMeaningfulDeclaredToolNames } from './tool-signals.js';
3
4
  import { computeRequestTokens } from './token-estimator.js';
4
5
  const THINKING_KEYWORDS = ['let me think', 'chain of thought', 'cot', 'reason step', 'deliberate'];
5
6
  export function buildRoutingFeatures(request, metadata) {
7
+ const antigravitySessionId = (() => {
8
+ try {
9
+ const messages = Array.isArray(request.messages) ? request.messages : [];
10
+ const contents = messages.map((msg) => {
11
+ const role = msg?.role === 'user' ? 'user' : 'assistant';
12
+ const text = msg ? extractMessageText(msg) : '';
13
+ return { role, parts: [{ text }] };
14
+ });
15
+ return extractAntigravityGeminiSessionId({ contents });
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ })();
6
21
  const latestUserMessage = getLatestUserMessage(request.messages);
7
22
  const latestMessageRole = getLatestMessageRole(request.messages);
8
23
  const assistantMessages = request.messages.filter((msg) => msg.role === 'assistant');
@@ -14,7 +29,9 @@ export function buildRoutingFeatures(request, metadata) {
14
29
  const estimatedTokens = computeRequestTokens(request, latestUserText);
15
30
  const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
16
31
  const hasVisionTool = detectVisionTool(request);
17
- const hasImageAttachment = detectImageAttachment(latestUserMessage);
32
+ // Vision routing must only trigger for the current user turn (latest message),
33
+ // not for historical user messages carrying images during tool/assistant followups.
34
+ const hasImageAttachment = latestMessageRole === 'user' && detectImageAttachment(latestUserMessage);
18
35
  const hasCodingTool = detectCodingTool(request);
19
36
  const hasWebTool = detectWebTool(request);
20
37
  const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
@@ -50,7 +67,8 @@ export function buildRoutingFeatures(request, metadata) {
50
67
  lastAssistantToolLabel,
51
68
  latestMessageFromUser: latestMessageRole === 'user',
52
69
  metadata: {
53
- ...metadata
70
+ ...metadata,
71
+ ...(antigravitySessionId ? { antigravitySessionId } : {})
54
72
  }
55
73
  };
56
74
  }
@@ -0,0 +1,10 @@
1
+ import type { ProviderSuccessEvent } from './types.js';
2
+ type ProviderSuccessListener = (event: ProviderSuccessEvent) => void;
3
+ export declare class ProviderSuccessCenter {
4
+ private readonly listeners;
5
+ subscribe(listener: ProviderSuccessListener): () => void;
6
+ emit(event: ProviderSuccessEvent): ProviderSuccessEvent;
7
+ private normalize;
8
+ }
9
+ export declare const providerSuccessCenter: ProviderSuccessCenter;
10
+ export {};
@@ -0,0 +1,32 @@
1
+ export class ProviderSuccessCenter {
2
+ listeners = new Set();
3
+ subscribe(listener) {
4
+ this.listeners.add(listener);
5
+ return () => {
6
+ this.listeners.delete(listener);
7
+ };
8
+ }
9
+ emit(event) {
10
+ const enriched = this.normalize(event);
11
+ for (const listener of this.listeners) {
12
+ try {
13
+ listener(enriched);
14
+ }
15
+ catch {
16
+ // Listener failures should not break propagation
17
+ }
18
+ }
19
+ return enriched;
20
+ }
21
+ normalize(event) {
22
+ const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now();
23
+ const runtime = event.runtime || {};
24
+ return {
25
+ runtime,
26
+ timestamp,
27
+ metadata: event.metadata,
28
+ details: event.details
29
+ };
30
+ }
31
+ }
32
+ export const providerSuccessCenter = new ProviderSuccessCenter();
@@ -166,6 +166,19 @@ export interface AliasSelectionConfig {
166
166
  * Per-provider overrides keyed by providerId (e.g. "antigravity").
167
167
  */
168
168
  providers?: Record<string, AliasSelectionStrategy>;
169
+ /**
170
+ * Antigravity session isolation cooldown window (ms).
171
+ * Within this window, the same Antigravity auth alias must not be reused by a different session.
172
+ * Default: 300000 (5 minutes).
173
+ */
174
+ sessionLeaseCooldownMs?: number;
175
+ /**
176
+ * Antigravity multi-alias session binding policy.
177
+ * - "lease" (default): prefer the session's last used alias, but can rotate to another alias when needed.
178
+ * - "strict": once a session binds to an alias, it will not switch to another alias; on failure it must
179
+ * fall back to other providers/routes rather than trying a different Antigravity alias.
180
+ */
181
+ antigravitySessionBinding?: 'lease' | 'strict';
169
182
  }
170
183
  export interface ContextWeightedLoadBalancingConfig {
171
184
  /**
@@ -238,6 +251,16 @@ export interface VirtualRouterClockConfig {
238
251
  * Daemon tick interval (ms). 0 disables background cleanup tick (still cleans on load).
239
252
  */
240
253
  tickMs?: number;
254
+ /**
255
+ * Allow clock hold flow for non-streaming (JSON) requests.
256
+ * Default: true.
257
+ */
258
+ holdNonStreaming?: boolean;
259
+ /**
260
+ * Maximum time (ms) a request is allowed to hold waiting for due window.
261
+ * Default: 60s.
262
+ */
263
+ holdMaxMs?: number;
241
264
  }
242
265
  export interface VirtualRouterConfig {
243
266
  routing: RoutingPools;
@@ -284,6 +307,11 @@ export interface RouterMetadataInput {
284
307
  providerProtocol?: string;
285
308
  stage?: 'inbound' | 'outbound' | 'response';
286
309
  routeHint?: string;
310
+ /**
311
+ * Antigravity-Manager alignment: stable sessionId derived from the first user message text.
312
+ * Used for Antigravity alias/session binding and thoughtSignature persistence.
313
+ */
314
+ antigravitySessionId?: string;
287
315
  /**
288
316
  * Indicates that current routing decision is for a request which
289
317
  * expects server-side tools orchestration (e.g. web_search).
@@ -481,6 +509,26 @@ export interface ProviderErrorEvent {
481
509
  timestamp: number;
482
510
  details?: Record<string, unknown>;
483
511
  }
512
+ export interface ProviderSuccessRuntimeMetadata {
513
+ requestId: string;
514
+ routeName?: string;
515
+ providerKey?: string;
516
+ providerId?: string;
517
+ providerType?: string;
518
+ providerProtocol?: string;
519
+ pipelineId?: string;
520
+ target?: TargetMetadata | Record<string, unknown>;
521
+ }
522
+ export interface ProviderSuccessEvent {
523
+ runtime: ProviderSuccessRuntimeMetadata;
524
+ timestamp: number;
525
+ /**
526
+ * Optional request metadata snapshot (e.g. sessionId / conversationId).
527
+ * This must not contain provider-specific payload semantics.
528
+ */
529
+ metadata?: Record<string, unknown>;
530
+ details?: Record<string, unknown>;
531
+ }
484
532
  export interface FeatureBuilder {
485
533
  build(request: StandardizedRequest, metadata: RouterMetadataInput): RoutingFeatures;
486
534
  }
@@ -3,6 +3,8 @@ export declare const CLOCK_CONFIG_DEFAULTS: {
3
3
  readonly retentionMs: number;
4
4
  readonly dueWindowMs: 60000;
5
5
  readonly tickMs: 60000;
6
+ readonly holdNonStreaming: true;
7
+ readonly holdMaxMs: 60000;
6
8
  };
7
9
  export declare function normalizeClockConfig(raw: unknown): ClockConfigSnapshot | null;
8
10
  /**
@@ -1,7 +1,9 @@
1
1
  export const CLOCK_CONFIG_DEFAULTS = {
2
2
  retentionMs: 20 * 60_000,
3
3
  dueWindowMs: 60_000,
4
- tickMs: 60_000
4
+ tickMs: 60_000,
5
+ holdNonStreaming: true,
6
+ holdMaxMs: 60_000
5
7
  };
6
8
  export function normalizeClockConfig(raw) {
7
9
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -23,7 +25,13 @@ export function normalizeClockConfig(raw) {
23
25
  const tickMs = typeof record.tickMs === 'number' && Number.isFinite(record.tickMs) && record.tickMs >= 0
24
26
  ? Math.floor(record.tickMs)
25
27
  : CLOCK_CONFIG_DEFAULTS.tickMs;
26
- return { enabled: true, retentionMs, dueWindowMs, tickMs };
28
+ const holdNonStreaming = record.holdNonStreaming === true ||
29
+ (typeof record.holdNonStreaming === 'string' && record.holdNonStreaming.trim().toLowerCase() === 'true') ||
30
+ (typeof record.holdNonStreaming === 'number' && record.holdNonStreaming === 1);
31
+ const holdMaxMs = typeof record.holdMaxMs === 'number' && Number.isFinite(record.holdMaxMs) && record.holdMaxMs >= 0
32
+ ? Math.floor(record.holdMaxMs)
33
+ : CLOCK_CONFIG_DEFAULTS.holdMaxMs;
34
+ return { enabled: true, retentionMs, dueWindowMs, tickMs, holdNonStreaming, holdMaxMs };
27
35
  }
28
36
  /**
29
37
  * Resolve the effective clock config for a request/session.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { readSessionDirEnv, resolveClockDir } from './paths.js';
4
4
  import { cleanExpiredTasks, coerceState, nowMs } from './state.js';
5
5
  import { readJsonFile, writeJsonFileAtomic } from './io.js';
6
+ import { startClockNtpSyncIfNeeded } from './ntp.js';
6
7
  let daemonStarted = false;
7
8
  let daemonTimer;
8
9
  let daemonConfig;
@@ -16,6 +17,8 @@ export async function startClockDaemonIfNeeded(config) {
16
17
  }
17
18
  daemonStarted = true;
18
19
  daemonConfig = config;
20
+ // Best-effort NTP sync (do not block daemon startup).
21
+ void startClockNtpSyncIfNeeded(config);
19
22
  const tickOnce = async () => {
20
23
  const effective = daemonConfig;
21
24
  if (!effective)