@deepblend/dsh-blender-host 0.2.0 → 0.2.2
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.
- package/lib/index.js +210 -7
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -143,6 +143,84 @@ export function contentTypeForArtifact(path) {
|
|
|
143
143
|
* "the host bundle is missing" error at runtime. */
|
|
144
144
|
export const RUNTIME_SERVICE = 'blenderRuntime'
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The configuration a diagnostic bundle may carry.
|
|
148
|
+
*
|
|
149
|
+
* AN ALLOWLIST, AND IT LIVES HERE BECAUSE THIS IS WHERE THE KEYS ARE DECLARED. `config-surface.test.mjs`
|
|
150
|
+
* holds every `config.<key>` a package reads to that package's own schema, and it is right to: a value read
|
|
151
|
+
* where it cannot arrive is a knob that lies. The UI half projecting the Host's configuration would be
|
|
152
|
+
* exactly that — the UI's own schema declares none of these keys. So the Host names them, and the UI
|
|
153
|
+
* asks for the projection.
|
|
154
|
+
*
|
|
155
|
+
* The direction of the default matters: a key added to `StudioConfig` later is ABSENT from a bundle
|
|
156
|
+
* until somebody adds it here. A spread would publish it, and the bundle is the one artifact that
|
|
157
|
+
* leaves the machine.
|
|
158
|
+
*
|
|
159
|
+
* @param {Record<string, unknown>} config - the resolved `StudioConfig`
|
|
160
|
+
* @returns {Record<string, unknown>}
|
|
161
|
+
*/
|
|
162
|
+
export function diagnosticConfiguration(config) {
|
|
163
|
+
const source = config ?? {}
|
|
164
|
+
return {
|
|
165
|
+
projectsRoot: source.projectsRoot ?? null,
|
|
166
|
+
workspaceRoot: source.workspaceRoot ?? null,
|
|
167
|
+
maxPreviewSamples: source.maxPreviewSamples ?? null,
|
|
168
|
+
assetMaxBytes: source.assetMaxBytes ?? null,
|
|
169
|
+
maxMeshPolygons: source.maxMeshPolygons ?? null,
|
|
170
|
+
maxVisualIterations: source.maxVisualIterations ?? null,
|
|
171
|
+
minVisualConfidenceForAutoFix: source.minVisualConfidenceForAutoFix ?? null,
|
|
172
|
+
stopOnRepeatedIssueCount: source.stopOnRepeatedIssueCount ?? null,
|
|
173
|
+
visualReviewProvider: source.visualReviewProvider ?? null,
|
|
174
|
+
visualReviewModel: source.visualReviewModel ?? null,
|
|
175
|
+
visualReviewMaxTokens: source.visualReviewMaxTokens ?? null,
|
|
176
|
+
visualReviewViews: source.visualReviewViews ?? [],
|
|
177
|
+
finalRenderProfile: source.finalRenderProfile ?? null,
|
|
178
|
+
maxFinalSamples: source.maxFinalSamples ?? null,
|
|
179
|
+
ffmpegPath: source.ffmpegPath ?? null,
|
|
180
|
+
ffprobePath: source.ffprobePath ?? null,
|
|
181
|
+
encodeCrf: source.encodeCrf ?? null,
|
|
182
|
+
encodePreset: source.encodePreset ?? null,
|
|
183
|
+
requireApprovalAboveFrames: source.requireApprovalAboveFrames ?? null,
|
|
184
|
+
progressPollMs: source.progressPollMs ?? null,
|
|
185
|
+
reconcileOnStart: source.reconcileOnStart ?? null,
|
|
186
|
+
orphanGraceMs: source.orphanGraceMs ?? null,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The product's version, from this package's own manifest.
|
|
192
|
+
*
|
|
193
|
+
* NOT A CONSTANT, and not a second copy of the number: `deepblend/version.json` is the single source,
|
|
194
|
+
* `tools/release-version.mjs --check` holds all eight manifests to it, so reading any one of them
|
|
195
|
+
* answers for the product — and `contract/diagnostics.test.mjs` compares what this returns against
|
|
196
|
+
* the source, which is what makes it a reading rather than a third copy.
|
|
197
|
+
*
|
|
198
|
+
* It answers `null` rather than throwing: the diagnostic bundle is what a user produces when
|
|
199
|
+
* something is already wrong, and a version field that crashed the export would be the least useful
|
|
200
|
+
* failure in the product. Read once and remembered — a manifest does not change under a running
|
|
201
|
+
* process.
|
|
202
|
+
*
|
|
203
|
+
* AND IT IS HERE, not in the UI half, because that half is asserted to import no filesystem module
|
|
204
|
+
* at all (`composition/ui-plane.e2e.mjs`): it is the layer the browser talks to. The first version of
|
|
205
|
+
* the diagnostics bundle read this manifest in `ui/lib/index.js` and that suite went red, correctly.
|
|
206
|
+
*
|
|
207
|
+
* @returns {string|null}
|
|
208
|
+
*/
|
|
209
|
+
export function readProductVersion() {
|
|
210
|
+
if (cachedProductVersion === undefined) {
|
|
211
|
+
try {
|
|
212
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
213
|
+
cachedProductVersion = typeof manifest.version === 'string' ? manifest.version : null
|
|
214
|
+
} catch {
|
|
215
|
+
cachedProductVersion = null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return cachedProductVersion
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @type {string|null|undefined} */
|
|
222
|
+
let cachedProductVersion
|
|
223
|
+
|
|
146
224
|
/**
|
|
147
225
|
* Host-level configuration. Mirrors SPEC §17.
|
|
148
226
|
*
|
|
@@ -223,17 +301,20 @@ export const StudioConfig = z.object({
|
|
|
223
301
|
/** Provider route used by the built-in vision reviewer. */
|
|
224
302
|
visualReviewProvider: z.string().default('deepseek-official'),
|
|
225
303
|
/** Model used by the built-in vision reviewer. */
|
|
226
|
-
visualReviewModel: z.string().default('deepseek-flash'),
|
|
304
|
+
visualReviewModel: z.string().default('deepseek-v4.1-flash'),
|
|
227
305
|
/**
|
|
228
306
|
* Token budget for one reviewer call.
|
|
229
307
|
*
|
|
230
308
|
* MEASURED, and the reason this is a config field rather than a constant: with the
|
|
231
|
-
* full reviewer prompt and a 2x2 sheet,
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
309
|
+
* full reviewer prompt and a 2x2 sheet, the model spends most of its output budget on
|
|
310
|
+
* REASONING before emitting any text. MEASURED twice, with the model the deployment
|
|
311
|
+
* actually serves (`deepseek-v4.1-flash`, via `visual-review-live-probe.mjs`): one
|
|
312
|
+
* review of a 640x360 sheet answered in 1049 tokens — 345 in, 704 out, of which 686
|
|
313
|
+
* were reasoning — and at the probe's own 400-token budget the same call ended with
|
|
314
|
+
* `max-tokens` and NO text at all. That empty answer is indistinguishable downstream
|
|
315
|
+
* from "the model reviewed the sheet and found nothing wrong", and that silent
|
|
316
|
+
* approval is the failure mode this number exists to prevent. The size of it is a
|
|
317
|
+
* property of the model, so this budget is sized for the reasoning, not the answer.
|
|
237
318
|
*/
|
|
238
319
|
visualReviewMaxTokens: z.number().default(24_000),
|
|
239
320
|
/** Views in the standard plan, in reading order (SPEC §12.3). */
|
|
@@ -400,6 +481,38 @@ export default class BlenderStudio extends Service {
|
|
|
400
481
|
return HOST_API_VERSION
|
|
401
482
|
}
|
|
402
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The product's version, read from this package's own manifest.
|
|
486
|
+
*
|
|
487
|
+
* HERE RATHER THAN IN THE UI HALF, and that is a rule rather than a preference: the UI host is
|
|
488
|
+
* asserted to import no filesystem module at all (`composition/ui-plane.e2e.mjs`), because that
|
|
489
|
+
* layer is the one the browser talks to. The first version of the diagnostics bundle read this
|
|
490
|
+
* manifest in `ui/lib/index.js` and that suite went red, correctly.
|
|
491
|
+
*
|
|
492
|
+
* NOT A CONSTANT, and not a second copy of the number: `deepblend/version.json` is the single
|
|
493
|
+
* source, `tools/release-version.mjs --check` holds all eight manifests to it, so reading any one
|
|
494
|
+
* of them answers for the product — and `contract/diagnostics.test.mjs` compares what this returns
|
|
495
|
+
* against the source, which is what makes it a reading rather than a third copy.
|
|
496
|
+
*
|
|
497
|
+
* It answers `null` rather than throwing: the diagnostic bundle is what a user produces when
|
|
498
|
+
* something is already wrong, and a version field that crashed the export would be the least
|
|
499
|
+
* useful failure in the product.
|
|
500
|
+
*
|
|
501
|
+
* @returns {string|null}
|
|
502
|
+
*/
|
|
503
|
+
productVersion() {
|
|
504
|
+
return readProductVersion()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The Host's own configuration, as a diagnostic bundle may carry it.
|
|
509
|
+
*
|
|
510
|
+
* @returns {Record<string, unknown>}
|
|
511
|
+
*/
|
|
512
|
+
describeConfiguration() {
|
|
513
|
+
return diagnosticConfiguration(this.config)
|
|
514
|
+
}
|
|
515
|
+
|
|
403
516
|
// ---------------------------------------------------------------------------
|
|
404
517
|
// Capabilities (M0)
|
|
405
518
|
// ---------------------------------------------------------------------------
|
|
@@ -1720,6 +1833,75 @@ export default class BlenderStudio extends Service {
|
|
|
1720
1833
|
*
|
|
1721
1834
|
* @returns {(request: object) => Promise<{ findings: object[], operations: object[], note: string|null, model: string|null, provider: string|null, raw: string|null }>}
|
|
1722
1835
|
*/
|
|
1836
|
+
/**
|
|
1837
|
+
* Whether the configured vision route can actually review an image.
|
|
1838
|
+
*
|
|
1839
|
+
* Answers `{ problem: null }` when the route is usable, or a sentence naming what is wrong and
|
|
1840
|
+
* what the catalog offers instead. Never throws: an absent catalog is "cannot check", not "bad
|
|
1841
|
+
* route", and the model call downstream is still the authority on whether the call works.
|
|
1842
|
+
*
|
|
1843
|
+
* @param {string} provider
|
|
1844
|
+
* @param {string} model
|
|
1845
|
+
* @returns {Promise<{ problem: string|null, providers: string[], models: string[], imageCapable: string[] }>}
|
|
1846
|
+
*/
|
|
1847
|
+
async _visionRoute(provider, model) {
|
|
1848
|
+
const empty = { problem: null, providers: [], models: [], imageCapable: [] }
|
|
1849
|
+
const llm = this.ctx.get('llm')
|
|
1850
|
+
if (llm === undefined || typeof llm.listModels !== 'function') return empty
|
|
1851
|
+
|
|
1852
|
+
let providers = []
|
|
1853
|
+
try {
|
|
1854
|
+
providers = (llm.listProviders?.() ?? []).map(entry => entry.id).filter(id => typeof id === 'string')
|
|
1855
|
+
} catch {
|
|
1856
|
+
return empty
|
|
1857
|
+
}
|
|
1858
|
+
if (providers.length > 0 && !providers.includes(provider)) {
|
|
1859
|
+
return {
|
|
1860
|
+
problem: `The vision reviewer is configured for provider "${provider}", which this deployment does not offer. ` +
|
|
1861
|
+
`Set \`visualReviewProvider\` to one of: ${providers.join(', ')}.`,
|
|
1862
|
+
providers,
|
|
1863
|
+
models: [],
|
|
1864
|
+
imageCapable: [],
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
let catalog = []
|
|
1869
|
+
try {
|
|
1870
|
+
catalog = await llm.listModels(provider)
|
|
1871
|
+
} catch {
|
|
1872
|
+
return { ...empty, providers }
|
|
1873
|
+
}
|
|
1874
|
+
const models = catalog.map(entry => entry.id).filter(id => typeof id === 'string')
|
|
1875
|
+
const imageCapable = catalog
|
|
1876
|
+
.filter(entry => (entry.inputModalities ?? []).includes('image'))
|
|
1877
|
+
.map(entry => entry.id)
|
|
1878
|
+
|
|
1879
|
+
const entry = catalog.find(candidate => candidate.id === model)
|
|
1880
|
+
if (entry === undefined) {
|
|
1881
|
+
return {
|
|
1882
|
+
problem: `The vision reviewer is configured for model "${model}", which provider "${provider}" does not serve. ` +
|
|
1883
|
+
`Set \`visualReviewModel\` to one of: ${models.join(', ') || '(the catalog is empty)'}.`,
|
|
1884
|
+
providers,
|
|
1885
|
+
models,
|
|
1886
|
+
imageCapable,
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (!(entry.inputModalities ?? []).includes('image')) {
|
|
1890
|
+
// THE QUIET HALF OF THIS FAILURE. A text-only model answers happily and never sees the sheet,
|
|
1891
|
+
// so the review comes back with zero findings — which downstream is indistinguishable from
|
|
1892
|
+
// "looked at it, found nothing wrong".
|
|
1893
|
+
return {
|
|
1894
|
+
problem: `The vision reviewer is configured for model "${model}", which does not accept images ` +
|
|
1895
|
+
`(inputModalities: ${JSON.stringify(entry.inputModalities ?? [])}). A review would silently omit the ` +
|
|
1896
|
+
`contact sheet. Set \`visualReviewModel\` to one of: ${imageCapable.join(', ') || '(none of the catalog accepts images)'}.`,
|
|
1897
|
+
providers,
|
|
1898
|
+
models,
|
|
1899
|
+
imageCapable,
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return { problem: null, providers, models, imageCapable }
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1723
1905
|
createVisualReviewer() {
|
|
1724
1906
|
const provider = this.config.visualReviewProvider
|
|
1725
1907
|
const model = this.config.visualReviewModel
|
|
@@ -1734,6 +1916,27 @@ export default class BlenderStudio extends Service {
|
|
|
1734
1916
|
)
|
|
1735
1917
|
}
|
|
1736
1918
|
|
|
1919
|
+
// THE ROUTE IS CHECKED BEFORE ANYTHING IS SPENT. MEASURED: the shipped default named a model
|
|
1920
|
+
// the provider had stopped serving, so every review ended in an HTTP 404 from inside the
|
|
1921
|
+
// stream — after the sheet had been uploaded — and the loop recorded "no second opinion" in a
|
|
1922
|
+
// note nobody read. A day of reviews ran that way with every suite green.
|
|
1923
|
+
//
|
|
1924
|
+
// Best-effort by design: a harness that does not expose a catalog (`listProviders`/
|
|
1925
|
+
// `listModels` are optional) still gets the model call and its own error handling. What this
|
|
1926
|
+
// adds is a refusal that NAMES the fix, at the one moment the caller can still do something.
|
|
1927
|
+
const catalogue = await this._visionRoute(provider, model)
|
|
1928
|
+
if (catalogue.problem !== null) {
|
|
1929
|
+
throw new BlenderError(BlenderErrorCode.VISUAL_REVIEW_MODEL_UNAVAILABLE, catalogue.problem, {
|
|
1930
|
+
detail: {
|
|
1931
|
+
provider,
|
|
1932
|
+
model,
|
|
1933
|
+
providers: catalogue.providers,
|
|
1934
|
+
models: catalogue.models,
|
|
1935
|
+
imageCapable: catalogue.imageCapable,
|
|
1936
|
+
},
|
|
1937
|
+
})
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1737
1940
|
const ref = await attachments.saveImage({
|
|
1738
1941
|
data: request.sheetPng,
|
|
1739
1942
|
mediaType: 'image/png',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-host",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@deepblend/dsh-blender-contracts": "0.2.
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.2.2"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"repository": {
|