Mayo commited on
Commit
8b4809d
·
unverified ·
1 Parent(s): e5a161e

feat: llama.cpp-based paddleocr-vl-1.5

Browse files
Cargo.lock CHANGED
@@ -3947,6 +3947,7 @@ dependencies = [
3947
  "flate2",
3948
  "futures",
3949
  "hf-hub",
 
3950
  "keyring",
3951
  "koharu-http",
3952
  "koharu-runtime",
@@ -3987,6 +3988,7 @@ dependencies = [
3987
  "imageproc",
3988
  "keyring",
3989
  "koharu-http",
 
3990
  "koharu-types",
3991
  "libloading 0.8.9",
3992
  "minijinja",
 
3947
  "flate2",
3948
  "futures",
3949
  "hf-hub",
3950
+ "image",
3951
  "keyring",
3952
  "koharu-http",
3953
  "koharu-runtime",
 
3988
  "imageproc",
3989
  "keyring",
3990
  "koharu-http",
3991
+ "koharu-llm",
3992
  "koharu-types",
3993
  "libloading 0.8.9",
3994
  "minijinja",
koharu-llm/Cargo.toml CHANGED
@@ -22,6 +22,10 @@ path = "bin/llama.rs"
22
  name = "llm"
23
  path = "bin/llm.rs"
24
 
 
 
 
 
25
  [dependencies]
26
  anyhow = { workspace = true }
27
  clap = { workspace = true, features = ["derive"] }
@@ -29,6 +33,7 @@ dirs = { workspace = true }
29
  flate2 = { workspace = true }
30
  futures = { workspace = true }
31
  hf-hub = { workspace = true }
 
32
  koharu-http = { workspace = true }
33
  koharu-runtime = { workspace = true }
34
  koharu-types = { workspace = true }
 
22
  name = "llm"
23
  path = "bin/llm.rs"
24
 
25
+ [[bin]]
26
+ name = "paddleocr-vl-llm"
27
+ path = "bin/paddleocr-vl.rs"
28
+
29
  [dependencies]
30
  anyhow = { workspace = true }
31
  clap = { workspace = true, features = ["derive"] }
 
33
  flate2 = { workspace = true }
34
  futures = { workspace = true }
35
  hf-hub = { workspace = true }
36
+ image = { workspace = true }
37
  koharu-http = { workspace = true }
38
  koharu-runtime = { workspace = true }
39
  koharu-types = { workspace = true }
koharu-llm/bin/paddleocr-vl.rs ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::{Path, PathBuf};
2
+ use std::time::Instant;
3
+
4
+ use anyhow::{Context, Result, bail};
5
+ use clap::{Parser, ValueEnum};
6
+ use koharu_llm::paddleocr_vl::{PaddleOcrVl, PaddleOcrVlOutput, PaddleOcrVlTask};
7
+
8
+ #[derive(Debug, Clone, Copy, ValueEnum)]
9
+ enum TaskArg {
10
+ Ocr,
11
+ Table,
12
+ Formula,
13
+ Chart,
14
+ Spotting,
15
+ Seal,
16
+ }
17
+
18
+ impl From<TaskArg> for PaddleOcrVlTask {
19
+ fn from(value: TaskArg) -> Self {
20
+ match value {
21
+ TaskArg::Ocr => Self::Ocr,
22
+ TaskArg::Table => Self::Table,
23
+ TaskArg::Formula => Self::Formula,
24
+ TaskArg::Chart => Self::Chart,
25
+ TaskArg::Spotting => Self::Spotting,
26
+ TaskArg::Seal => Self::Seal,
27
+ }
28
+ }
29
+ }
30
+
31
+ #[derive(Parser)]
32
+ struct Cli {
33
+ #[arg(long, value_name = "FILE", num_args = 1..)]
34
+ input: Vec<PathBuf>,
35
+
36
+ #[arg(long, value_name = "DIR")]
37
+ model_dir: Option<PathBuf>,
38
+
39
+ #[arg(long, value_enum, default_value = "ocr")]
40
+ task: TaskArg,
41
+
42
+ #[arg(long, default_value_t = 128)]
43
+ max_new_tokens: usize,
44
+
45
+ #[arg(long, value_name = "FILE")]
46
+ json_output: Option<PathBuf>,
47
+
48
+ #[arg(long, default_value_t = false)]
49
+ cpu: bool,
50
+
51
+ #[arg(long, value_name = "DIR")]
52
+ dataset_root: Option<PathBuf>,
53
+
54
+ #[arg(long, value_name = "FILE")]
55
+ ground_truth: Option<PathBuf>,
56
+
57
+ #[arg(long)]
58
+ limit: Option<usize>,
59
+
60
+ #[arg(long, default_value_t = 0)]
61
+ offset: usize,
62
+
63
+ #[arg(long, default_value_t = 10)]
64
+ sample_errors: usize,
65
+ }
66
+
67
+ #[derive(serde::Serialize)]
68
+ #[serde(rename_all = "camelCase")]
69
+ struct OutputEnvelope {
70
+ outputs: Vec<PaddleOcrVlOutput>,
71
+ }
72
+
73
+ #[derive(Debug, Clone)]
74
+ struct GroundTruthRecord {
75
+ relative_path: PathBuf,
76
+ text: String,
77
+ }
78
+
79
+ #[derive(Debug, serde::Serialize)]
80
+ #[serde(rename_all = "camelCase")]
81
+ struct EvaluationError {
82
+ image: String,
83
+ expected: String,
84
+ predicted: String,
85
+ char_distance: usize,
86
+ }
87
+
88
+ #[derive(Debug, serde::Serialize)]
89
+ #[serde(rename_all = "camelCase")]
90
+ struct EvaluationSummary {
91
+ dataset_root: String,
92
+ ground_truth: String,
93
+ evaluated_examples: usize,
94
+ exact_matches: usize,
95
+ exact_match_rate: f64,
96
+ total_reference_chars: usize,
97
+ total_char_distance: usize,
98
+ cer: f64,
99
+ elapsed_seconds: f64,
100
+ sample_errors: Vec<EvaluationError>,
101
+ }
102
+
103
+ fn init_tracing() {
104
+ tracing_subscriber::fmt()
105
+ .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
106
+ .init();
107
+ }
108
+
109
+ #[tokio::main]
110
+ async fn main() -> Result<()> {
111
+ init_tracing();
112
+ koharu_runtime::initialize()
113
+ .await
114
+ .context("failed to initialize runtime libraries")?;
115
+
116
+ let cli = Cli::parse();
117
+ let task: PaddleOcrVlTask = cli.task.into();
118
+
119
+ if cli.input.is_empty() && cli.dataset_root.is_none() {
120
+ bail!("provide either --input or --dataset-root");
121
+ }
122
+
123
+ let mut model = if let Some(model_dir) = &cli.model_dir {
124
+ PaddleOcrVl::load_from_dir(model_dir, cli.cpu)?
125
+ } else {
126
+ PaddleOcrVl::load(cli.cpu).await?
127
+ };
128
+
129
+ if let Some(dataset_root) = &cli.dataset_root {
130
+ let summary = evaluate_dataset(&mut model, dataset_root, &cli, task)?;
131
+ println!("{}", serde_json::to_string_pretty(&summary)?);
132
+ if let Some(path) = &cli.json_output {
133
+ std::fs::write(path, serde_json::to_string_pretty(&summary)?)?;
134
+ }
135
+ return Ok(());
136
+ }
137
+
138
+ let images = cli
139
+ .input
140
+ .iter()
141
+ .map(image::open)
142
+ .collect::<Result<Vec<_>, _>>()?;
143
+ let outputs = model.inference_images(&images, task, cli.max_new_tokens)?;
144
+ for (input, output) in cli.input.iter().zip(&outputs) {
145
+ if cli.input.len() > 1 {
146
+ println!("== {} ==", input.display());
147
+ }
148
+ println!("{}", output.text);
149
+ }
150
+
151
+ if let Some(path) = &cli.json_output {
152
+ std::fs::write(
153
+ path,
154
+ serde_json::to_string_pretty(&OutputEnvelope { outputs })?,
155
+ )?;
156
+ }
157
+
158
+ Ok(())
159
+ }
160
+
161
+ fn evaluate_dataset(
162
+ model: &mut PaddleOcrVl,
163
+ dataset_root: &Path,
164
+ cli: &Cli,
165
+ task: PaddleOcrVlTask,
166
+ ) -> Result<EvaluationSummary> {
167
+ let started = Instant::now();
168
+ let split_name = dataset_root
169
+ .file_name()
170
+ .and_then(|name| name.to_str())
171
+ .context("dataset root must end with a split directory like `train`")?;
172
+ let ground_truth = cli
173
+ .ground_truth
174
+ .clone()
175
+ .unwrap_or_else(|| dataset_root.with_file_name(format!("rec_gt_{split_name}.txt")));
176
+ let dataset_base = dataset_root
177
+ .parent()
178
+ .and_then(Path::parent)
179
+ .context("dataset root must look like `<base>/rec/<split>`")?;
180
+
181
+ let records = read_ground_truth(&ground_truth)?;
182
+ let selected = records
183
+ .into_iter()
184
+ .skip(cli.offset)
185
+ .take(cli.limit.unwrap_or(usize::MAX))
186
+ .collect::<Vec<_>>();
187
+ if selected.is_empty() {
188
+ bail!("no evaluation records selected");
189
+ }
190
+
191
+ let mut exact_matches = 0usize;
192
+ let mut total_reference_chars = 0usize;
193
+ let mut total_char_distance = 0usize;
194
+ let mut sample_errors = Vec::new();
195
+
196
+ for (index, record) in selected.iter().enumerate() {
197
+ let image_path = dataset_base.join(&record.relative_path);
198
+ let image = image::open(&image_path)
199
+ .with_context(|| format!("failed to open `{}`", image_path.display()))?;
200
+ let output = model.inference_with_max_new_tokens(&image, task, cli.max_new_tokens)?;
201
+ let predicted = output.text;
202
+ let expected = record.text.as_str();
203
+ let char_distance = levenshtein_chars(expected, &predicted);
204
+ let reference_chars = expected.chars().count();
205
+
206
+ total_reference_chars += reference_chars;
207
+ total_char_distance += char_distance;
208
+ if predicted == expected {
209
+ exact_matches += 1;
210
+ } else if sample_errors.len() < cli.sample_errors {
211
+ sample_errors.push(EvaluationError {
212
+ image: image_path.display().to_string(),
213
+ expected: expected.to_string(),
214
+ predicted,
215
+ char_distance,
216
+ });
217
+ }
218
+
219
+ if (index + 1) % 50 == 0 || index + 1 == selected.len() {
220
+ let cer = ratio(total_char_distance, total_reference_chars);
221
+ let exact_match_rate = ratio(exact_matches, index + 1);
222
+ eprintln!(
223
+ "evaluated {}/{} exact={:.4} cer={:.4}",
224
+ index + 1,
225
+ selected.len(),
226
+ exact_match_rate,
227
+ cer
228
+ );
229
+ }
230
+ }
231
+
232
+ Ok(EvaluationSummary {
233
+ dataset_root: dataset_root.display().to_string(),
234
+ ground_truth: ground_truth.display().to_string(),
235
+ evaluated_examples: selected.len(),
236
+ exact_matches,
237
+ exact_match_rate: ratio(exact_matches, selected.len()),
238
+ total_reference_chars,
239
+ total_char_distance,
240
+ cer: ratio(total_char_distance, total_reference_chars),
241
+ elapsed_seconds: started.elapsed().as_secs_f64(),
242
+ sample_errors,
243
+ })
244
+ }
245
+
246
+ fn read_ground_truth(path: &Path) -> Result<Vec<GroundTruthRecord>> {
247
+ let bytes = std::fs::read(path)
248
+ .with_context(|| format!("failed to read ground-truth file `{}`", path.display()))?;
249
+ let (text, _, had_errors) = encoding_rs::UTF_8.decode(&bytes);
250
+ if had_errors {
251
+ bail!("ground-truth file `{}` is not valid UTF-8", path.display());
252
+ }
253
+
254
+ let mut records = Vec::new();
255
+ for line in text.lines().filter(|line| !line.trim().is_empty()) {
256
+ if let Some((relative_path, value)) = line.split_once('\t') {
257
+ records.push(GroundTruthRecord {
258
+ relative_path: PathBuf::from(relative_path),
259
+ text: value.to_string(),
260
+ });
261
+ continue;
262
+ }
263
+
264
+ let previous = records.last_mut().with_context(|| {
265
+ format!("invalid ground-truth line without a preceding record: `{line}`")
266
+ })?;
267
+ previous.text.push('\n');
268
+ previous.text.push_str(line);
269
+ }
270
+
271
+ Ok(records)
272
+ }
273
+
274
+ fn levenshtein_chars(expected: &str, predicted: &str) -> usize {
275
+ let expected = expected.chars().collect::<Vec<_>>();
276
+ let predicted = predicted.chars().collect::<Vec<_>>();
277
+
278
+ if expected.is_empty() {
279
+ return predicted.len();
280
+ }
281
+ if predicted.is_empty() {
282
+ return expected.len();
283
+ }
284
+
285
+ let mut prev = (0..=predicted.len()).collect::<Vec<_>>();
286
+ let mut curr = vec![0usize; predicted.len() + 1];
287
+
288
+ for (i, expected_char) in expected.iter().enumerate() {
289
+ curr[0] = i + 1;
290
+ for (j, predicted_char) in predicted.iter().enumerate() {
291
+ let substitution_cost = usize::from(expected_char != predicted_char);
292
+ curr[j + 1] = (prev[j + 1] + 1)
293
+ .min(curr[j] + 1)
294
+ .min(prev[j] + substitution_cost);
295
+ }
296
+ std::mem::swap(&mut prev, &mut curr);
297
+ }
298
+
299
+ prev[predicted.len()]
300
+ }
301
+
302
+ fn ratio(numerator: usize, denominator: usize) -> f64 {
303
+ if denominator == 0 {
304
+ 0.0
305
+ } else {
306
+ numerator as f64 / denominator as f64
307
+ }
308
+ }
koharu-llm/src/lib.rs CHANGED
@@ -2,6 +2,7 @@ pub mod api;
2
  pub mod facade;
3
  pub mod language;
4
  mod model;
 
5
  pub mod prompt;
6
  pub mod providers;
7
  pub mod safe;
 
2
  pub mod facade;
3
  pub mod language;
4
  mod model;
5
+ pub mod paddleocr_vl;
6
  pub mod prompt;
7
  pub mod providers;
8
  pub mod safe;
koharu-llm/src/model.rs CHANGED
@@ -67,8 +67,7 @@ impl Llm {
67
  }
68
 
69
  fn load_from_path(id: ModelId, cpu: bool, model_path: PathBuf) -> Result<Self> {
70
- crate::sys::initialize()
71
- .context("failed to initialize llama.cpp runtime bindings")?;
72
 
73
  LOGGING_READY.call_once(|| {
74
  send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
 
67
  }
68
 
69
  fn load_from_path(id: ModelId, cpu: bool, model_path: PathBuf) -> Result<Self> {
70
+ crate::sys::initialize().context("failed to initialize llama.cpp runtime bindings")?;
 
71
 
72
  LOGGING_READY.call_once(|| {
73
  send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
koharu-llm/src/paddleocr_vl.rs ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::ffi::CString;
2
+ use std::num::NonZeroU32;
3
+ use std::path::{Path, PathBuf};
4
+ use std::sync::Once;
5
+ use std::time::Instant;
6
+
7
+ use anyhow::{Context, Result, bail};
8
+ use image::DynamicImage;
9
+ use minijinja::{Environment, context};
10
+ use serde::{Deserialize, Serialize};
11
+
12
+ use crate::safe::context::params::{LlamaAttentionType, LlamaContextParams};
13
+ use crate::safe::llama_backend::LlamaBackend;
14
+ use crate::safe::llama_batch::LlamaBatch;
15
+ use crate::safe::model::LlamaModel;
16
+ use crate::safe::model::params::LlamaModelParams;
17
+ use crate::safe::mtmd::{
18
+ MtmdBitmap, MtmdContext, MtmdContextParams, MtmdInputChunkType, MtmdInputText,
19
+ };
20
+ use crate::safe::sampling::LlamaSampler;
21
+ use crate::safe::token::LlamaToken;
22
+ use crate::safe::{LogOptions, send_logs_to_tracing};
23
+
24
+ const HF_REPO: &str = "PaddlePaddle/PaddleOCR-VL-1.5-GGUF";
25
+ const MODEL_FILENAME: &str = "PaddleOCR-VL-1.5.gguf";
26
+ const MMPROJ_FILENAME: &str = "PaddleOCR-VL-1.5-mmproj.gguf";
27
+ const DEFAULT_MEDIA_MARKER: &str = "<__media__>";
28
+ const DEFAULT_GPU_LAYERS: u32 = 1000;
29
+ const DEFAULT_MAX_NEW_TOKENS: usize = 128;
30
+ const MAX_UBATCH: u32 = 512;
31
+
32
+ static LOGGING_READY: Once = Once::new();
33
+
34
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35
+ #[serde(rename_all = "snake_case")]
36
+ pub enum PaddleOcrVlTask {
37
+ Ocr,
38
+ Table,
39
+ Formula,
40
+ Chart,
41
+ Spotting,
42
+ Seal,
43
+ }
44
+
45
+ impl PaddleOcrVlTask {
46
+ fn prompt(self) -> &'static str {
47
+ match self {
48
+ Self::Ocr => "OCR:",
49
+ Self::Table => "Table Recognition:",
50
+ Self::Formula => "Formula Recognition:",
51
+ Self::Chart => "Chart Recognition:",
52
+ Self::Spotting => "Spotting:",
53
+ Self::Seal => "Seal Recognition:",
54
+ }
55
+ }
56
+ }
57
+
58
+ #[derive(Debug, Clone, Serialize, Deserialize)]
59
+ #[serde(rename_all = "camelCase")]
60
+ pub struct PaddleOcrVlOutput {
61
+ pub task: PaddleOcrVlTask,
62
+ pub text: String,
63
+ pub token_ids: Vec<u32>,
64
+ pub original_width: u32,
65
+ pub original_height: u32,
66
+ pub processed_width: u32,
67
+ pub processed_height: u32,
68
+ pub grid_thw: [u32; 3],
69
+ pub num_image_tokens: usize,
70
+ }
71
+
72
+ struct ModelFiles {
73
+ model: PathBuf,
74
+ mmproj: PathBuf,
75
+ }
76
+
77
+ struct RenderedPrompt {
78
+ text: String,
79
+ add_special: bool,
80
+ }
81
+
82
+ #[derive(Debug, Clone, Serialize)]
83
+ struct PromptMessage {
84
+ role: &'static str,
85
+ content: String,
86
+ }
87
+
88
+ pub struct PaddleOcrVl {
89
+ backend: LlamaBackend,
90
+ model: LlamaModel,
91
+ chat_template: String,
92
+ bos_token: String,
93
+ eos_token_text: String,
94
+ mtmd: MtmdContext,
95
+ eos_token: LlamaToken,
96
+ }
97
+
98
+ impl PaddleOcrVl {
99
+ pub async fn load(cpu: bool) -> Result<Self> {
100
+ let files = download_model_files().await?;
101
+ tokio::task::spawn_blocking(move || Self::load_from_files(files, cpu))
102
+ .await
103
+ .context("failed to join PaddleOCR-VL loading task")?
104
+ }
105
+
106
+ pub fn load_from_dir(dir: impl AsRef<Path>, cpu: bool) -> Result<Self> {
107
+ let files = resolve_local_model_files(dir.as_ref())?;
108
+ Self::load_from_files(files, cpu)
109
+ }
110
+
111
+ fn load_from_files(files: ModelFiles, cpu: bool) -> Result<Self> {
112
+ crate::sys::initialize().context("failed to initialize llama.cpp runtime bindings")?;
113
+
114
+ LOGGING_READY.call_once(|| {
115
+ send_logs_to_tracing(LogOptions::default().with_logs_enabled(true));
116
+ });
117
+
118
+ let backend = LlamaBackend::init().context("unable to initialize llama.cpp backend")?;
119
+ let model_params = model_params(cpu, &backend);
120
+ let model = LlamaModel::load_from_file(&backend, &files.model, &model_params)
121
+ .with_context(|| format!("unable to load model from `{}`", files.model.display()))?;
122
+ let eos_token = model.token_eos();
123
+ let chat_template = model
124
+ .meta_val_str("tokenizer.ggml.chat_template")
125
+ .or_else(|_| model.meta_val_str("tokenizer.chat_template"))
126
+ .context("missing embedded PaddleOCR-VL chat template")?;
127
+ let bos_token = token_text(&model, model.token_bos());
128
+ let eos_token_text = token_text(&model, eos_token);
129
+ let mmproj_path = files
130
+ .mmproj
131
+ .to_str()
132
+ .with_context(|| format!("invalid mmproj path `{}`", files.mmproj.display()))?;
133
+ let mtmd = MtmdContext::init_from_file(
134
+ mmproj_path,
135
+ &model,
136
+ &MtmdContextParams {
137
+ use_gpu: !cpu && backend.supports_gpu_offload(),
138
+ print_timings: true,
139
+ n_threads: num_cpus::get().try_into().unwrap_or(i32::MAX),
140
+ media_marker: CString::new(DEFAULT_MEDIA_MARKER)
141
+ .expect("default media marker contains no null bytes"),
142
+ },
143
+ )
144
+ .context("unable to initialize multimodal projector")?;
145
+
146
+ if !mtmd.support_vision() {
147
+ bail!("loaded PaddleOCR-VL projector does not advertise vision support");
148
+ }
149
+
150
+ tracing::info!(
151
+ has_encoder = unsafe { crate::sys::llama_model_has_encoder(model.model.as_ptr()) },
152
+ has_decoder = unsafe { crate::sys::llama_model_has_decoder(model.model.as_ptr()) },
153
+ decoder_start_token = model.decode_start_token().0,
154
+ "loaded PaddleOCR-VL model capabilities"
155
+ );
156
+
157
+ Ok(Self {
158
+ backend,
159
+ model,
160
+ chat_template,
161
+ bos_token,
162
+ eos_token_text,
163
+ mtmd,
164
+ eos_token,
165
+ })
166
+ }
167
+
168
+ pub fn inference(
169
+ &mut self,
170
+ image: &DynamicImage,
171
+ task: PaddleOcrVlTask,
172
+ ) -> Result<PaddleOcrVlOutput> {
173
+ self.inference_with_max_new_tokens(image, task, DEFAULT_MAX_NEW_TOKENS)
174
+ }
175
+
176
+ pub fn inference_with_max_new_tokens(
177
+ &mut self,
178
+ image: &DynamicImage,
179
+ task: PaddleOcrVlTask,
180
+ max_new_tokens: usize,
181
+ ) -> Result<PaddleOcrVlOutput> {
182
+ let started = Instant::now();
183
+ let original_width = image.width();
184
+ let original_height = image.height();
185
+ let bitmap = bitmap_from_image(image)?;
186
+ let prompt = self.render_prompt(task)?;
187
+ let chunks = self
188
+ .mtmd
189
+ .tokenize(
190
+ MtmdInputText {
191
+ text: prompt.text,
192
+ add_special: prompt.add_special,
193
+ parse_special: true,
194
+ },
195
+ &[&bitmap],
196
+ )
197
+ .context("failed to tokenize multimodal OCR input")?;
198
+ if chunks.is_empty() {
199
+ bail!("multimodal tokenization produced no chunks");
200
+ }
201
+
202
+ let batch_tokens = max_chunk_tokens(&chunks).max(1);
203
+ let prompt_positions =
204
+ usize::try_from(chunks.total_positions()).context("prompt positions overflow usize")?;
205
+ let prompt_total_tokens = chunks.total_tokens();
206
+ let num_image_tokens = image_chunk_tokens(&chunks);
207
+ let ctx_params = context_params(
208
+ &self.mtmd,
209
+ prompt_positions,
210
+ prompt_total_tokens,
211
+ batch_tokens,
212
+ max_new_tokens,
213
+ )?;
214
+ let mut ctx = self
215
+ .model
216
+ .new_context(&self.backend, ctx_params)
217
+ .context("unable to create PaddleOCR-VL llama.cpp context")?;
218
+ let n_batch = i32::try_from(batch_tokens).context("batch size exceeds i32")?;
219
+
220
+ let prompt_started = Instant::now();
221
+ let n_past = chunks
222
+ .eval_chunks(&self.mtmd, &ctx, 0, 0, n_batch, true)
223
+ .context("failed to evaluate multimodal prompt")?;
224
+ let prompt_elapsed = prompt_started.elapsed();
225
+
226
+ let generation_started = Instant::now();
227
+ let mut sampler = LlamaSampler::greedy();
228
+ let mut decoder = encoding_rs::UTF_8.new_decoder();
229
+ let mut token_ids = Vec::new();
230
+ let mut text = String::new();
231
+
232
+ if max_new_tokens > 0 {
233
+ let decoder_start = self.decoder_start_token();
234
+ let (mut next_token, mut position) = if let Some(decoder_start) = decoder_start {
235
+ let mut batch = LlamaBatch::new(1, 1);
236
+ batch
237
+ .add(decoder_start, 0, &[0], true)
238
+ .context("failed to build decoder start batch")?;
239
+ ctx.decode(&mut batch)
240
+ .context("failed to decode PaddleOCR-VL decoder start token")?;
241
+ (sampler.sample(&ctx, -1), 1)
242
+ } else {
243
+ (sampler.sample(&ctx, -1), n_past)
244
+ };
245
+
246
+ while token_ids.len() < max_new_tokens && !self.should_stop(next_token) {
247
+ sampler.accept(next_token);
248
+ token_ids
249
+ .push(u32::try_from(next_token.0).context("generated token id was negative")?);
250
+ text.push_str(&decode_token(&self.model, next_token, &mut decoder)?);
251
+
252
+ if token_ids.len() >= max_new_tokens {
253
+ break;
254
+ }
255
+
256
+ let mut batch = LlamaBatch::new(1, 1);
257
+ batch
258
+ .add(next_token, position, &[0], true)
259
+ .context("failed to build generation batch")?;
260
+ ctx.decode(&mut batch)
261
+ .context("failed to decode generated OCR token")?;
262
+ position += 1;
263
+ next_token = sampler.sample(&ctx, -1);
264
+ }
265
+ }
266
+
267
+ tracing::info!(
268
+ task = ?task,
269
+ original_width,
270
+ original_height,
271
+ prompt_total_tokens,
272
+ prompt_positions,
273
+ batch_tokens,
274
+ num_image_tokens,
275
+ prompt_ms = prompt_elapsed.as_millis(),
276
+ generation_ms = generation_started.elapsed().as_millis(),
277
+ total_ms = started.elapsed().as_millis(),
278
+ "paddleocr-vl inference timings"
279
+ );
280
+
281
+ Ok(PaddleOcrVlOutput {
282
+ task,
283
+ text: text.trim().to_string(),
284
+ token_ids,
285
+ original_width,
286
+ original_height,
287
+ processed_width: original_width,
288
+ processed_height: original_height,
289
+ grid_thw: [0, 0, 0],
290
+ num_image_tokens,
291
+ })
292
+ }
293
+
294
+ pub fn inference_images(
295
+ &mut self,
296
+ images: &[DynamicImage],
297
+ task: PaddleOcrVlTask,
298
+ max_new_tokens: usize,
299
+ ) -> Result<Vec<PaddleOcrVlOutput>> {
300
+ let started = Instant::now();
301
+ let mut outputs = Vec::with_capacity(images.len());
302
+ for image in images {
303
+ outputs.push(self.inference_with_max_new_tokens(image, task, max_new_tokens)?);
304
+ }
305
+ tracing::info!(
306
+ images = images.len(),
307
+ total_ms = started.elapsed().as_millis(),
308
+ "paddleocr-vl batch timings"
309
+ );
310
+ Ok(outputs)
311
+ }
312
+
313
+ fn should_stop(&self, token: LlamaToken) -> bool {
314
+ token == self.eos_token || self.model.is_eog_token(token)
315
+ }
316
+
317
+ fn render_prompt(&self, task: PaddleOcrVlTask) -> Result<RenderedPrompt> {
318
+ let text = render_chat_prompt(
319
+ &self.chat_template,
320
+ &self.bos_token,
321
+ &self.eos_token_text,
322
+ task,
323
+ )
324
+ .context("failed to render PaddleOCR-VL prompt from embedded chat template")?;
325
+ Ok(RenderedPrompt {
326
+ text,
327
+ add_special: false,
328
+ })
329
+ }
330
+
331
+ fn decoder_start_token(&self) -> Option<LlamaToken> {
332
+ let has_encoder = unsafe { crate::sys::llama_model_has_encoder(self.model.model.as_ptr()) };
333
+ if !has_encoder {
334
+ return None;
335
+ }
336
+
337
+ let decoder_start = self.model.decode_start_token();
338
+ if decoder_start.0 >= 0 {
339
+ Some(decoder_start)
340
+ } else {
341
+ Some(self.model.token_bos())
342
+ }
343
+ }
344
+ }
345
+
346
+ pub async fn prefetch() -> Result<()> {
347
+ download_model_files().await?;
348
+ Ok(())
349
+ }
350
+
351
+ async fn download_model_files() -> Result<ModelFiles> {
352
+ let (model, mmproj) = tokio::try_join!(
353
+ koharu_http::download::model(HF_REPO, MODEL_FILENAME),
354
+ koharu_http::download::model(HF_REPO, MMPROJ_FILENAME),
355
+ )?;
356
+
357
+ Ok(ModelFiles { model, mmproj })
358
+ }
359
+
360
+ fn resolve_local_model_files(dir: &Path) -> Result<ModelFiles> {
361
+ let preferred_model = dir.join(MODEL_FILENAME);
362
+ let preferred_mmproj = dir.join(MMPROJ_FILENAME);
363
+ if preferred_model.exists() && preferred_mmproj.exists() {
364
+ return Ok(ModelFiles {
365
+ model: preferred_model,
366
+ mmproj: preferred_mmproj,
367
+ });
368
+ }
369
+
370
+ let mut model = None;
371
+ let mut mmproj = None;
372
+ for entry in std::fs::read_dir(dir)
373
+ .with_context(|| format!("unable to read PaddleOCR-VL model dir `{}`", dir.display()))?
374
+ {
375
+ let entry = entry?;
376
+ let path = entry.path();
377
+ if !path
378
+ .extension()
379
+ .and_then(|ext| ext.to_str())
380
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
381
+ {
382
+ continue;
383
+ }
384
+
385
+ let filename = path
386
+ .file_name()
387
+ .and_then(|name| name.to_str())
388
+ .unwrap_or("");
389
+ if filename.contains("mmproj") {
390
+ if mmproj.is_none() {
391
+ mmproj = Some(path);
392
+ }
393
+ } else if model.is_none() {
394
+ model = Some(path);
395
+ }
396
+ }
397
+
398
+ Ok(ModelFiles {
399
+ model: model.with_context(|| {
400
+ format!(
401
+ "missing `{MODEL_FILENAME}` (or any non-mmproj GGUF) in `{}`",
402
+ dir.display()
403
+ )
404
+ })?,
405
+ mmproj: mmproj.with_context(|| {
406
+ format!(
407
+ "missing `{MMPROJ_FILENAME}` (or any mmproj GGUF) in `{}`",
408
+ dir.display()
409
+ )
410
+ })?,
411
+ })
412
+ }
413
+
414
+ fn model_params(cpu: bool, backend: &LlamaBackend) -> LlamaModelParams {
415
+ if !cpu && backend.supports_gpu_offload() {
416
+ LlamaModelParams::default().with_n_gpu_layers(DEFAULT_GPU_LAYERS)
417
+ } else {
418
+ LlamaModelParams::default()
419
+ }
420
+ }
421
+
422
+ fn context_params(
423
+ mtmd: &MtmdContext,
424
+ prompt_positions: usize,
425
+ prompt_total_tokens: usize,
426
+ batch_tokens: usize,
427
+ max_tokens: usize,
428
+ ) -> Result<LlamaContextParams> {
429
+ let required_ctx = prompt_positions
430
+ .saturating_add(max_tokens)
431
+ .saturating_add(1)
432
+ .max(
433
+ prompt_total_tokens
434
+ .saturating_add(max_tokens)
435
+ .saturating_add(1),
436
+ )
437
+ .max(batch_tokens.saturating_add(1));
438
+ let n_ctx = NonZeroU32::new(u32::try_from(required_ctx).context("context size exceeds u32")?)
439
+ .expect("required context size is non-zero");
440
+ let n_batch = u32::try_from(batch_tokens.max(1)).context("batch size exceeds u32")?;
441
+ let n_ubatch = if mtmd.decode_use_non_causal() {
442
+ n_batch
443
+ } else {
444
+ n_batch.min(MAX_UBATCH)
445
+ };
446
+
447
+ let mut params = LlamaContextParams::default()
448
+ .with_n_ctx(Some(n_ctx))
449
+ .with_n_batch(n_batch)
450
+ .with_n_ubatch(n_ubatch);
451
+ if mtmd.decode_use_non_causal() {
452
+ params = params.with_attention_type(LlamaAttentionType::NonCausal);
453
+ }
454
+ Ok(params)
455
+ }
456
+
457
+ fn bitmap_from_image(image: &DynamicImage) -> Result<MtmdBitmap> {
458
+ let rgb = image.to_rgb8();
459
+ let (width, height) = rgb.dimensions();
460
+ MtmdBitmap::from_image_data(width, height, &rgb.into_raw())
461
+ .context("failed to create MTMD bitmap from image")
462
+ }
463
+
464
+ fn build_user_message_content(task: PaddleOcrVlTask) -> String {
465
+ format!("{DEFAULT_MEDIA_MARKER}{}", task.prompt())
466
+ }
467
+
468
+ fn render_chat_prompt(
469
+ chat_template: &str,
470
+ bos_token: &str,
471
+ eos_token: &str,
472
+ task: PaddleOcrVlTask,
473
+ ) -> Result<String> {
474
+ let mut env = Environment::new();
475
+ env.add_filter("trim", |s: String| s.trim().to_string());
476
+ let tmpl = env
477
+ .template_from_str(chat_template)
478
+ .map_err(anyhow::Error::msg)
479
+ .context("failed to parse embedded PaddleOCR-VL chat template")?;
480
+ tmpl.render(context! {
481
+ messages => vec![PromptMessage {
482
+ role: "user",
483
+ content: build_user_message_content(task),
484
+ }],
485
+ bos_token => bos_token,
486
+ eos_token => eos_token,
487
+ add_generation_prompt => true,
488
+ })
489
+ .map_err(anyhow::Error::msg)
490
+ .context("failed to evaluate embedded PaddleOCR-VL chat template")
491
+ }
492
+
493
+ fn max_chunk_tokens(chunks: &crate::safe::mtmd::MtmdInputChunks) -> usize {
494
+ (0..chunks.len())
495
+ .filter_map(|index| chunks.get(index))
496
+ .map(|chunk| chunk.n_tokens())
497
+ .max()
498
+ .unwrap_or(0)
499
+ }
500
+
501
+ fn image_chunk_tokens(chunks: &crate::safe::mtmd::MtmdInputChunks) -> usize {
502
+ (0..chunks.len())
503
+ .filter_map(|index| chunks.get(index))
504
+ .filter(|chunk| chunk.chunk_type() == MtmdInputChunkType::Image)
505
+ .map(|chunk| chunk.n_tokens())
506
+ .sum()
507
+ }
508
+
509
+ fn decode_token(
510
+ model: &LlamaModel,
511
+ token: LlamaToken,
512
+ decoder: &mut encoding_rs::Decoder,
513
+ ) -> Result<String> {
514
+ model
515
+ .token_to_piece(token, decoder, true, None)
516
+ .context("failed to decode generated OCR token")
517
+ }
518
+
519
+ fn token_text(model: &LlamaModel, token: LlamaToken) -> String {
520
+ let mut decoder = encoding_rs::UTF_8.new_decoder();
521
+ match model.token_to_piece(token, &mut decoder, true, None) {
522
+ Ok(piece) if !piece.is_empty() => piece,
523
+ _ => token.to_string(),
524
+ }
525
+ }
526
+
527
+ #[cfg(test)]
528
+ mod tests {
529
+ use super::{
530
+ DEFAULT_MEDIA_MARKER, PaddleOcrVlTask, build_user_message_content, render_chat_prompt,
531
+ };
532
+
533
+ #[test]
534
+ fn user_message_places_media_marker_before_task() {
535
+ assert_eq!(
536
+ build_user_message_content(PaddleOcrVlTask::Ocr),
537
+ format!("{DEFAULT_MEDIA_MARKER}OCR:")
538
+ );
539
+ }
540
+
541
+ #[test]
542
+ fn user_message_contents_match_paddleocr_tasks() {
543
+ assert_eq!(
544
+ build_user_message_content(PaddleOcrVlTask::Table),
545
+ "<__media__>Table Recognition:"
546
+ );
547
+ assert_eq!(
548
+ build_user_message_content(PaddleOcrVlTask::Formula),
549
+ "<__media__>Formula Recognition:"
550
+ );
551
+ assert_eq!(
552
+ build_user_message_content(PaddleOcrVlTask::Chart),
553
+ "<__media__>Chart Recognition:"
554
+ );
555
+ assert_eq!(
556
+ build_user_message_content(PaddleOcrVlTask::Spotting),
557
+ "<__media__>Spotting:"
558
+ );
559
+ assert_eq!(
560
+ build_user_message_content(PaddleOcrVlTask::Seal),
561
+ "<__media__>Seal Recognition:"
562
+ );
563
+ }
564
+
565
+ #[test]
566
+ fn embedded_template_render_keeps_media_marker_in_user_turn() -> anyhow::Result<()> {
567
+ let rendered = render_chat_prompt(
568
+ "{{ bos_token }}{% for message in messages %}User: {{ message['content'] }}\n{% endfor %}{% if add_generation_prompt %}Assistant:\n{% endif %}",
569
+ "<|begin_of_sentence|>",
570
+ "</s>",
571
+ PaddleOcrVlTask::Formula,
572
+ )?;
573
+ assert_eq!(
574
+ rendered,
575
+ "<|begin_of_sentence|>User: <__media__>Formula Recognition:\nAssistant:\n"
576
+ );
577
+ Ok(())
578
+ }
579
+ }
koharu-llm/src/safe/model.rs CHANGED
@@ -401,8 +401,14 @@ impl LlamaModel {
401
  ),
402
  x => x,
403
  }?;
404
- // here the assumption is that each byte from the output may map to at most one output charakter
405
- let mut output_piece = String::with_capacity(bytes.len());
 
 
 
 
 
 
406
  // _result only tells if there is nothing more in the input, or if the output was full
407
  // but further decoding will happen on the next interation anyway
408
  let (_result, _somesize, _truthy) =
 
401
  ),
402
  x => x,
403
  }?;
404
+ let mut output_piece = String::new();
405
+ // encoding_rs treats the String capacity as the output buffer size and will not
406
+ // reallocate, so reserve using the decoder's current state before decoding.
407
+ output_piece.reserve(
408
+ decoder
409
+ .max_utf8_buffer_length(bytes.len())
410
+ .unwrap_or(bytes.len().saturating_mul(3).saturating_add(3)),
411
+ );
412
  // _result only tells if there is nothing more in the input, or if the output was full
413
  // but further decoding will happen on the next interation anyway
414
  let (_result, _somesize, _truthy) =
koharu-llm/src/sys/mod.rs CHANGED
@@ -63,8 +63,9 @@ const LIB_NAMES: [&str; 4] = [
63
  static LIBRARIES: OnceLock<LoadedLibraries> = OnceLock::new();
64
 
65
  pub(crate) fn initialize() -> Result<()> {
66
- let runtime_dir = koharu_runtime::llama_runtime_dir()
67
- .context("failed to resolve the llama runtime directory; call `koharu_runtime::initialize()` first")?;
 
68
 
69
  if !runtime_dir.exists() {
70
  bail!(
@@ -73,9 +74,9 @@ pub(crate) fn initialize() -> Result<()> {
73
  );
74
  }
75
 
76
- let dir = runtime_dir.canonicalize().with_context(|| {
77
- format!("failed to canonicalize `{}`", runtime_dir.display())
78
- })?;
79
 
80
  if let Some(existing) = LIBRARIES.get() {
81
  if existing.path == dir {
 
63
  static LIBRARIES: OnceLock<LoadedLibraries> = OnceLock::new();
64
 
65
  pub(crate) fn initialize() -> Result<()> {
66
+ let runtime_dir = koharu_runtime::llama_runtime_dir().context(
67
+ "failed to resolve the llama runtime directory; call `koharu_runtime::initialize()` first",
68
+ )?;
69
 
70
  if !runtime_dir.exists() {
71
  bail!(
 
74
  );
75
  }
76
 
77
+ let dir = runtime_dir
78
+ .canonicalize()
79
+ .with_context(|| format!("failed to canonicalize `{}`", runtime_dir.display()))?;
80
 
81
  if let Some(existing) = LIBRARIES.get() {
82
  if existing.path == dir {
koharu-ml/Cargo.toml CHANGED
@@ -13,6 +13,7 @@ publish.workspace = true
13
 
14
  [dependencies]
15
  koharu-http = { workspace = true }
 
16
  koharu-types = { workspace = true }
17
  anyhow = { workspace = true }
18
  clap = { workspace = true }
 
13
 
14
  [dependencies]
15
  koharu-http = { workspace = true }
16
+ koharu-llm = { workspace = true }
17
  koharu-types = { workspace = true }
18
  anyhow = { workspace = true }
19
  clap = { workspace = true }
koharu-ml/src/facade.rs CHANGED
@@ -2,12 +2,12 @@ use std::{sync::Mutex, time::Instant};
2
 
3
  use anyhow::Result;
4
  use image::DynamicImage;
 
5
  use koharu_types::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection};
6
 
7
  use crate::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
8
  use crate::font_detector::{self, FontDetector};
9
  use crate::lama::{self, Lama};
10
- use crate::paddleocr_vl::{self, PaddleOcrVl, PaddleOcrVlTask};
11
  use crate::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
12
 
13
  const NEAR_BLACK_THRESHOLD: u8 = 12;
@@ -244,7 +244,7 @@ impl Model {
244
  pub async fn prefetch() -> Result<()> {
245
  pp_doclayout_v3::prefetch().await?;
246
  comic_text_detector::prefetch_segmentation().await?;
247
- paddleocr_vl::prefetch().await?;
248
  lama::prefetch().await?;
249
  font_detector::prefetch().await?;
250
 
 
2
 
3
  use anyhow::Result;
4
  use image::DynamicImage;
5
+ use koharu_llm::paddleocr_vl::{self as paddleocr_vl_llm, PaddleOcrVl, PaddleOcrVlTask};
6
  use koharu_types::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection};
7
 
8
  use crate::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
9
  use crate::font_detector::{self, FontDetector};
10
  use crate::lama::{self, Lama};
 
11
  use crate::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
12
 
13
  const NEAR_BLACK_THRESHOLD: u8 = 12;
 
244
  pub async fn prefetch() -> Result<()> {
245
  pp_doclayout_v3::prefetch().await?;
246
  comic_text_detector::prefetch_segmentation().await?;
247
+ paddleocr_vl_llm::prefetch().await?;
248
  lama::prefetch().await?;
249
  font_detector::prefetch().await?;
250
 
koharu-runtime/src/cuda.rs CHANGED
@@ -256,8 +256,10 @@ mod tests {
256
  }
257
  }
258
 
259
- let all_dylibs: Vec<&str> =
260
- WHEELS.iter().flat_map(|w| w.dylibs().iter().copied()).collect();
 
 
261
  for dylib in &all_dylibs {
262
  assert!(root.join(dylib).exists());
263
  }
 
256
  }
257
  }
258
 
259
+ let all_dylibs: Vec<&str> = WHEELS
260
+ .iter()
261
+ .flat_map(|w| w.dylibs().iter().copied())
262
+ .collect();
263
  for dylib in &all_dylibs {
264
  assert!(root.join(dylib).exists());
265
  }
koharu-runtime/src/lib.rs CHANGED
@@ -58,8 +58,7 @@ fn is_up_to_date(install_dir: &Path, source_id: &str) -> bool {
58
 
59
  fn reset_dir(dir: &Path) -> Result<()> {
60
  if dir.exists() {
61
- fs::remove_dir_all(dir)
62
- .with_context(|| format!("failed to reset `{}`", dir.display()))?;
63
  }
64
  fs::create_dir_all(dir).with_context(|| format!("failed to create `{}`", dir.display()))?;
65
  Ok(())
 
58
 
59
  fn reset_dir(dir: &Path) -> Result<()> {
60
  if dir.exists() {
61
+ fs::remove_dir_all(dir).with_context(|| format!("failed to reset `{}`", dir.display()))?;
 
62
  }
63
  fs::create_dir_all(dir).with_context(|| format!("failed to create `{}`", dir.display()))?;
64
  Ok(())
koharu-runtime/src/llama.rs CHANGED
@@ -235,11 +235,13 @@ mod tests {
235
  fn install_dir_includes_tag_and_id() {
236
  let root = Path::new("/tmp/rt");
237
  let dir = LlamaRuntime::WindowsVulkanX64.install_dir(root);
238
- assert!(dir.ends_with(
239
- Path::new("llama.cpp")
240
- .join(LLAMA_CPP_TAG)
241
- .join("windows-vulkan-x64")
242
- ));
 
 
243
  }
244
 
245
  #[test]
 
235
  fn install_dir_includes_tag_and_id() {
236
  let root = Path::new("/tmp/rt");
237
  let dir = LlamaRuntime::WindowsVulkanX64.install_dir(root);
238
+ assert!(
239
+ dir.ends_with(
240
+ Path::new("llama.cpp")
241
+ .join(LLAMA_CPP_TAG)
242
+ .join("windows-vulkan-x64")
243
+ )
244
+ );
245
  }
246
 
247
  #[test]
koharu/src/app.rs CHANGED
@@ -292,4 +292,3 @@ pub async fn run() -> Result<()> {
292
 
293
  Ok(())
294
  }
295
-
 
292
 
293
  Ok(())
294
  }