@mengruo/dsh-vision-toolkit 0.1.3 → 0.1.4

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 (80) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  5. package/docs/requirements-traceability/README.md +1 -1
  6. package/docs/requirements-traceability/README.zh.md +1 -1
  7. package/lib/artifact-access.js +20 -2
  8. package/lib/artifact-access.js.map +1 -1
  9. package/lib/client.js +22 -7
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +34 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -2
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +2 -1
  16. package/lib/evidence-cache.js.map +1 -1
  17. package/lib/exposure.js +14 -1
  18. package/lib/exposure.js.map +1 -1
  19. package/lib/image-input-variants.js +22 -12
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/paste-images.js +67 -19
  24. package/lib/paste-images.js.map +1 -1
  25. package/lib/paths.js +214 -28
  26. package/lib/paths.js.map +1 -1
  27. package/lib/runtime-manager.js +76 -10
  28. package/lib/runtime-manager.js.map +1 -1
  29. package/lib/runtime.js +80 -12
  30. package/lib/runtime.js.map +1 -1
  31. package/lib/storage-history.js +154 -0
  32. package/lib/storage-history.js.map +1 -0
  33. package/lib/types/artifact-access.d.ts.map +1 -1
  34. package/lib/types/client/index.d.ts +6 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/paste-images.d.ts +2 -0
  37. package/lib/types/client/paste-images.d.ts.map +1 -1
  38. package/lib/types/config.d.ts +22 -0
  39. package/lib/types/config.d.ts.map +1 -1
  40. package/lib/types/errors.d.ts +18 -2
  41. package/lib/types/errors.d.ts.map +1 -1
  42. package/lib/types/evidence-cache.d.ts +1 -1
  43. package/lib/types/evidence-cache.d.ts.map +1 -1
  44. package/lib/types/exposure.d.ts.map +1 -1
  45. package/lib/types/image-input-variants.d.ts +5 -3
  46. package/lib/types/image-input-variants.d.ts.map +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/paste-images.d.ts +12 -4
  49. package/lib/types/paste-images.d.ts.map +1 -1
  50. package/lib/types/paths.d.ts +31 -5
  51. package/lib/types/paths.d.ts.map +1 -1
  52. package/lib/types/runtime-manager.d.ts +28 -4
  53. package/lib/types/runtime-manager.d.ts.map +1 -1
  54. package/lib/types/runtime.d.ts +19 -1
  55. package/lib/types/runtime.d.ts.map +1 -1
  56. package/lib/types/storage-history.d.ts +63 -0
  57. package/lib/types/storage-history.d.ts.map +1 -0
  58. package/lib/types/upstream.d.ts.map +1 -1
  59. package/lib/types/web.d.ts.map +1 -1
  60. package/lib/upstream.js +31 -8
  61. package/lib/upstream.js.map +1 -1
  62. package/lib/web.js +9 -3
  63. package/lib/web.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/artifact-access.ts +22 -2
  66. package/src/client/index.tsx +18 -3
  67. package/src/client/paste-images.tsx +14 -4
  68. package/src/config.ts +61 -0
  69. package/src/errors.ts +25 -2
  70. package/src/evidence-cache.ts +2 -1
  71. package/src/exposure.ts +16 -2
  72. package/src/image-input-variants.ts +21 -6
  73. package/src/index.ts +65 -6
  74. package/src/paste-images.ts +81 -19
  75. package/src/paths.ts +249 -28
  76. package/src/runtime-manager.ts +93 -10
  77. package/src/runtime.ts +79 -11
  78. package/src/storage-history.ts +172 -0
  79. package/src/upstream.ts +32 -7
  80. package/src/web.ts +9 -2
package/src/runtime.ts CHANGED
@@ -475,6 +475,9 @@ export interface VisionToolkitHealthResult {
475
475
  python: HealthCheck
476
476
  dependencies: HealthCheck
477
477
  chrome: HealthCheck
478
+ credential: HealthCheck
479
+ artifactDirectory: HealthCheck
480
+ tempDirectory: HealthCheck
478
481
  service: HealthCheck
479
482
  model: HealthCheck
480
483
  }
@@ -537,8 +540,15 @@ const FORMAT_BY_EXTENSION = new Map([
537
540
  ])
538
541
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
539
542
 
540
- /** Error codes a provider retries within its attempt budget (429 is handled separately). */
541
- const RETRYABLE_CODES: ReadonlySet<VisionToolkitErrorCode> = new Set(['service', 'timeout'])
543
+ /**
544
+ * Error codes a provider retries against the SAME provider within its
545
+ * `attempts` budget. Only transient failures are worth re-requesting: a
546
+ * timeout may clear on the next attempt and a 5xx / network drop is usually
547
+ * ephemeral. Deterministic failures (auth, quota, rate_limit, invalid_request,
548
+ * region, tos) must fail over to the next provider immediately instead of
549
+ * re-requesting a backend that cannot succeed with the same input.
550
+ */
551
+ const RETRYABLE_CODES: ReadonlySet<VisionToolkitErrorCode> = new Set(['timeout', 'server', 'network'])
542
552
 
543
553
  /** Resolve as soon as `signal` aborts (or immediately when already aborted). */
544
554
  function untilAbort(signal: AbortSignal): Promise<void> {
@@ -771,6 +781,7 @@ export class VisionToolkitRuntime {
771
781
  private readonly ctx: Context,
772
782
  private readonly config: ResolvedVisionToolkitConfig,
773
783
  adapter?: UpstreamAdapter,
784
+ private readonly readableStorageDirs: readonly string[] = [],
774
785
  ) {
775
786
  this.adapter = adapter ?? new UpstreamAdapter(ctx, config)
776
787
  }
@@ -785,6 +796,11 @@ export class VisionToolkitRuntime {
785
796
  return this.config.sessionMaxConcurrency
786
797
  }
787
798
 
799
+ /** Shared storage root belonging to this immutable runtime generation. */
800
+ get storageDirectory(): string | undefined {
801
+ return this.config.storageDir
802
+ }
803
+
788
804
  /** Stable identity for persisted image descriptions produced by this runtime. */
789
805
  get evidenceFingerprint(): string {
790
806
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
@@ -1010,13 +1026,13 @@ export class VisionToolkitRuntime {
1010
1026
  }
1011
1027
 
1012
1028
  private pathPolicy(workspace: string): Promise<PathPolicy> {
1013
- return createPathPolicy(workspace, this.config.allowedDirs)
1029
+ return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs)
1014
1030
  }
1015
1031
 
1016
1032
  private async compressedImageRoot(policy: PathPolicy): Promise<string> {
1017
- const root = join(policy.workspace, '.dsh-vision-toolkit', 'tmp', 'compressed-images')
1018
- let current = policy.workspace
1019
- for (const segment of ['.dsh-vision-toolkit', 'tmp', 'compressed-images']) {
1033
+ const root = join(policy.storageRoot, 'tmp', 'compressed-images')
1034
+ let current = policy.storageRoot
1035
+ for (const segment of ['tmp', 'compressed-images']) {
1020
1036
  current = join(current, segment)
1021
1037
  try {
1022
1038
  await mkdir(current, { mode: 0o700 })
@@ -1027,13 +1043,13 @@ export class VisionToolkitRuntime {
1027
1043
  if (info.isSymbolicLink() || !info.isDirectory()) {
1028
1044
  throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`)
1029
1045
  }
1030
- if (!isWithin(policy.workspace, current)) {
1031
- throw new VisionToolkitError('path', `compressed-image cache path escaped the workspace: ${current}`)
1046
+ if (!isWithin(policy.storageRoot, current)) {
1047
+ throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`)
1032
1048
  }
1033
1049
  }
1034
1050
  const canonical = await realpath(root)
1035
- if (!isWithin(policy.workspace, canonical)) {
1036
- throw new VisionToolkitError('path', 'compressed-image cache resolved outside the workspace')
1051
+ if (!isWithin(policy.storageRoot, canonical)) {
1052
+ throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage')
1037
1053
  }
1038
1054
  return canonical
1039
1055
  }
@@ -1383,6 +1399,22 @@ export class VisionToolkitRuntime {
1383
1399
  return Math.max(0, Math.min(t2Remaining, globalRemaining))
1384
1400
  }
1385
1401
 
1402
+ /**
1403
+ * Advance to the next provider after one provider reached a terminal
1404
+ * failure. This is what makes failover work for FAST failures too: the
1405
+ * hedge timer only launches the next provider when the current one is SLOW
1406
+ * (crosses t1), so a quick auth/5xx/network failure must explicitly launch
1407
+ * the successor. Never advances when a higher-priority provider superseded
1408
+ * this task, when the whole operation was cancelled, or when the global
1409
+ * deadline has too little room left for another request. `launch` is
1410
+ * idempotent, so an earlier hedge timer cannot cause a double launch.
1411
+ */
1412
+ private advanceAfterFailure(task: ProviderTask, operation: OperationContext, launchNext: () => void): void {
1413
+ if (task.abort.signal.aborted || operation.signal.aborted) return
1414
+ if (operation.deadlineAt - Date.now() < this.config.minAvailableSeconds * 1000) return
1415
+ launchNext()
1416
+ }
1417
+
1386
1418
  /**
1387
1419
  * Run one provider to a terminal state: retryable errors retry within
1388
1420
  * `attempts`, a single request crossing t1 hedges the next provider, and the
@@ -1415,6 +1447,7 @@ export class VisionToolkitRuntime {
1415
1447
  if (budget < minAvailableMs) {
1416
1448
  task.status = 'failed'
1417
1449
  task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} has insufficient remaining time`)
1450
+ this.advanceAfterFailure(task, operation, launchNext)
1418
1451
  return
1419
1452
  }
1420
1453
  const reqDeadline = createDeadline(AbortSignal.any([operation.signal, task.abort.signal]), budget)
@@ -1453,6 +1486,7 @@ export class VisionToolkitRuntime {
1453
1486
  if (reqDeadline.timedOut) {
1454
1487
  task.status = 'failed'
1455
1488
  task.error = new VisionToolkitError('timeout', `${tool}: ${provider.name} exhausted its t2 budget`)
1489
+ this.advanceAfterFailure(task, operation, launchNext)
1456
1490
  return
1457
1491
  }
1458
1492
  if (classified.code === 'rate_limit') {
@@ -1467,6 +1501,7 @@ export class VisionToolkitRuntime {
1467
1501
  }
1468
1502
  task.status = 'failed'
1469
1503
  task.error = classified
1504
+ this.advanceAfterFailure(task, operation, launchNext)
1470
1505
  return
1471
1506
  } finally {
1472
1507
  reqDeadline.cleanup()
@@ -2453,6 +2488,18 @@ export class VisionToolkitRuntime {
2453
2488
  })
2454
2489
  }
2455
2490
 
2491
+ private async writableDirectoryCheck(path: string, label: string): Promise<HealthCheck> {
2492
+ const probe = join(path, `.vision-toolkit-health-${randomUUID()}`)
2493
+ try {
2494
+ await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' })
2495
+ await rm(probe, { force: true })
2496
+ return { status: 'ok', detail: `${label} is writable: ${path}` }
2497
+ } catch {
2498
+ await rm(probe, { force: true }).catch(() => {})
2499
+ return { status: 'error', detail: `${label} is not writable: ${path}` }
2500
+ }
2501
+ }
2502
+
2456
2503
  /** Health: inspect local readiness, and optionally probe one provider's `/models` plus one real multimodal request. */
2457
2504
  async health(testConnection: boolean, options: ToolCallOptions, testModel = false, provider?: ResolvedProvider): Promise<VisionToolkitHealthResult> {
2458
2505
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
@@ -2474,6 +2521,27 @@ export class VisionToolkitRuntime {
2474
2521
  if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled')
2475
2522
  chrome = { status: 'error', detail: 'Chrome availability probe failed' }
2476
2523
  }
2524
+ let resolvedCredential: ResolvedCredential | undefined
2525
+ let credential: HealthCheck
2526
+ try {
2527
+ resolvedCredential = isBuiltInFreeVisionProvider(this.config.provider)
2528
+ ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
2529
+ : await this.ctx.credentials.resolve(this.config.provider.credential)
2530
+ credential = resolvedCredential === undefined
2531
+ ? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
2532
+ : { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` }
2533
+ } catch {
2534
+ credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` }
2535
+ }
2536
+ let artifactDirectory: HealthCheck
2537
+ try {
2538
+ // allowedDirs are session input roots; they do not affect output readiness.
2539
+ const policy = await createPathPolicy(options.workspace, [], this.config.storageDir)
2540
+ artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
2541
+ } catch {
2542
+ artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
2543
+ }
2544
+ const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory')
2477
2545
  let service: HealthCheck = {
2478
2546
  status: 'not_tested',
2479
2547
  detail: 'Connection was not tested; use the per-provider API test',
@@ -2564,7 +2632,7 @@ export class VisionToolkitRuntime {
2564
2632
  }
2565
2633
  }
2566
2634
  }
2567
- const checks = { python, dependencies, chrome, service, model }
2635
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
2568
2636
  const healthy = Object.values(checks).every(check => check.status !== 'error')
2569
2637
  return {
2570
2638
  pluginVersion: PLUGIN_VERSION,
@@ -0,0 +1,172 @@
1
+ /** Durable configured-storage history used to authorize persisted image paths after Profile restarts. */
2
+
3
+ import type { Context, Fiber } from '@deepseek-ai/cordis'
4
+ import { defineDomain } from '@deepseek-ai/dsh-storage-domain'
5
+ import type { DomainGlobal } from '@deepseek-ai/dsh-storage-domain'
6
+ import { z } from 'zod'
7
+ import { resolveConfig, type VisionToolkitConfig } from './config.ts'
8
+
9
+ const storageHistoryStateSchema = z.object({
10
+ roots: z.array(z.string().min(1)),
11
+ })
12
+
13
+ type StorageHistoryState = z.infer<typeof storageHistoryStateSchema>
14
+
15
+ /** Plugin-owned storage roots that survive Settings-provider and Profile restarts. */
16
+ export const storageHistoryDomainSpec = defineDomain({
17
+ name: 'vision_toolkit_storage',
18
+ version: 0,
19
+ global: {
20
+ schema: storageHistoryStateSchema,
21
+ initial: { roots: [] },
22
+ },
23
+ tables: {},
24
+ })
25
+
26
+ interface StorageBinding {
27
+ accepting: boolean
28
+ global: DomainGlobal<StorageHistoryState>
29
+ }
30
+
31
+ function sameRoots(left: readonly string[], right: readonly string[]): boolean {
32
+ return left.length === right.length && left.every((root, index) => root === right[index])
33
+ }
34
+
35
+ /**
36
+ * Return every configured root that must remain readable, including the active root.
37
+ * @param config - Settings generation to summarize.
38
+ * @returns normalized configured roots in retention order.
39
+ */
40
+ export function configuredStorageRoots(config: VisionToolkitConfig): string[] {
41
+ const resolved = resolveConfig(config)
42
+ return [...new Set([
43
+ ...resolved.storageHistory,
44
+ ...(resolved.storageDir === undefined ? [] : [resolved.storageDir]),
45
+ ])]
46
+ }
47
+
48
+ /**
49
+ * Merge plugin-owned roots into a Settings generation without retaining its active root as history.
50
+ * @param config - Settings generation being restored.
51
+ * @param durableRoots - roots loaded from the plugin-owned sidecar.
52
+ * @returns the original generation when unchanged, otherwise a generation with restored history.
53
+ */
54
+ export function restoreDurableStorageHistory(
55
+ config: VisionToolkitConfig,
56
+ durableRoots: readonly string[],
57
+ ): VisionToolkitConfig {
58
+ const resolved = resolveConfig(config)
59
+ const storageHistory = [...new Set([...resolved.storageHistory, ...durableRoots])]
60
+ .filter(root => root !== resolved.storageDir)
61
+ if (sameRoots(storageHistory, resolved.storageHistory)) return config
62
+ return { ...config, storageHistory }
63
+ }
64
+
65
+ /** Optional storage-domain sidecar for storage roots that Settings cannot persist itself. */
66
+ export class StorageHistoryStore {
67
+ private storage: StorageBinding | undefined
68
+ private storageFiber: (Fiber & PromiseLike<Fiber>) | undefined
69
+ private storageReady: Promise<void> | undefined
70
+ private mutationTail: Promise<void> = Promise.resolve()
71
+ private desiredRoots: readonly string[] | undefined
72
+ private persistenceTicket = 0
73
+ private warned = false
74
+
75
+ constructor(private readonly ctx: Context) {
76
+ if (typeof ctx.inject !== 'function') return
77
+ this.storageFiber = ctx.inject(['storageDomain'], async (storageCtx: Context) => {
78
+ const domain = await storageCtx.storageDomain.open(storageHistoryDomainSpec)
79
+ const binding: StorageBinding = { accepting: true, global: domain.global }
80
+ this.storage = binding
81
+ try {
82
+ if (this.desiredRoots !== undefined) {
83
+ await this.write(binding, this.desiredRoots, this.persistenceTicket)
84
+ }
85
+ } catch (error) {
86
+ this.storage = undefined
87
+ await domain.close()
88
+ throw error
89
+ }
90
+ return async () => {
91
+ binding.accepting = false
92
+ if (this.storage === binding) this.storage = undefined
93
+ await this.mutationTail
94
+ await domain.close()
95
+ }
96
+ })
97
+ this.storageReady = Promise.resolve(this.storageFiber).then(
98
+ () => undefined,
99
+ (error: unknown) => { this.warnOnce(error) },
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Restore durable roots into one Settings generation before runtime preparation.
105
+ * @param config - Settings generation to restore.
106
+ * @returns the generation with available durable roots merged into its history.
107
+ */
108
+ async restore(config: VisionToolkitConfig): Promise<VisionToolkitConfig> {
109
+ const binding = await this.prepareStorage()
110
+ return restoreDurableStorageHistory(config, binding?.global.get().roots ?? [])
111
+ }
112
+
113
+ /**
114
+ * Persist the active and historical configured roots.
115
+ * @param config - validated generation whose roots must survive restart.
116
+ * @returns false when no storage-domain is available; true after persistence or when there are no roots.
117
+ */
118
+ async persist(config: VisionToolkitConfig): Promise<boolean> {
119
+ const roots = configuredStorageRoots(config)
120
+ const ticket = ++this.persistenceTicket
121
+ this.desiredRoots = roots
122
+ if (roots.length === 0) return true
123
+ const binding = await this.prepareStorage()
124
+ if (binding === undefined) return false
125
+ await this.write(binding, roots, ticket)
126
+ return true
127
+ }
128
+
129
+ /** Release the optional storage-domain binding with the plugin lifecycle. */
130
+ dispose(): void {
131
+ const fiber = this.storageFiber
132
+ this.storageFiber = undefined
133
+ this.storageReady = undefined
134
+ if (fiber !== undefined) void fiber.dispose().catch(error => { this.warnOnce(error) })
135
+ }
136
+
137
+ private async prepareStorage(): Promise<StorageBinding | undefined> {
138
+ const current = this.activeStorage()
139
+ if (current !== undefined) return current
140
+ if (this.ctx.get('storageDomain') === undefined) return undefined
141
+ await this.storageReady
142
+ return this.activeStorage()
143
+ }
144
+
145
+ private activeStorage(): StorageBinding | undefined {
146
+ return this.storage?.accepting === true ? this.storage : undefined
147
+ }
148
+
149
+ private write(binding: StorageBinding, roots: readonly string[], ticket: number): Promise<void> {
150
+ return this.enqueueMutation(async () => {
151
+ if (!binding.accepting) throw new Error('the storage-domain provider changed while storage history was pending')
152
+ if (ticket !== this.persistenceTicket) return
153
+ if (sameRoots(binding.global.get().roots, roots)) return
154
+ await binding.global.set({ roots: [...roots] })
155
+ })
156
+ }
157
+
158
+ private enqueueMutation(operation: () => Promise<void>): Promise<void> {
159
+ const result = this.mutationTail.then(operation)
160
+ this.mutationTail = result.then(() => undefined, () => undefined)
161
+ return result
162
+ }
163
+
164
+ private warnOnce(error: unknown): void {
165
+ if (this.warned) return
166
+ this.warned = true
167
+ this.ctx.logger?.warn(
168
+ 'dsh-vision-toolkit: configured storage history sidecar is unavailable. %s',
169
+ (error instanceof Error ? error.message : String(error)).slice(0, 500),
170
+ )
171
+ }
172
+ }
package/src/upstream.ts CHANGED
@@ -1059,22 +1059,47 @@ export class UpstreamAdapter {
1059
1059
  return new VisionToolkitError('output', `${tool}: upstream output exceeded the capture limit`)
1060
1060
  }
1061
1061
  const message = upstreamFailureMessage(tool, result.stderr, options.secrets ?? [])
1062
- if (/HTTP 401|\b401\b|Unauthorized|authentication/i.test(result.stderr)) {
1063
- return new VisionToolkitError('service', `${message}; verify the configured credential`)
1062
+ const stderr = result.stderr
1063
+
1064
+ // Remote provider failures, classified into a machine-routable taxonomy so
1065
+ // the failover loop retries transient errors and fails over immediately on
1066
+ // deterministic ones. Order matters: auth/quota/rate-limit/region/tos are
1067
+ // checked before the broader server/network/request-shape patterns.
1068
+ if (/HTTP 401|\b401\b|HTTP 403|\b403\b|Unauthorized|Forbidden|authentication|Invalid API-key|invalid api key|check the api key/i.test(stderr)) {
1069
+ return new VisionToolkitError('auth', `${message}; verify the configured credential`)
1070
+ }
1071
+ if (/HTTP 402|\b402\b|Payment Required|insufficient balance|insufficient quota|insufficient credits|billing|out of credits|out of quota/i.test(stderr)) {
1072
+ return new VisionToolkitError('quota', `${message}; the provider account is out of quota or unpaid`)
1064
1073
  }
1065
- if (/HTTP 429|\b429\b|rate limit|quota/i.test(result.stderr)) {
1074
+ if (/HTTP 429|\b429\b|rate ?limit|too many requests/i.test(stderr)) {
1066
1075
  return new VisionToolkitError('rate_limit', `${message}; retry later or reduce concurrency`)
1067
1076
  }
1068
- if (/Missing config VISION_/i.test(result.stderr)) {
1077
+ if (/not available in your region|prohibited region|unsupported region|region is not supported/i.test(stderr)) {
1078
+ return new VisionToolkitError('region', `${message}; the provider is not available in this region`)
1079
+ }
1080
+ if (/terms of service|\btos\b|content policy|safety system/i.test(stderr)) {
1081
+ return new VisionToolkitError('tos', `${message}; the request was rejected by the provider content policy`)
1082
+ }
1083
+ if (/HTTP 5\d\d|\b500\b|\b502\b|\b503\b|\b504\b|bad gateway|service unavailable|internal server error/i.test(stderr)) {
1084
+ return new VisionToolkitError('server', `${message}; the provider returned a server error`)
1085
+ }
1086
+ if (/connection refused|connection reset|ECONN|ENOTFOUND|EAI_AGAIN|getaddrinfo|fetch failed|name resolution|network error|network is unreachable/i.test(stderr)) {
1087
+ return new VisionToolkitError('network', `${message}; network failure reaching the provider`)
1088
+ }
1089
+ if (/HTTP 400|\b400\b|HTTP 404|\b404\b|HTTP 422|\b422\b|invalid request|invalid model|no such model|model not exist|unknown model/i.test(stderr)) {
1090
+ return new VisionToolkitError('invalid_request', `${message}; the request or model is not supported by this provider`)
1091
+ }
1092
+
1093
+ if (/Missing config VISION_/i.test(stderr)) {
1069
1094
  return new VisionToolkitError('config', message)
1070
1095
  }
1071
- if (/maxImagePixels|exceed(?:s|ing).*pixels/i.test(result.stderr)) {
1096
+ if (/maxImagePixels|exceed(?:s|ing).*pixels/i.test(stderr)) {
1072
1097
  return new VisionToolkitError('capacity', message)
1073
1098
  }
1074
- if (/not found|only PNG|unsupported|cannot open|empty region|must be|expects|invalid colour|needs at least/i.test(result.stderr)) {
1099
+ if (/not found|only PNG|unsupported|cannot open|empty region|must be|expects|invalid colour|needs at least/i.test(stderr)) {
1075
1100
  return new VisionToolkitError('input', message)
1076
1101
  }
1077
- if (/requires Pillow|requires numpy|requires vtracer|no Chrome|capture failed/i.test(result.stderr)) {
1102
+ if (/requires Pillow|requires numpy|requires vtracer|no Chrome|capture failed/i.test(stderr)) {
1078
1103
  return new VisionToolkitError('runtime', message)
1079
1104
  }
1080
1105
  return new VisionToolkitError(
package/src/web.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './paste-images.ts'
23
23
  import {
24
24
  resolveConfig,
25
+ retainedStorageHistory,
25
26
  isBuiltInFreeVisionProvider,
26
27
  VISION_TOOLKIT_SETTINGS_NAMESPACE,
27
28
  type ResolvedProvider,
@@ -346,16 +347,22 @@ export class VisionToolkitWebBackend {
346
347
 
347
348
  private async save(request: SaveRequest): Promise<VisionToolkitSettingsSnapshot> {
348
349
  if (!this.ctx.settings.writable) throw new Error('settings provider is read-only')
350
+ const current = resolveConfig(descriptorOf(this.ctx).value as VisionToolkitConfig)
351
+ const storageHistory = retainedStorageHistory(request.value, current)
352
+ const value: VisionToolkitConfig = {
353
+ ...request.value,
354
+ ...(storageHistory.length === 0 ? {} : { storageHistory }),
355
+ }
349
356
  let candidate: PreparedRuntimeGeneration
350
357
  try {
351
- candidate = await this.manager.prepareCandidate(request.value)
358
+ candidate = await this.manager.prepareCandidate(value)
352
359
  } catch (error) {
353
360
  this.manager.recordFailure(error)
354
361
  throw error
355
362
  }
356
363
  await this.ctx.settings.replace(
357
364
  VISION_TOOLKIT_SETTINGS_NAMESPACE,
358
- request.value as object,
365
+ value as object,
359
366
  request.expectedRevision,
360
367
  )
361
368
  this.manager.activateCandidate(candidate)