@phamkhachoabk/dsh-vision-describe-mlx 0.1.0
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/README.md +48 -0
- package/lib/engine.d.ts +28 -0
- package/lib/engine.js +68 -0
- package/lib/index.d.ts +31 -0
- package/lib/index.js +55 -0
- package/lib/sidecar.d.ts +113 -0
- package/lib/sidecar.js +362 -0
- package/package.json +39 -0
- package/python/sidecar.py +119 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @phamkhachoabk/dsh-vision-describe-mlx
|
|
2
|
+
|
|
3
|
+
Service Provider for `ctx.visionDescribe`: a vision-language model held in
|
|
4
|
+
memory on this machine by [MLX](https://github.com/ml-explore/mlx), answering
|
|
5
|
+
what each image shows.
|
|
6
|
+
|
|
7
|
+
**Apple Silicon only.** MLX is built on Metal; there is no Linux or Intel
|
|
8
|
+
build, so this provider reports itself `unusable` everywhere else and the
|
|
9
|
+
harness keeps running without it.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
python3 -m pip install mlx-vlm
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The checkpoint downloads itself into the Hugging Face cache on first use —
|
|
18
|
+
about 2.5 GB for the default `mlx-community/Qwen3-VL-4B-Instruct-4bit`. Nothing
|
|
19
|
+
is downloaded until the plugin first starts, and nothing leaves the machine
|
|
20
|
+
afterwards.
|
|
21
|
+
|
|
22
|
+
Name a virtualenv's interpreter in `python` to keep it out of the system
|
|
23
|
+
environment.
|
|
24
|
+
|
|
25
|
+
## How it runs
|
|
26
|
+
|
|
27
|
+
A checkpoint costs seconds to load, so the process is long-lived: one
|
|
28
|
+
`python/sidecar.py` is spawned, loads the model once, and then answers NDJSON
|
|
29
|
+
requests on stdin for the life of the plugin. `warmUp()` starts it at boot so a
|
|
30
|
+
user's first image does not pay for the load.
|
|
31
|
+
|
|
32
|
+
A process that dies is replaced on the next call under a doubling backoff
|
|
33
|
+
(500 ms to 30 s, ten attempts). A request that outruns its budget takes the
|
|
34
|
+
process with it: the model generates inside a blocking call that cannot be
|
|
35
|
+
asked to stop, so an abandoned one is replaced rather than reused.
|
|
36
|
+
|
|
37
|
+
## Config
|
|
38
|
+
|
|
39
|
+
| Key | Default | Meaning |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `python` | `python3` | Interpreter owning the MLX install |
|
|
42
|
+
| `model` | `mlx-community/Qwen3-VL-4B-Instruct-4bit` | MLX model id or local checkpoint |
|
|
43
|
+
| `maxTokens` | `512` | Longest description for one image |
|
|
44
|
+
| `startupTimeoutMs` | `900000` | Budget for the first start, which may download the checkpoint |
|
|
45
|
+
| `warmUpOnStart` | `true` | Load the model in the background at boot |
|
|
46
|
+
|
|
47
|
+
A larger checkpoint answers better and costs more memory; `model` is the only
|
|
48
|
+
change needed to swap one in.
|
package/lib/engine.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The MLX vision-language engine. @module @phamkhachoabk/dsh-vision-describe-mlx */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import { VisionDescribeEngine, type EngineAvailability, type VisionDescribeAttempt, type VisionDescribeInput, type VisionDescribeSpec } from '@phamkhachoabk/dsh-vision-describe';
|
|
4
|
+
import { type MlxSidecarConfig } from './sidecar.ts';
|
|
5
|
+
/**
|
|
6
|
+
* Whole-image description from a vision-language model held in memory on this
|
|
7
|
+
* machine. Tier 1 because it is, today, the only engine that can answer the
|
|
8
|
+
* question at all; a provider for another platform registers beside it.
|
|
9
|
+
*/
|
|
10
|
+
export declare class MlxVisionDescribeEngine extends VisionDescribeEngine {
|
|
11
|
+
private readonly ctx;
|
|
12
|
+
private readonly config;
|
|
13
|
+
readonly id = "mlx-vlm";
|
|
14
|
+
readonly tier = 1;
|
|
15
|
+
private readonly sidecar;
|
|
16
|
+
constructor(ctx: Context, config: MlxSidecarConfig);
|
|
17
|
+
/** The checkpoint decides what an answer looks like, so it is the build identity. */
|
|
18
|
+
get version(): string;
|
|
19
|
+
probe(signal?: AbortSignal): Promise<EngineAvailability>;
|
|
20
|
+
/**
|
|
21
|
+
* Describe each image in turn. The model answers one request at a time, so
|
|
22
|
+
* issuing them serially is what keeps each image's budget meaningful.
|
|
23
|
+
*/
|
|
24
|
+
describe(inputs: readonly VisionDescribeInput[], spec: VisionDescribeSpec, signal: AbortSignal): Promise<readonly VisionDescribeAttempt[]>;
|
|
25
|
+
/** Load the model before a user's first image has to wait for it. */
|
|
26
|
+
warmUp(signal?: AbortSignal): Promise<void>;
|
|
27
|
+
private describeOne;
|
|
28
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The MLX vision-language engine. @module @phamkhachoabk/dsh-vision-describe-mlx */
|
|
2
|
+
import { VisionDescribeEngine, } from '@phamkhachoabk/dsh-vision-describe';
|
|
3
|
+
import { MlxSidecar, SidecarTimeout, probeRuntime } from "./sidecar.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whole-image description from a vision-language model held in memory on this
|
|
6
|
+
* machine. Tier 1 because it is, today, the only engine that can answer the
|
|
7
|
+
* question at all; a provider for another platform registers beside it.
|
|
8
|
+
*/
|
|
9
|
+
export class MlxVisionDescribeEngine extends VisionDescribeEngine {
|
|
10
|
+
ctx;
|
|
11
|
+
config;
|
|
12
|
+
id = 'mlx-vlm';
|
|
13
|
+
tier = 1;
|
|
14
|
+
sidecar;
|
|
15
|
+
constructor(ctx, config) {
|
|
16
|
+
super();
|
|
17
|
+
this.ctx = ctx;
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.sidecar = new MlxSidecar(ctx, config);
|
|
20
|
+
ctx.effect(() => () => { this.sidecar.stop(); });
|
|
21
|
+
}
|
|
22
|
+
/** The checkpoint decides what an answer looks like, so it is the build identity. */
|
|
23
|
+
get version() {
|
|
24
|
+
return this.config.model;
|
|
25
|
+
}
|
|
26
|
+
async probe(signal) {
|
|
27
|
+
return probeRuntime(this.ctx, this.config, signal);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Describe each image in turn. The model answers one request at a time, so
|
|
31
|
+
* issuing them serially is what keeps each image's budget meaningful.
|
|
32
|
+
*/
|
|
33
|
+
async describe(inputs, spec, signal) {
|
|
34
|
+
const attempts = [];
|
|
35
|
+
for (const input of inputs) {
|
|
36
|
+
attempts.push(await this.describeOne(input, spec, signal));
|
|
37
|
+
}
|
|
38
|
+
return attempts;
|
|
39
|
+
}
|
|
40
|
+
/** Load the model before a user's first image has to wait for it. */
|
|
41
|
+
async warmUp(signal) {
|
|
42
|
+
await this.sidecar.ensure(signal);
|
|
43
|
+
}
|
|
44
|
+
async describeOne(input, spec, signal) {
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const answer = await this.sidecar.request(input.path, spec.prompt, spec.perImageTimeoutMs, signal);
|
|
48
|
+
const description = answer.description.trim();
|
|
49
|
+
return {
|
|
50
|
+
attachmentId: input.attachmentId,
|
|
51
|
+
outcome: description === '' ? 'empty' : 'ok',
|
|
52
|
+
ms: answer.ms,
|
|
53
|
+
description,
|
|
54
|
+
warnings: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
attachmentId: input.attachmentId,
|
|
60
|
+
outcome: error instanceof SidecarTimeout ? 'timeout' : 'error',
|
|
61
|
+
ms: Date.now() - started,
|
|
62
|
+
description: '',
|
|
63
|
+
warnings: [],
|
|
64
|
+
detail: String(error),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MLX vision-description provider. Registers one engine on `ctx.visionDescribe`
|
|
3
|
+
* backed by a vision-language model kept loaded on this machine.
|
|
4
|
+
*
|
|
5
|
+
* @module @phamkhachoabk/dsh-vision-describe-mlx
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
export { MlxVisionDescribeEngine } from './engine.ts';
|
|
10
|
+
export { MlxSidecar, SidecarTimeout, probeRuntime, SIDECAR_SCRIPT } from './sidecar.ts';
|
|
11
|
+
export type { MlxSidecarConfig, SidecarReply } from './sidecar.ts';
|
|
12
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
13
|
+
export declare const name = "vision-describe-mlx";
|
|
14
|
+
/** `ctx.visionDescribe` owns the ladder; `ctx.subprocess` runs the model in the host world. */
|
|
15
|
+
export declare const inject: readonly ["visionDescribe", "subprocess"];
|
|
16
|
+
export interface Config {
|
|
17
|
+
python: string;
|
|
18
|
+
model: string;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
graceMs: number;
|
|
21
|
+
stderrMaxBytes: number;
|
|
22
|
+
startupTimeoutMs: number;
|
|
23
|
+
warmUpOnStart: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare const Config: z<Config>;
|
|
26
|
+
/**
|
|
27
|
+
* Register the engine for the lifetime of `ctx`.
|
|
28
|
+
* @param ctx - plugin context; the registration and the process are disposed with it.
|
|
29
|
+
* @param config - interpreter, model, and process settings.
|
|
30
|
+
*/
|
|
31
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MLX vision-description provider. Registers one engine on `ctx.visionDescribe`
|
|
3
|
+
* backed by a vision-language model kept loaded on this machine.
|
|
4
|
+
*
|
|
5
|
+
* @module @phamkhachoabk/dsh-vision-describe-mlx
|
|
6
|
+
*/
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { MlxVisionDescribeEngine } from "./engine.js";
|
|
9
|
+
export { MlxVisionDescribeEngine } from "./engine.js";
|
|
10
|
+
export { MlxSidecar, SidecarTimeout, probeRuntime, SIDECAR_SCRIPT } from "./sidecar.js";
|
|
11
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
12
|
+
export const name = 'vision-describe-mlx';
|
|
13
|
+
/** `ctx.visionDescribe` owns the ladder; `ctx.subprocess` runs the model in the host world. */
|
|
14
|
+
export const inject = ['visionDescribe', 'subprocess'];
|
|
15
|
+
export const Config = z.object({
|
|
16
|
+
python: z.string().default('python3')
|
|
17
|
+
.description('Interpreter owning the MLX install; name a virtualenv\'s python to use it.'),
|
|
18
|
+
model: z.string().default('mlx-community/Qwen3-VL-4B-Instruct-4bit')
|
|
19
|
+
.description('MLX model id or local checkpoint path. Downloaded on first use.'),
|
|
20
|
+
maxTokens: z.number().step(1).min(16).default(512)
|
|
21
|
+
.description('Longest description the model may produce for one image.'),
|
|
22
|
+
graceMs: z.number().step(1).min(1).default(2000)
|
|
23
|
+
.description('SIGTERM to SIGKILL window for the sidecar process tree.'),
|
|
24
|
+
stderrMaxBytes: z.number().step(1).min(1024).default(64 * 1024)
|
|
25
|
+
.description('Diagnostic tail retained from the sidecar stderr.'),
|
|
26
|
+
startupTimeoutMs: z.number().step(1).min(1000).default(900_000)
|
|
27
|
+
.description('Budget for the first start, which downloads the checkpoint if the Hugging Face '
|
|
28
|
+
+ 'cache does not already hold it. Later starts only reload from disk.'),
|
|
29
|
+
warmUpOnStart: z.boolean().default(true)
|
|
30
|
+
.description('Load the model in the background when the plugin starts.'),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* Register the engine for the lifetime of `ctx`.
|
|
34
|
+
* @param ctx - plugin context; the registration and the process are disposed with it.
|
|
35
|
+
* @param config - interpreter, model, and process settings.
|
|
36
|
+
*/
|
|
37
|
+
export function apply(ctx, config) {
|
|
38
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
39
|
+
// Fail-closed and loud once: the harness keeps running without image
|
|
40
|
+
// descriptions rather than refusing to boot over an optional capability.
|
|
41
|
+
ctx.logger.warn('vision-describe-mlx: MLX runs on Apple Silicon only, '
|
|
42
|
+
+ `not ${process.platform}-${process.arch} — image description disabled`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const engine = new MlxVisionDescribeEngine(ctx, config);
|
|
46
|
+
ctx.effect(() => ctx.visionDescribe.registerEngine(engine));
|
|
47
|
+
if (!config.warmUpOnStart)
|
|
48
|
+
return;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
ctx.effect(() => () => { controller.abort(); });
|
|
51
|
+
// Deliberately not awaited: booting must not block on a checkpoint download.
|
|
52
|
+
void ctx.visionDescribe.warmUp(controller.signal).catch((error) => {
|
|
53
|
+
ctx.logger.warn(`vision-describe-mlx: warm-up failed: ${String(error)}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
package/lib/sidecar.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** The persistent MLX sidecar process. @module @phamkhachoabk/dsh-vision-describe-mlx */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/**
|
|
4
|
+
* Path of the bundled Python entry point. It ships as source rather than as a
|
|
5
|
+
* per-platform binary: there is nothing to compile, and the heavy parts (MLX,
|
|
6
|
+
* the model weights) live in the user's own Python environment and Hugging
|
|
7
|
+
* Face cache, not in this package.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SIDECAR_SCRIPT: string;
|
|
10
|
+
/** One answered request. */
|
|
11
|
+
export interface SidecarReply {
|
|
12
|
+
description: string;
|
|
13
|
+
ms: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A request that outran its budget. The model generates inside a blocking call
|
|
17
|
+
* the process cannot be asked to abandon, so this always ends with the process
|
|
18
|
+
* being replaced rather than reused.
|
|
19
|
+
*/
|
|
20
|
+
export declare class SidecarTimeout extends Error {
|
|
21
|
+
constructor(ms: number);
|
|
22
|
+
}
|
|
23
|
+
export interface MlxSidecarConfig {
|
|
24
|
+
/** Interpreter that owns the MLX install; `python3` unless a venv is named. */
|
|
25
|
+
python: string;
|
|
26
|
+
/** MLX model id or local checkpoint path. */
|
|
27
|
+
model: string;
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
/** SIGTERM → grace → SIGKILL window for the process tree. */
|
|
30
|
+
graceMs: number;
|
|
31
|
+
/** Bounded diagnostic tail kept from the sidecar's stderr. */
|
|
32
|
+
stderrMaxBytes: number;
|
|
33
|
+
/** Budget for import, checkpoint download and model load on first start. */
|
|
34
|
+
startupTimeoutMs: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Ask the bundled script whether its runtime exists, without loading a model.
|
|
38
|
+
*
|
|
39
|
+
* Exit 3 is the script's word for "mlx_vlm is not importable", which is the
|
|
40
|
+
* one failure a user can act on, so it is reported in those terms rather than
|
|
41
|
+
* as a bare exit code.
|
|
42
|
+
*
|
|
43
|
+
* @param ctx - plugin context supplying `ctx.subprocess`.
|
|
44
|
+
* @param config - interpreter, model and diagnostic limits.
|
|
45
|
+
* @param signal - cancellation for the probe.
|
|
46
|
+
* @returns readiness, or the reason this machine cannot run the sidecar.
|
|
47
|
+
*/
|
|
48
|
+
export declare function probeRuntime(ctx: Context, config: MlxSidecarConfig, signal?: AbortSignal): Promise<{
|
|
49
|
+
kind: 'ready';
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'unusable';
|
|
52
|
+
reason: string;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Owns one long-lived model process.
|
|
56
|
+
*
|
|
57
|
+
* Every call reuses the same process, so the checkpoint is loaded once for the
|
|
58
|
+
* life of the plugin rather than once per image. A process that dies is
|
|
59
|
+
* replaced on the next call under a bounded backoff, and one that has failed
|
|
60
|
+
* {@link MAX_RESTARTS} times in a row is left alone until the plugin reloads:
|
|
61
|
+
* a machine without the runtime should say so, not respawn forever.
|
|
62
|
+
*/
|
|
63
|
+
export declare class MlxSidecar {
|
|
64
|
+
private readonly ctx;
|
|
65
|
+
private readonly config;
|
|
66
|
+
private handle;
|
|
67
|
+
private controller;
|
|
68
|
+
private starting;
|
|
69
|
+
private readonly waiting;
|
|
70
|
+
/** Tail of the in-flight chain; see {@link request}. */
|
|
71
|
+
private queue;
|
|
72
|
+
private sequence;
|
|
73
|
+
private restarts;
|
|
74
|
+
private blockedUntil;
|
|
75
|
+
private lastFailure?;
|
|
76
|
+
private stopped;
|
|
77
|
+
constructor(ctx: Context, config: MlxSidecarConfig);
|
|
78
|
+
/** Whether a process is currently loaded and answering. */
|
|
79
|
+
get running(): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Ensure a loaded process exists, starting one if needed.
|
|
82
|
+
* @param signal - cancellation for a start that is under way.
|
|
83
|
+
* @throws when the runtime is missing, the model cannot load, or the backoff
|
|
84
|
+
* window has not elapsed.
|
|
85
|
+
*/
|
|
86
|
+
ensure(signal?: AbortSignal): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Describe one image on the loaded process.
|
|
89
|
+
* @param path - absolute host path of the image.
|
|
90
|
+
* @param prompt - the resolved instruction.
|
|
91
|
+
* @param timeoutMs - budget for this one image.
|
|
92
|
+
* @param signal - cancellation for the whole call.
|
|
93
|
+
* @returns the model's description and the time it took.
|
|
94
|
+
* @throws {@link SidecarTimeout} when the budget elapses, or an `Error` when
|
|
95
|
+
* the process could not be started or died mid-request.
|
|
96
|
+
*/
|
|
97
|
+
request(path: string, prompt: string, timeoutMs: number, signal: AbortSignal): Promise<SidecarReply>;
|
|
98
|
+
private send;
|
|
99
|
+
/** Terminate the process and fail everything still waiting. Safe to call twice. */
|
|
100
|
+
stop(): void;
|
|
101
|
+
private replace;
|
|
102
|
+
private start;
|
|
103
|
+
private noteFailure;
|
|
104
|
+
/**
|
|
105
|
+
* Start the dispatch loop and resolve once the model reports itself loaded.
|
|
106
|
+
* The loop keeps running for the life of the process, routing each later
|
|
107
|
+
* line to whoever is waiting on that id.
|
|
108
|
+
*/
|
|
109
|
+
private readUntilReady;
|
|
110
|
+
private deliver;
|
|
111
|
+
/** A process that exits on its own leaves the next call to start a new one. */
|
|
112
|
+
private onExit;
|
|
113
|
+
}
|
package/lib/sidecar.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/** The persistent MLX sidecar process. @module @phamkhachoabk/dsh-vision-describe-mlx */
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
/**
|
|
8
|
+
* Path of the bundled Python entry point. It ships as source rather than as a
|
|
9
|
+
* per-platform binary: there is nothing to compile, and the heavy parts (MLX,
|
|
10
|
+
* the model weights) live in the user's own Python environment and Hugging
|
|
11
|
+
* Face cache, not in this package.
|
|
12
|
+
*/
|
|
13
|
+
export const SIDECAR_SCRIPT = join(dirname(require.resolve('@phamkhachoabk/dsh-vision-describe-mlx/package.json')), 'python', 'sidecar.py');
|
|
14
|
+
const readyEvent = z.object({
|
|
15
|
+
event: z.literal('ready'),
|
|
16
|
+
model: z.string(),
|
|
17
|
+
ms: z.number(),
|
|
18
|
+
});
|
|
19
|
+
const errorEvent = z.object({
|
|
20
|
+
event: z.literal('error'),
|
|
21
|
+
detail: z.string(),
|
|
22
|
+
});
|
|
23
|
+
const reply = z.object({
|
|
24
|
+
id: z.string().nullish(),
|
|
25
|
+
ok: z.boolean(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
error: z.string().optional(),
|
|
28
|
+
ms: z.number().optional(),
|
|
29
|
+
});
|
|
30
|
+
const sidecarLine = z.union([readyEvent, errorEvent, reply]);
|
|
31
|
+
/**
|
|
32
|
+
* A request that outran its budget. The model generates inside a blocking call
|
|
33
|
+
* the process cannot be asked to abandon, so this always ends with the process
|
|
34
|
+
* being replaced rather than reused.
|
|
35
|
+
*/
|
|
36
|
+
export class SidecarTimeout extends Error {
|
|
37
|
+
constructor(ms) {
|
|
38
|
+
super(`the model did not answer within ${String(ms)}ms`);
|
|
39
|
+
this.name = 'SidecarTimeout';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Ask the bundled script whether its runtime exists, without loading a model.
|
|
44
|
+
*
|
|
45
|
+
* Exit 3 is the script's word for "mlx_vlm is not importable", which is the
|
|
46
|
+
* one failure a user can act on, so it is reported in those terms rather than
|
|
47
|
+
* as a bare exit code.
|
|
48
|
+
*
|
|
49
|
+
* @param ctx - plugin context supplying `ctx.subprocess`.
|
|
50
|
+
* @param config - interpreter, model and diagnostic limits.
|
|
51
|
+
* @param signal - cancellation for the probe.
|
|
52
|
+
* @returns readiness, or the reason this machine cannot run the sidecar.
|
|
53
|
+
*/
|
|
54
|
+
export async function probeRuntime(ctx, config, signal) {
|
|
55
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'unusable',
|
|
58
|
+
reason: `MLX runs on Apple Silicon only, not ${process.platform}-${process.arch}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const handle = ctx.subprocess.spawn({
|
|
63
|
+
argv: [config.python, SIDECAR_SCRIPT, '--model', config.model, '--probe'],
|
|
64
|
+
cwd: tmpdir(),
|
|
65
|
+
stdio: {
|
|
66
|
+
stdin: 'ignore',
|
|
67
|
+
stdout: { maxBytes: 4096 },
|
|
68
|
+
stderr: { maxBytes: config.stderrMaxBytes },
|
|
69
|
+
},
|
|
70
|
+
graceMs: config.graceMs,
|
|
71
|
+
...(signal === undefined ? {} : { signal }),
|
|
72
|
+
});
|
|
73
|
+
const outcome = await handle.done;
|
|
74
|
+
if (outcome.exitCode === 0)
|
|
75
|
+
return { kind: 'ready' };
|
|
76
|
+
const tail = handle.collected.stderr?.readFrom(0).text.trim() ?? '';
|
|
77
|
+
if (outcome.exitCode === 3) {
|
|
78
|
+
return {
|
|
79
|
+
kind: 'unusable',
|
|
80
|
+
reason: `${config.python} cannot import mlx_vlm — install it with `
|
|
81
|
+
+ `\`${config.python} -m pip install mlx-vlm\``,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
kind: 'unusable',
|
|
86
|
+
reason: `${config.python} could not run the sidecar (exit ${String(outcome.exitCode)})`
|
|
87
|
+
+ (tail === '' ? '' : `: ${tail}`),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return { kind: 'unusable', reason: `${config.python} could not be spawned: ${String(error)}` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Restart policy, mirroring the one the harness's MCP client uses for its servers. */
|
|
95
|
+
const BACKOFF_BASE_MS = 500;
|
|
96
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
97
|
+
const MAX_RESTARTS = 10;
|
|
98
|
+
/**
|
|
99
|
+
* Owns one long-lived model process.
|
|
100
|
+
*
|
|
101
|
+
* Every call reuses the same process, so the checkpoint is loaded once for the
|
|
102
|
+
* life of the plugin rather than once per image. A process that dies is
|
|
103
|
+
* replaced on the next call under a bounded backoff, and one that has failed
|
|
104
|
+
* {@link MAX_RESTARTS} times in a row is left alone until the plugin reloads:
|
|
105
|
+
* a machine without the runtime should say so, not respawn forever.
|
|
106
|
+
*/
|
|
107
|
+
export class MlxSidecar {
|
|
108
|
+
ctx;
|
|
109
|
+
config;
|
|
110
|
+
handle;
|
|
111
|
+
controller;
|
|
112
|
+
starting;
|
|
113
|
+
waiting = new Map();
|
|
114
|
+
/** Tail of the in-flight chain; see {@link request}. */
|
|
115
|
+
queue = Promise.resolve();
|
|
116
|
+
sequence = 0;
|
|
117
|
+
restarts = 0;
|
|
118
|
+
blockedUntil = 0;
|
|
119
|
+
lastFailure;
|
|
120
|
+
stopped = false;
|
|
121
|
+
constructor(ctx, config) {
|
|
122
|
+
this.ctx = ctx;
|
|
123
|
+
this.config = config;
|
|
124
|
+
}
|
|
125
|
+
/** Whether a process is currently loaded and answering. */
|
|
126
|
+
get running() {
|
|
127
|
+
return this.handle !== undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Ensure a loaded process exists, starting one if needed.
|
|
131
|
+
* @param signal - cancellation for a start that is under way.
|
|
132
|
+
* @throws when the runtime is missing, the model cannot load, or the backoff
|
|
133
|
+
* window has not elapsed.
|
|
134
|
+
*/
|
|
135
|
+
async ensure(signal) {
|
|
136
|
+
if (this.stopped)
|
|
137
|
+
throw new Error('the sidecar was stopped');
|
|
138
|
+
if (this.handle !== undefined)
|
|
139
|
+
return;
|
|
140
|
+
if (this.restarts >= MAX_RESTARTS) {
|
|
141
|
+
throw new Error(`gave up after ${String(MAX_RESTARTS)} failed starts: ${this.lastFailure ?? 'no detail'}`);
|
|
142
|
+
}
|
|
143
|
+
const wait = this.blockedUntil - Date.now();
|
|
144
|
+
if (wait > 0) {
|
|
145
|
+
throw new Error(`still in the restart backoff for ${String(wait)}ms: ${this.lastFailure ?? 'no detail'}`);
|
|
146
|
+
}
|
|
147
|
+
this.starting ??= this.start(signal).finally(() => { this.starting = undefined; });
|
|
148
|
+
await this.starting;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Describe one image on the loaded process.
|
|
152
|
+
* @param path - absolute host path of the image.
|
|
153
|
+
* @param prompt - the resolved instruction.
|
|
154
|
+
* @param timeoutMs - budget for this one image.
|
|
155
|
+
* @param signal - cancellation for the whole call.
|
|
156
|
+
* @returns the model's description and the time it took.
|
|
157
|
+
* @throws {@link SidecarTimeout} when the budget elapses, or an `Error` when
|
|
158
|
+
* the process could not be started or died mid-request.
|
|
159
|
+
*/
|
|
160
|
+
async request(path, prompt, timeoutMs, signal) {
|
|
161
|
+
// One at a time. The model answers serially, so a second caller's budget
|
|
162
|
+
// must not start ticking while the first is still generating — a queued
|
|
163
|
+
// request would time out and take the running one's process with it.
|
|
164
|
+
const turn = this.queue.then(async () => this.send(path, prompt, timeoutMs, signal));
|
|
165
|
+
this.queue = turn.then(() => undefined, () => undefined);
|
|
166
|
+
return turn;
|
|
167
|
+
}
|
|
168
|
+
async send(path, prompt, timeoutMs, signal) {
|
|
169
|
+
if (signal.aborted)
|
|
170
|
+
throw new Error('cancelled');
|
|
171
|
+
await this.ensure(signal);
|
|
172
|
+
const handle = this.handle;
|
|
173
|
+
if (handle?.stdin === undefined)
|
|
174
|
+
throw new Error('the sidecar has no stdin to write to');
|
|
175
|
+
this.sequence += 1;
|
|
176
|
+
const id = String(this.sequence);
|
|
177
|
+
const answered = new Promise((resolve, reject) => {
|
|
178
|
+
this.waiting.set(id, { resolve, reject });
|
|
179
|
+
});
|
|
180
|
+
handle.stdin.write(`${JSON.stringify({
|
|
181
|
+
id, path, prompt, maxTokens: this.config.maxTokens,
|
|
182
|
+
})}\n`);
|
|
183
|
+
let timer;
|
|
184
|
+
const expired = new Promise((_resolve, reject) => {
|
|
185
|
+
timer = setTimeout(() => { reject(new SidecarTimeout(timeoutMs)); }, timeoutMs);
|
|
186
|
+
});
|
|
187
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
188
|
+
if (signal.aborted) {
|
|
189
|
+
reject(new Error('cancelled'));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
signal.addEventListener('abort', () => { reject(new Error('cancelled')); }, { once: true });
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
return await Promise.race([answered, expired, aborted]);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// A process mid-generation cannot be handed the next request, so an
|
|
199
|
+
// abandoned one is replaced rather than reused.
|
|
200
|
+
this.replace(String(error));
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
if (timer !== undefined)
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
this.waiting.delete(id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Terminate the process and fail everything still waiting. Safe to call twice. */
|
|
210
|
+
stop() {
|
|
211
|
+
this.stopped = true;
|
|
212
|
+
this.replace('the sidecar was stopped');
|
|
213
|
+
}
|
|
214
|
+
replace(reason) {
|
|
215
|
+
const handle = this.handle;
|
|
216
|
+
this.handle = undefined;
|
|
217
|
+
this.controller?.abort();
|
|
218
|
+
this.controller = undefined;
|
|
219
|
+
handle?.terminate();
|
|
220
|
+
for (const [id, waiter] of this.waiting) {
|
|
221
|
+
this.waiting.delete(id);
|
|
222
|
+
waiter.reject(new Error(reason));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async start(signal) {
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
if (signal !== undefined) {
|
|
228
|
+
signal.addEventListener('abort', () => { controller.abort(); }, { once: true });
|
|
229
|
+
}
|
|
230
|
+
const handle = this.ctx.subprocess.spawn({
|
|
231
|
+
argv: [this.config.python, SIDECAR_SCRIPT, '--model', this.config.model],
|
|
232
|
+
cwd: tmpdir(),
|
|
233
|
+
stdio: {
|
|
234
|
+
stdin: 'pipe',
|
|
235
|
+
stdout: 'pipe',
|
|
236
|
+
stderr: { maxBytes: this.config.stderrMaxBytes },
|
|
237
|
+
},
|
|
238
|
+
graceMs: this.config.graceMs,
|
|
239
|
+
signal: controller.signal,
|
|
240
|
+
});
|
|
241
|
+
const loaded = this.readUntilReady(handle);
|
|
242
|
+
let timer;
|
|
243
|
+
const expired = new Promise((_resolve, reject) => {
|
|
244
|
+
timer = setTimeout(() => { reject(new Error(`the model did not load within ${String(this.config.startupTimeoutMs)}ms`)); }, this.config.startupTimeoutMs);
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
await Promise.race([loaded, expired]);
|
|
248
|
+
this.handle = handle;
|
|
249
|
+
this.controller = controller;
|
|
250
|
+
this.restarts = 0;
|
|
251
|
+
this.blockedUntil = 0;
|
|
252
|
+
delete this.lastFailure;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
controller.abort();
|
|
256
|
+
handle.terminate();
|
|
257
|
+
this.noteFailure(String(error));
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
if (timer !== undefined)
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
noteFailure(detail) {
|
|
266
|
+
this.restarts += 1;
|
|
267
|
+
this.lastFailure = detail;
|
|
268
|
+
const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.restarts - 1), BACKOFF_MAX_MS);
|
|
269
|
+
this.blockedUntil = Date.now() + backoff;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Start the dispatch loop and resolve once the model reports itself loaded.
|
|
273
|
+
* The loop keeps running for the life of the process, routing each later
|
|
274
|
+
* line to whoever is waiting on that id.
|
|
275
|
+
*/
|
|
276
|
+
async readUntilReady(handle) {
|
|
277
|
+
const stream = handle.stdout;
|
|
278
|
+
if (stream === undefined)
|
|
279
|
+
throw new Error('the sidecar has no stdout to read from');
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
let settled = false;
|
|
282
|
+
const fail = (error) => {
|
|
283
|
+
if (settled)
|
|
284
|
+
return;
|
|
285
|
+
settled = true;
|
|
286
|
+
reject(error);
|
|
287
|
+
};
|
|
288
|
+
const pump = async () => {
|
|
289
|
+
let buffer = '';
|
|
290
|
+
for await (const chunk of stream) {
|
|
291
|
+
buffer += String(chunk);
|
|
292
|
+
let newline = buffer.indexOf('\n');
|
|
293
|
+
while (newline !== -1) {
|
|
294
|
+
const line = buffer.slice(0, newline).trim();
|
|
295
|
+
buffer = buffer.slice(newline + 1);
|
|
296
|
+
if (line !== '') {
|
|
297
|
+
const parsed = sidecarLine.safeParse(JSON.parse(line));
|
|
298
|
+
if (!parsed.success) {
|
|
299
|
+
fail(new Error(`unparsable sidecar line: ${line.slice(0, 200)}`));
|
|
300
|
+
}
|
|
301
|
+
else if ('event' in parsed.data) {
|
|
302
|
+
if (parsed.data.event === 'error') {
|
|
303
|
+
fail(new Error(parsed.data.detail));
|
|
304
|
+
}
|
|
305
|
+
else if (!settled) {
|
|
306
|
+
settled = true;
|
|
307
|
+
resolve();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
this.deliver(parsed.data);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
newline = buffer.indexOf('\n');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
void pump().catch((error) => { fail(new Error(String(error))); });
|
|
319
|
+
void handle.done.then((outcome) => {
|
|
320
|
+
const tail = handle.collected.stderr?.readFrom(0).text.trim() ?? '';
|
|
321
|
+
const detail = `sidecar exited with code ${String(outcome.exitCode)}`
|
|
322
|
+
+ (tail === '' ? '' : `: ${tail}`);
|
|
323
|
+
fail(new Error(detail));
|
|
324
|
+
this.onExit(detail);
|
|
325
|
+
}, (error) => {
|
|
326
|
+
fail(new Error(String(error)));
|
|
327
|
+
this.onExit(String(error));
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
deliver(message) {
|
|
332
|
+
const id = message.id ?? undefined;
|
|
333
|
+
if (id === undefined) {
|
|
334
|
+
this.ctx.logger.warn(`vision-describe-mlx: sidecar answered without an id: ${message.error ?? ''}`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const waiter = this.waiting.get(id);
|
|
338
|
+
if (waiter === undefined)
|
|
339
|
+
return;
|
|
340
|
+
this.waiting.delete(id);
|
|
341
|
+
if (message.ok && message.description !== undefined) {
|
|
342
|
+
waiter.resolve({ description: message.description, ms: message.ms ?? 0 });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
waiter.reject(new Error(message.error ?? 'the sidecar reported a failure with no detail'));
|
|
346
|
+
}
|
|
347
|
+
/** A process that exits on its own leaves the next call to start a new one. */
|
|
348
|
+
onExit(detail) {
|
|
349
|
+
if (this.handle === undefined)
|
|
350
|
+
return;
|
|
351
|
+
this.handle = undefined;
|
|
352
|
+
this.controller = undefined;
|
|
353
|
+
for (const [id, waiter] of this.waiting) {
|
|
354
|
+
this.waiting.delete(id);
|
|
355
|
+
waiter.reject(new Error(detail));
|
|
356
|
+
}
|
|
357
|
+
if (this.stopped)
|
|
358
|
+
return;
|
|
359
|
+
this.noteFailure(detail);
|
|
360
|
+
this.ctx.logger.warn(`vision-describe-mlx: ${detail}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phamkhachoabk/dsh-vision-describe-mlx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local vision-description provider for DeepSeek Harness: a persistent MLX vision-language model on Apple Silicon, so a text-only model can be told what an image shows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib/",
|
|
17
|
+
"python/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"zod": "^4.4.3",
|
|
26
|
+
"@phamkhachoabk/dsh-vision-describe": "^0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
30
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
|
|
31
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
35
|
+
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
|
|
37
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persistent MLX vision-language sidecar for @phamkhachoabk/dsh-vision-describe-mlx.
|
|
3
|
+
|
|
4
|
+
Loads one vision-language model into memory and then answers NDJSON requests on
|
|
5
|
+
stdin with NDJSON responses on stdout, for as long as the parent keeps it alive.
|
|
6
|
+
Loading a multi-gigabyte checkpoint costs seconds, so the process is long-lived
|
|
7
|
+
by design: the parent pays that once, not once per image.
|
|
8
|
+
|
|
9
|
+
Protocol (one JSON object per line, both directions):
|
|
10
|
+
<- {"id": "1", "path": "/abs/image.png", "prompt": "...", "maxTokens": 512}
|
|
11
|
+
-> {"id": "1", "ok": true, "description": "...", "ms": 1234}
|
|
12
|
+
-> {"id": "1", "ok": false, "error": "..."}
|
|
13
|
+
Startup emits {"event": "ready", "model": "...", "ms": 1234} once, or exits.
|
|
14
|
+
|
|
15
|
+
Exit codes: 2 usage, 3 runtime missing (mlx_vlm not importable), 4 model load
|
|
16
|
+
failed. The parent turns each into a distinct `unusable` reason.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def emit(payload):
|
|
27
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
28
|
+
sys.stdout.flush()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fail(code, detail):
|
|
32
|
+
emit({"event": "error", "detail": detail})
|
|
33
|
+
sys.exit(code)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_args(argv):
|
|
37
|
+
parser = argparse.ArgumentParser(prog="dsh-vision-describe-mlx", add_help=True)
|
|
38
|
+
parser.add_argument("--model", required=True, help="MLX model id or local path")
|
|
39
|
+
parser.add_argument("--probe", action="store_true", help="load nothing; report importability and exit")
|
|
40
|
+
try:
|
|
41
|
+
return parser.parse_args(argv)
|
|
42
|
+
except SystemExit:
|
|
43
|
+
raise SystemExit(2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv):
|
|
47
|
+
args = parse_args(argv)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import mlx_vlm
|
|
51
|
+
from mlx_vlm import generate, load
|
|
52
|
+
from mlx_vlm.prompt_utils import apply_chat_template
|
|
53
|
+
from mlx_vlm.utils import load_config
|
|
54
|
+
except Exception as error: # noqa: BLE001 - any import failure means the same thing
|
|
55
|
+
fail(3, f"mlx_vlm is not importable: {error}")
|
|
56
|
+
|
|
57
|
+
version = getattr(mlx_vlm, "__version__", "unknown")
|
|
58
|
+
if args.probe:
|
|
59
|
+
emit({"event": "ready", "model": args.model, "ms": 0, "probe": True, "mlxVlm": version})
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
started = time.monotonic()
|
|
63
|
+
try:
|
|
64
|
+
model, processor = load(args.model)
|
|
65
|
+
config = load_config(args.model)
|
|
66
|
+
except Exception as error: # noqa: BLE001 - a download or checkpoint failure reads alike
|
|
67
|
+
fail(4, f"could not load {args.model}: {error}")
|
|
68
|
+
emit({
|
|
69
|
+
"event": "ready",
|
|
70
|
+
"model": args.model,
|
|
71
|
+
"ms": int((time.monotonic() - started) * 1000),
|
|
72
|
+
"mlxVlm": version,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
for line in sys.stdin:
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
request = json.loads(line)
|
|
81
|
+
except ValueError as error:
|
|
82
|
+
emit({"id": None, "ok": False, "error": f"unparsable request: {error}"})
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
request_id = request.get("id")
|
|
86
|
+
began = time.monotonic()
|
|
87
|
+
try:
|
|
88
|
+
path = request["path"]
|
|
89
|
+
if not os.path.isfile(path):
|
|
90
|
+
raise FileNotFoundError(path)
|
|
91
|
+
formatted = apply_chat_template(
|
|
92
|
+
processor, config, request["prompt"], num_images=1,
|
|
93
|
+
)
|
|
94
|
+
# `generate` opens the image itself and wants paths, not pixels.
|
|
95
|
+
output = generate(
|
|
96
|
+
model, processor, formatted, image=[path],
|
|
97
|
+
max_tokens=int(request.get("maxTokens", 512)), verbose=False,
|
|
98
|
+
)
|
|
99
|
+
# 0.7 returns a GenerationResult carrying the text plus usage stats.
|
|
100
|
+
text = getattr(output, "text", output)
|
|
101
|
+
emit({
|
|
102
|
+
"id": request_id,
|
|
103
|
+
"ok": True,
|
|
104
|
+
"description": str(text).strip(),
|
|
105
|
+
"ms": int((time.monotonic() - began) * 1000),
|
|
106
|
+
})
|
|
107
|
+
except Exception as error: # noqa: BLE001 - one bad image must not end the process
|
|
108
|
+
emit({
|
|
109
|
+
"id": request_id,
|
|
110
|
+
"ok": False,
|
|
111
|
+
"error": f"{type(error).__name__}: {error}",
|
|
112
|
+
"ms": int((time.monotonic() - began) * 1000),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
sys.exit(main(sys.argv[1:]))
|