dsh-vision-router 2.3.0 → 3.0.1

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 (105) hide show
  1. package/README.md +18 -3
  2. package/README.zh.md +16 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +447 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +13 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/docs/releases/v3.0.1.md +77 -0
  17. package/entry.js +1 -1
  18. package/index.js +110 -71
  19. package/lib/agent-request-route-authority.js +1 -20
  20. package/lib/core-apply-lifecycle.js +37 -43
  21. package/lib/core-cache.js +87 -0
  22. package/lib/core-primitives.js +11 -144
  23. package/lib/core-vision-surface-domain.js +63 -0
  24. package/lib/core-vision-surface.js +11 -77
  25. package/lib/delegated-call-config.js +15 -24
  26. package/lib/doctor-cli.js +0 -0
  27. package/lib/doctor-runtime.js +4 -1
  28. package/lib/dsh-contract-compat.js +26 -230
  29. package/lib/dsh-host-capabilities.js +3 -9
  30. package/lib/dsh-settings-017-compat.js +17 -10
  31. package/lib/file-logger.js +8 -3
  32. package/lib/grounding-coordinate-runtime.js +1 -2
  33. package/lib/http-body-limit.js +97 -79
  34. package/lib/image-offload-compat.js +30 -21
  35. package/lib/image-resource-governor.js +210 -228
  36. package/lib/legacy-global-proxy-boundary.js +46 -3
  37. package/lib/live-model-client-prelude.js +12 -4
  38. package/lib/live-model-discovery.js +134 -43
  39. package/lib/local-vision-stabilizer.js +33 -18
  40. package/lib/maintenance-routes.js +1 -3
  41. package/lib/native-image-coexistence.js +15 -3
  42. package/lib/ollama-cold-start.js +87 -23
  43. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  44. package/lib/proxy-dispatcher-pool.js +151 -92
  45. package/lib/proxy-routing.js +75 -51
  46. package/lib/proxy-url-compat.js +3 -10
  47. package/lib/public-config.js +23 -44
  48. package/lib/public-entry.d.ts +148 -0
  49. package/lib/public-entry.js +17 -4
  50. package/lib/replay-delegation.js +12 -7
  51. package/lib/replay-envelope-v2-compat.js +12 -7
  52. package/lib/runtime-composition.js +30 -10
  53. package/lib/screenshot-source-boundary.js +2 -2
  54. package/lib/session-affinity-runtime.js +113 -97
  55. package/lib/session-affinity.js +89 -76
  56. package/lib/session-event-history-compat.js +30 -0
  57. package/lib/session-query-readers.js +263 -0
  58. package/lib/session-surface-compat.js +30 -31
  59. package/lib/session-surface-policy-domain.js +85 -0
  60. package/lib/session-surface-policy.js +2 -111
  61. package/lib/session-turn-resolver.js +2 -6
  62. package/lib/session-vision-attachment-index.js +143 -0
  63. package/lib/session-vision-delegation-boundary.js +34 -0
  64. package/lib/session-vision-delegation.js +22 -0
  65. package/lib/session-vision-index.js +25 -107
  66. package/lib/session-vision-mode-authority.js +229 -192
  67. package/lib/session-vision-mode-boundary.js +65 -22
  68. package/lib/session-vision-policy-domain.js +304 -0
  69. package/lib/session-vision-policy.js +104 -0
  70. package/lib/session-vision-state.js +368 -378
  71. package/lib/settings-client-017-compat.js +16 -10
  72. package/lib/structured-flow-hardening.js +22 -4
  73. package/lib/tesseract-exec-compat.js +9 -1
  74. package/lib/turn-budget-context.js +47 -41
  75. package/lib/update-check.js +11 -2
  76. package/lib/vision-attachment-handle-runtime.js +176 -156
  77. package/lib/vision-backend-runtime-policy.js +16 -0
  78. package/lib/vision-background-benchmark.js +55 -6
  79. package/lib/vision-background-failure-policy.js +41 -41
  80. package/lib/vision-background-stop-store-core.js +217 -0
  81. package/lib/vision-background-stop-store.js +8 -179
  82. package/lib/vision-capability-benchmark.js +11 -61
  83. package/lib/vision-capability-evidence.js +35 -0
  84. package/lib/vision-capability-fingerprint.js +72 -0
  85. package/lib/vision-capability-identity.js +116 -61
  86. package/lib/vision-capability-router.js +13 -33
  87. package/lib/vision-chunk-assembler.js +95 -0
  88. package/lib/vision-diagnostics-routes.js +42 -4
  89. package/lib/vision-execution-order-apply.js +45 -55
  90. package/lib/vision-execution-order.js +19 -19
  91. package/lib/vision-execution-policy.js +12 -7
  92. package/lib/vision-model-registry.js +30 -17
  93. package/lib/vision-model-visibility-boundary-main.js +15 -16
  94. package/lib/vision-provider-transport.js +137 -147
  95. package/lib/vision-routing-authority.js +47 -48
  96. package/lib/vision-routing-product.js +26 -29
  97. package/lib/vision-routing-settings-prelude.js +8 -3
  98. package/lib/vision-runtime-performance-store.js +166 -0
  99. package/lib/vision-runtime-performance.js +13 -142
  100. package/lib/vision-tool-runtime-boundary.js +99 -30
  101. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  102. package/lib/web/remote-settings-client.js +4 -7
  103. package/lib/web-capability-boundary.js +17 -7
  104. package/lib/wrapper-directory.js +14 -23
  105. package/package.json +37 -11
package/index.js CHANGED
@@ -1049,8 +1049,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1049
1049
  yield { type: 'finish', reason: { kind: 'stop' } }
1050
1050
  },
1051
1051
  }
1052
- const httpHandle = ctx.llm.registerAdapter([HTTP_ROUTE], httpAdapter)
1053
- ctx.effect(() => httpHandle, 'vision-router: vision-http route')
1052
+ ctx.llm.registerAdapter([HTTP_ROUTE], httpAdapter)
1054
1053
 
1055
1054
  }
1056
1055
 
@@ -1354,7 +1353,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1354
1353
  const spec = wanted.get(provider)
1355
1354
  if (spec === undefined) {
1356
1355
  try {
1357
- held.handle()
1356
+ held.registration()
1358
1357
  twinHandles.delete(provider)
1359
1358
  } catch (error) {
1360
1359
  ctx.logger?.warn(
@@ -1372,7 +1371,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1372
1371
  held.state.models = spec.models
1373
1372
  held.state.sourceName = spec.sourceName
1374
1373
  try {
1375
- held.handle.replace([`${provider}-vision`])
1374
+ held.registration.replace([`${provider}-vision`])
1376
1375
  held.key = nextKey
1377
1376
  } catch (error) {
1378
1377
  held.state.models = previousModels
@@ -1394,10 +1393,12 @@ export function apply(ctx, config = {}, runtime = {}) {
1394
1393
  const twinRoute = `${provider}-vision`
1395
1394
  const state = { models: spec.models, sourceName: spec.sourceName }
1396
1395
  try {
1397
- const handle = ctx.llm.registerAdapter([twinRoute], makeTwinAdapter(provider, state))
1398
- ctx.effect(() => handle, `vision-router: twin route ${twinRoute}`)
1396
+ const registration = ctx.llm.registerAdapter(
1397
+ [twinRoute],
1398
+ makeTwinAdapter(provider, state),
1399
+ )
1399
1400
  twinHandles.set(provider, {
1400
- handle,
1401
+ registration,
1401
1402
  state,
1402
1403
  key: twinSpecKey(spec.models, spec.sourceName),
1403
1404
  })
@@ -4666,34 +4667,48 @@ ctx.logger?.info(
4666
4667
  activateDeepTools = () => {
4667
4668
  if (deepActive) return '视觉深看工具已在挂载状态。'
4668
4669
  deepActive = true
4669
- for (const def of deepToolDefs) {
4670
- const registeredDef =
4671
- def.name === 'vision_bootstrap' || typeof def.execute !== 'function'
4672
- ? def
4673
- : {
4674
- ...def,
4675
- async execute(args, exec) {
4676
- const session = exec && exec.agent && exec.agent.session
4677
- const state = session ? structuredBootstrapTurnState.get(session) : undefined
4678
- if (structuredBootstrapEnabled() && state && state.required && state.completed !== true) {
4679
- return JSON.stringify({
4680
- ok: false,
4681
- code: state.failed ? 'STRUCTURED_BOOTSTRAP_FAILED' : 'STRUCTURED_BOOTSTRAP_REQUIRED',
4682
- retryable: !state.failed,
4683
- reason: state.failed
4684
- ? 'the required structured bootstrap visual pass failed; do not make more visual calls this turn'
4685
- : 'call vision_bootstrap and wait for its universal structured visual result before any other vision tool',
4686
- })
4687
- }
4688
- // 识图档位不在这里做调用次数拦截;显式 visionDepthMaxCalls 由
4689
- // structured-flow hardening 统一执行,避免与 evidence 完成状态重复计数。
4690
- // Tool-specific execution policy belongs to the tool itself; this wrapper owns
4691
- // only bootstrap ordering and never rewrites model/user arguments.
4692
- const result = await def.execute(args, exec)
4693
- return result
4694
- },
4695
- }
4696
- deepDisposers.push(ctx.tools.register(registeredDef))
4670
+ try {
4671
+ for (const def of deepToolDefs) {
4672
+ const registeredDef =
4673
+ def.name === 'vision_bootstrap' || typeof def.execute !== 'function'
4674
+ ? def
4675
+ : {
4676
+ ...def,
4677
+ async execute(args, exec) {
4678
+ const session = exec && exec.agent && exec.agent.session
4679
+ const state = session ? structuredBootstrapTurnState.get(session) : undefined
4680
+ if (structuredBootstrapEnabled() && state && state.required && state.completed !== true) {
4681
+ return JSON.stringify({
4682
+ ok: false,
4683
+ code: state.failed ? 'STRUCTURED_BOOTSTRAP_FAILED' : 'STRUCTURED_BOOTSTRAP_REQUIRED',
4684
+ retryable: !state.failed,
4685
+ reason: state.failed
4686
+ ? 'the required structured bootstrap visual pass failed; do not make more visual calls this turn'
4687
+ : 'call vision_bootstrap and wait for its universal structured visual result before any other vision tool',
4688
+ })
4689
+ }
4690
+ // 识图档位不在这里做调用次数拦截;显式 visionDepthMaxCalls 由
4691
+ // structured-flow hardening 统一执行,避免与 evidence 完成状态重复计数。
4692
+ // Tool-specific execution policy belongs to the tool itself; this wrapper owns
4693
+ // only bootstrap ordering and never rewrites model/user arguments.
4694
+ const result = await def.execute(args, exec)
4695
+ return result
4696
+ },
4697
+ }
4698
+ deepDisposers.push(ctx.tools.register(registeredDef))
4699
+ }
4700
+ } catch (error) {
4701
+ // A partial mount must never be reported as mounted: retire what this
4702
+ // attempt registered, release the latch and surface the failure.
4703
+ deepDisposers.splice(0).forEach((dispose) => {
4704
+ try {
4705
+ dispose()
4706
+ } catch {
4707
+ // Rollback is best effort; the original registration error wins.
4708
+ }
4709
+ })
4710
+ deepActive = false
4711
+ throw error
4697
4712
  }
4698
4713
  return (
4699
4714
  '视觉深看工具已挂载:vision_bootstrap(结构化预识别)、vision_describe(看图问答)、vision_ground(像素定位)、vision_detect(元素清单)、' +
@@ -4703,20 +4718,32 @@ ctx.logger?.info(
4703
4718
  '注意:vision_ocr 只用于读取图片文字;视觉工具返回 ok:false 后端不可用结果时,不要改问法重复调用,继续文本任务。'
4704
4719
  )
4705
4720
  }
4721
+ ctx.effect(
4722
+ () => () => {
4723
+ deepDisposers.splice(0).forEach((dispose) => dispose())
4724
+ deepActive = false
4725
+ },
4726
+ 'vision-router: deep tools',
4727
+ )
4728
+
4706
4729
  if (progressive) {
4707
- ctx.tools.register({
4708
- name: 'vision_activate',
4709
- description:
4710
- 'Mount the deep vision tools (vision_bootstrap / vision_describe / vision_ground / vision_detect / vision_materialize / vision_crop / ' +
4711
- 'vision_pixel_diff / vision_colors / vision_ocr / vision_trace / ' +
4712
- 'vision_extract_foreground / vision_present / vision_html_screenshot) for this session. Desktop screenshot remains disabled until the user explicitly opts in through Vision Router settings. They mount ' +
4713
- 'automatically on image turns; call this only when you need them on a text-only turn.',
4714
- parameters: { type: 'object', properties: {}, additionalProperties: false },
4715
- output: stringOutput,
4716
- async execute() {
4717
- return activateDeepTools()
4718
- },
4719
- })
4730
+ ctx.effect(
4731
+ () =>
4732
+ ctx.tools.register({
4733
+ name: 'vision_activate',
4734
+ description:
4735
+ 'Mount the deep vision tools (vision_bootstrap / vision_describe / vision_ground / vision_detect / vision_materialize / vision_crop / ' +
4736
+ 'vision_pixel_diff / vision_colors / vision_ocr / vision_trace / ' +
4737
+ 'vision_extract_foreground / vision_present / vision_html_screenshot) for this session. Desktop screenshot remains disabled until the user explicitly opts in through Vision Router settings. They mount ' +
4738
+ 'automatically on image turns; call this only when you need them on a text-only turn.',
4739
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
4740
+ output: stringOutput,
4741
+ async execute() {
4742
+ return activateDeepTools()
4743
+ },
4744
+ }),
4745
+ 'vision-router: progressive bootstrap tool',
4746
+ )
4720
4747
  const skills = ctx.get('skills')
4721
4748
  if (skills !== undefined && typeof skills.register === 'function') {
4722
4749
  ctx.effect(
@@ -4767,13 +4794,6 @@ ctx.logger?.info(
4767
4794
  } else {
4768
4795
  activateDeepTools()
4769
4796
  }
4770
- ctx.effect(
4771
- () => () => {
4772
- deepDisposers.splice(0).forEach((dispose) => dispose())
4773
- deepActive = false
4774
- },
4775
- 'vision-router: deep tools',
4776
- )
4777
4797
  }
4778
4798
 
4779
4799
  // ── settings seam: the Web 设置 > 插件 > 插件配置 panel owns a
@@ -4784,6 +4804,9 @@ ctx.logger?.info(
4784
4804
  // @deepseek-ai/dsh-settings: the published npm build trails the deployment,
4785
4805
  // and the service API is the stable contract here.
4786
4806
  ctx.inject(['settings'], (sctx) => {
4807
+ // Registration and the live-settings subscription are Host contract calls:
4808
+ // they must happen for every supported Host, independently of whether the
4809
+ // lifecycle seam below can run eagerly. Only ownership needs the factory.
4787
4810
  const scope = sctx.settings.register('vision-router', Config, {
4788
4811
  base: config,
4789
4812
  })
@@ -4791,26 +4814,43 @@ ctx.logger?.info(
4791
4814
  // With the settings document now visible, reconcile the routing mounts
4792
4815
  // (wrapper route, chain route) against the resolved values.
4793
4816
  syncRoutingMounts()
4794
- sctx.effect(
4795
- () => () => {
4796
- // The settings provider went away: fall back to the composition entry.
4797
- current = () => config
4798
- },
4799
- 'vision-router: settings fallback',
4800
- )
4801
- scope.watch(() => {
4802
- // Most consumers read current() per call, but the wrappedProviders
4803
- // twins and the routing mounts are registered eagerly: re-sync them
4804
- // whenever the settings document loads or the user edits the card.
4805
- syncTwins()
4806
- syncRoutingMounts()
4807
- })
4817
+ let disposeWatch
4818
+ if (typeof scope.watch === 'function') {
4819
+ disposeWatch = scope.watch(() => {
4820
+ // Most consumers read current() per call, but the wrappedProviders
4821
+ // twins and the routing mounts are registered eagerly: re-sync them
4822
+ // whenever the settings document loads or the user edits the card.
4823
+ syncTwins()
4824
+ syncRoutingMounts()
4825
+ })
4826
+ }
4827
+ const restore = () => {
4828
+ if (typeof disposeWatch === 'function') disposeWatch()
4829
+ disposeWatch = undefined
4830
+ // The settings provider went away: fall back to the composition entry.
4831
+ current = () => config
4832
+ }
4833
+ if (typeof sctx.effect !== 'function') {
4834
+ // No lifecycle seam: this injection's context owns the source, so there is
4835
+ // nothing for DVR to restore when the Settings service itself goes away.
4836
+ return
4837
+ }
4838
+ try {
4839
+ sctx.effect(() => restore, 'vision-router: settings source')
4840
+ } catch (error) {
4841
+ // Registration already published the scope and its subscription: withdraw
4842
+ // both before the failure escapes, exactly as the owner would on unload.
4843
+ restore()
4844
+ throw error
4845
+ }
4808
4846
  })
4809
4847
 
4810
4848
  // Product diagnostics/settings support is a separate Web owner. Core supplies
4811
4849
  // only the three coherent domain faces it already computes; route lifecycle,
4812
4850
  // bounded /models probing and JSON response semantics live outside Core.
4813
4851
  installVisionDiagnosticsRoutes(ctx, {
4852
+ // Same egress as real vision turns: the Router-owned provider transport.
4853
+ transport: runtime?.providerTransport,
4814
4854
  connection: {
4815
4855
  candidatePairs: pairs,
4816
4856
  localBackends: () => localProvidersOf(current()),
@@ -4836,7 +4876,7 @@ ctx.logger?.info(
4836
4876
  // (plus a fixed product allowlist) — without this directory entry the Web
4837
4877
  // card's settingsScope binder reports the namespace as unavailable.
4838
4878
  try {
4839
- const providerDirectory = ctx.llm.registerConfigurableProviders([
4879
+ ctx.llm.registerConfigurableProviders([
4840
4880
  {
4841
4881
  provider: 'vision-router',
4842
4882
  displayName: '视觉路由(自动识图)',
@@ -4844,7 +4884,6 @@ ctx.logger?.info(
4844
4884
  settingsPath: [],
4845
4885
  },
4846
4886
  ])
4847
- ctx.effect(() => providerDirectory, 'vision-router: configurable provider directory')
4848
4887
  } catch (error) {
4849
4888
  ctx.logger?.warn(
4850
4889
  'vision-router: configurable provider registration failed: %s',
@@ -1,8 +1,6 @@
1
1
  import { projectDelegatedCallConfig } from './delegated-call-config.js'
2
2
  import { contextWithTwinImageCapabilityFallback } from './twin-image-capability-fallback.js'
3
3
 
4
- const routeAuthorityOptionsByContext = new WeakMap()
5
-
6
4
  function isObject(value) {
7
5
  return value !== null && typeof value === 'object' && !Array.isArray(value)
8
6
  }
@@ -30,21 +28,6 @@ export function projectAgentRequestRouteHandoff(sourceConfig, resultConfig) {
30
28
  return projectDelegatedCallConfig(resultConfig)
31
29
  }
32
30
 
33
- /**
34
- * Runtime composition can publish route identities before Core.apply without
35
- * changing the mature `contextWithAgentRequestRouteAuthority(backendRuntimeCtx)`
36
- * boundary shape. The WeakMap keeps this per-context and garbage-collectable.
37
- */
38
- export function configureAgentRequestRouteAuthority(ctx, options = {}) {
39
- if (!isObject(ctx)) return ctx
40
- routeAuthorityOptionsByContext.set(ctx, {
41
- wrapperRoute: options.wrapperRoute,
42
- chainRoute: options.chainRoute,
43
- logger: options.logger,
44
- })
45
- return ctx
46
- }
47
-
48
31
  /**
49
32
  * Private Core-facing context. It wraps only `agent/request` handlers and only
50
33
  * projects a result when that handler demonstrably changed provider/model.
@@ -60,9 +43,7 @@ export function configureAgentRequestRouteAuthority(ctx, options = {}) {
60
43
  */
61
44
  export function contextWithAgentRequestRouteAuthority(ctx, options) {
62
45
  if (!isObject(ctx)) return ctx
63
- const fallbackOptions = isObject(options)
64
- ? options
65
- : routeAuthorityOptionsByContext.get(ctx) ?? {}
46
+ const fallbackOptions = isObject(options) ? options : {}
66
47
  const routeAuthorityCtx = new Proxy(ctx, {
67
48
  get(target, property) {
68
49
  if (property !== 'on') {
@@ -1,47 +1,41 @@
1
- /**
2
- * Preserve Core's synchronous apply contract while giving Promise-like Core
3
- * implementations one explicit settlement boundary for post-registration work.
4
- * This helper owns no runtime state; callers provide success/failure lifecycle
5
- * effects explicitly.
6
- */
7
1
  export function runCoreApplyLifecycle(invoke, { onSuccess, onFailure } = {}) {
8
- if (typeof invoke !== 'function') throw new TypeError('Core apply lifecycle requires an invoke function')
9
- const succeed = typeof onSuccess === 'function' ? onSuccess : () => {}
10
- const fail = (error) => {
11
- try {
12
- onFailure?.(error)
13
- } catch {
14
- // Cleanup/diagnostic failure must not replace the Core apply error.
15
- }
16
- throw error
17
- }
18
-
19
- let result
20
- try {
21
- result = invoke()
22
- } catch (error) {
23
- return fail(error)
24
- }
25
-
26
- if (result && typeof result.then === 'function') {
27
- return Promise.resolve(result).then(
28
- (value) => {
2
+ if (typeof invoke !== 'function')
3
+ throw new TypeError('Core apply lifecycle requires an invoke function');
4
+ const succeed = typeof onSuccess === 'function' ? onSuccess : () => { };
5
+ const fail = (error) => {
29
6
  try {
30
- succeed()
31
- return value
32
- } catch (error) {
33
- return fail(error)
7
+ onFailure?.(error);
8
+ }
9
+ catch {
10
+ // Cleanup/diagnostic failure must not replace the Core apply error.
34
11
  }
35
- },
36
- fail,
37
- )
38
- }
39
-
40
- try {
41
- succeed()
42
- return result
43
- } catch (error) {
44
- return fail(error)
45
- }
12
+ throw error;
13
+ };
14
+ let result;
15
+ try {
16
+ result = invoke();
17
+ }
18
+ catch (error) {
19
+ return fail(error);
20
+ }
21
+ if (result !== null
22
+ && (typeof result === 'object' || typeof result === 'function')
23
+ && typeof result.then === 'function') {
24
+ return Promise.resolve(result).then((value) => {
25
+ try {
26
+ succeed();
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ return fail(error);
31
+ }
32
+ }, fail);
33
+ }
34
+ try {
35
+ succeed();
36
+ return result;
37
+ }
38
+ catch (error) {
39
+ return fail(error);
40
+ }
46
41
  }
47
-
@@ -0,0 +1,87 @@
1
+ export function cacheWeight(value) {
2
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
3
+ return value.byteLength;
4
+ if (typeof value === 'string')
5
+ return Buffer.byteLength(value, 'utf8');
6
+ try {
7
+ const encoded = JSON.stringify(value);
8
+ return Buffer.byteLength(encoded === undefined ? String(value) : encoded, 'utf8');
9
+ }
10
+ catch {
11
+ return Buffer.byteLength(String(value), 'utf8');
12
+ }
13
+ }
14
+ /**
15
+ * LRU+TTL cache bounded by both entry count and retained bytes.
16
+ *
17
+ * Keys are normalized to strings at the ownership boundary. A rejected write
18
+ * never remains resident, and every eviction updates retained-byte accounting
19
+ * before the entry is released.
20
+ */
21
+ export function createCache(maxEntries, ttlMsValue, options = {}) {
22
+ const entries = new Map();
23
+ const entryLimit = Math.max(0, Math.floor(Number(maxEntries) || 0));
24
+ const ttlMs = ttlMsValue;
25
+ const maxBytes = Number.isFinite(Number(options.maxBytes)) && Number(options.maxBytes) >= 0
26
+ ? Math.floor(Number(options.maxBytes))
27
+ : 8 * 1024 * 1024;
28
+ const maxEntryBytes = Number.isFinite(Number(options.maxEntryBytes)) && Number(options.maxEntryBytes) >= 0
29
+ ? Math.floor(Number(options.maxEntryBytes))
30
+ : Math.min(maxBytes, 1024 * 1024);
31
+ let retainedBytes = 0;
32
+ const remove = (key) => {
33
+ const entry = entries.get(key);
34
+ if (entry === undefined)
35
+ return;
36
+ retainedBytes = Math.max(0, retainedBytes - entry.weight);
37
+ entries.delete(key);
38
+ };
39
+ const evict = () => {
40
+ while (entries.size > entryLimit || retainedBytes > maxBytes) {
41
+ const oldest = entries.keys().next().value;
42
+ if (oldest === undefined)
43
+ break;
44
+ remove(oldest);
45
+ }
46
+ };
47
+ return {
48
+ get(key) {
49
+ const rawKey = key;
50
+ const entry = entries.get(rawKey);
51
+ if (entry === undefined)
52
+ return undefined;
53
+ if (entry.expiresAt <= Date.now()) {
54
+ remove(rawKey);
55
+ return undefined;
56
+ }
57
+ entries.delete(rawKey);
58
+ entries.set(rawKey, entry);
59
+ return entry.value;
60
+ },
61
+ set(key, value) {
62
+ const normalizedKey = String(key);
63
+ const weight = Buffer.byteLength(normalizedKey, 'utf8') + cacheWeight(value);
64
+ remove(normalizedKey);
65
+ if (entryLimit === 0
66
+ || maxBytes === 0
67
+ || weight > maxEntryBytes
68
+ || weight > maxBytes) {
69
+ return false;
70
+ }
71
+ entries.set(normalizedKey, {
72
+ value,
73
+ weight,
74
+ expiresAt: ttlMs <= 0 ? Infinity : Date.now() + ttlMs,
75
+ });
76
+ retainedBytes += weight;
77
+ evict();
78
+ return entries.has(normalizedKey);
79
+ },
80
+ get size() {
81
+ return entries.size;
82
+ },
83
+ get bytes() {
84
+ return retainedBytes;
85
+ },
86
+ };
87
+ }
@@ -611,77 +611,7 @@ export function extractJson(text) {
611
611
  return undefined
612
612
  }
613
613
 
614
- function cacheWeight(value) {
615
- if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength
616
- if (typeof value === 'string') return Buffer.byteLength(value, 'utf8')
617
- try {
618
- const encoded = JSON.stringify(value)
619
- return Buffer.byteLength(encoded === undefined ? String(value) : encoded, 'utf8')
620
- } catch {
621
- return Buffer.byteLength(String(value), 'utf8')
622
- }
623
- }
624
-
625
- /** LRU+TTL cache bounded by BOTH entry count and retained bytes. */
626
- export function createCache(maxEntries, ttlMs, options = {}) {
627
- const entries = new Map()
628
- const entryLimit = Math.max(0, Math.floor(Number(maxEntries) || 0))
629
- const maxBytes = Number.isFinite(Number(options.maxBytes)) && Number(options.maxBytes) >= 0
630
- ? Math.floor(Number(options.maxBytes))
631
- : 8 * 1024 * 1024
632
- const maxEntryBytes = Number.isFinite(Number(options.maxEntryBytes)) && Number(options.maxEntryBytes) >= 0
633
- ? Math.floor(Number(options.maxEntryBytes))
634
- : Math.min(maxBytes, 1024 * 1024)
635
- let retainedBytes = 0
636
-
637
- const remove = (key) => {
638
- const entry = entries.get(key)
639
- if (!entry) return
640
- retainedBytes = Math.max(0, retainedBytes - entry.weight)
641
- entries.delete(key)
642
- }
643
- const evict = () => {
644
- while (entries.size > entryLimit || retainedBytes > maxBytes) {
645
- const oldest = entries.keys().next().value
646
- if (oldest === undefined) break
647
- remove(oldest)
648
- }
649
- }
650
-
651
- return {
652
- get(key) {
653
- const entry = entries.get(key)
654
- if (!entry) return undefined
655
- if (entry.expiresAt <= Date.now()) {
656
- remove(key)
657
- return undefined
658
- }
659
- entries.delete(key)
660
- entries.set(key, entry)
661
- return entry.value
662
- },
663
- set(key, value) {
664
- const normalizedKey = String(key)
665
- const weight = Buffer.byteLength(normalizedKey, 'utf8') + cacheWeight(value)
666
- remove(normalizedKey)
667
- if (entryLimit === 0 || maxBytes === 0 || weight > maxEntryBytes || weight > maxBytes) return false
668
- entries.set(normalizedKey, {
669
- value,
670
- weight,
671
- expiresAt: ttlMs <= 0 ? Infinity : Date.now() + ttlMs,
672
- })
673
- retainedBytes += weight
674
- evict()
675
- return entries.has(normalizedKey)
676
- },
677
- get size() {
678
- return entries.size
679
- },
680
- get bytes() {
681
- return retainedBytes
682
- },
683
- }
684
- }
614
+ export { cacheWeight, createCache } from './core-cache.js'
685
615
 
686
616
  /** True when the harness llm service has a registered adapter for the provider route. */
687
617
  export function adapterAvailable(llm, provider) {
@@ -2007,7 +1937,11 @@ async function callLmStudioNative(provider, messages, options = {}) {
2007
1937
  ...(typeof options.temperature === 'number' ? { temperature: options.temperature } : {}),
2008
1938
  ...(typeof options.top_p === 'number' ? { top_p: options.top_p } : {}),
2009
1939
  }
2010
- const response = await fetch(`${apiRoot}/api/v1/chat`, {
1940
+ const transport = options.providerTransport
1941
+ const requestFetch = typeof transport?.fetch === 'function'
1942
+ ? (input, init) => transport.fetch(input, init, { providerName: provider.name, allowProxy: true })
1943
+ : fetch
1944
+ const response = await requestFetch(`${apiRoot}/api/v1/chat`, {
2011
1945
  method: 'POST',
2012
1946
  headers: { 'content-type': 'application/json' },
2013
1947
  body: JSON.stringify(body),
@@ -2054,6 +1988,9 @@ export async function callLocalBackend(provider, messages, options = {}) {
2054
1988
  return callLmStudioNative(provider, messages, {
2055
1989
  maxTokens,
2056
1990
  signal: options.signal,
1991
+ // The native LM Studio transport is a provider leg like the openai/anthropic
1992
+ // ones: it must use the Router-owned transport so proxy/proxyHosts apply.
1993
+ providerTransport: options.providerTransport,
2057
1994
  ...(typeof provider.reasoningEffort === 'string' ? { reasoningEffort: provider.reasoningEffort } : {}),
2058
1995
  ...sampling,
2059
1996
  })
@@ -2335,77 +2272,8 @@ export async function callOpenAICompatible(provider, messages, options = {}) {
2335
2272
  return text
2336
2273
  }
2337
2274
 
2338
- /**
2339
- * Minimal harness-chunk assembler (no dsh imports required). Feeds the raw
2340
- * `llm/stream` chunk protocol and produces the final text of text blocks.
2341
- * Terminal failures throw; a `max-tokens` finish returns the partial text.
2342
- */
2343
- export function createChunkAssembler() {
2344
- const parts = new Map()
2345
- const order = []
2346
- let finishKind
2347
- let failure
2348
-
2349
- const push = (chunk) => {
2350
- if (!chunk || typeof chunk.type !== 'string') return
2351
- switch (chunk.type) {
2352
- case 'block-start': {
2353
- if (!parts.has(chunk.index)) {
2354
- order.push(chunk.index)
2355
- parts.set(chunk.index, { type: chunk.blockType, text: '' })
2356
- }
2357
- break
2358
- }
2359
- case 'text-delta': {
2360
- const part = parts.get(chunk.index)
2361
- if (part) part.text += chunk.text ?? ''
2362
- break
2363
- }
2364
- case 'reasoning-delta':
2365
- case 'tool-call-delta':
2366
- case 'usage':
2367
- break
2368
- case 'block-end': {
2369
- const part = parts.get(chunk.index)
2370
- if (part && chunk.block && typeof chunk.block.text === 'string') {
2371
- part.text = chunk.block.text
2372
- }
2373
- break
2374
- }
2375
- case 'finish': {
2376
- const reason = chunk.reason
2377
- if (reason && (reason.kind === 'error' || reason.kind === 'aborted')) {
2378
- failure = reason.failure
2379
- }
2380
- finishKind = reason && reason.kind ? reason.kind : 'stop'
2381
- break
2382
- }
2383
- case 'error':
2384
- case 'aborted':
2385
- failure = chunk.failure
2386
- break
2387
- default:
2388
- break
2389
- }
2390
- }
2391
-
2392
- const finish = () => {
2393
- if (failure) {
2394
- throw new Error(failure && failure.message ? failure.message : String(failure))
2395
- }
2396
- if (finishKind !== undefined && finishKind !== 'stop' && finishKind !== 'max-tokens') {
2397
- throw new Error(`vision call finished with "${finishKind}"`)
2398
- }
2399
- return order
2400
- .map((index) => parts.get(index))
2401
- .filter((part) => part && part.type === 'text')
2402
- .map((part) => part.text)
2403
- .join('')
2404
- .trim()
2405
- }
2406
-
2407
- return { push, finish }
2408
- }
2275
+ import { createChunkAssembler } from './vision-chunk-assembler.js'
2276
+ export { createChunkAssembler }
2409
2277
 
2410
2278
  async function visionAnswer(llm, options) {
2411
2279
  return runWithVisionSessionAffinity(options?.sessionId, async () => {
@@ -2972,7 +2840,6 @@ export {
2972
2840
  FAILURE_ADVICE,
2973
2841
  PERSISTED_GUARD_STOP_SURFACE_ID,
2974
2842
  imageMarker,
2975
- cacheWeight,
2976
2843
  visionAnswer,
2977
2844
  NON_GENERATIVE_VISION_MODEL_HINTS,
2978
2845
  VISION_MODEL_NAME_HINTS,