promptforge-gateway 0.1.0

PromptForge inference gateway: routes OpenAI-shaped chat completions to a backend
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
//! HF metadata sidecar files written beside cached GGUFs.
//!
//! When a GGUF is provisioned from Hugging Face, we fetch lightweight metadata
//! (tokenizer `chat_template`, optional model card excerpt) and write a single
//! markdown file next to the GGUF with the same stem:
//!
//! ```text
//! models/gemma-3-27b-it-q4_0.gguf
//! models/gemma-3-27b-it-q4_0.md    <-- sidecar
//! ```
//!
//! The sidecar is YAML frontmatter plus optional fenced content. The gateway
//! reads it back as supplementary [`DialectEvidence`] when `/props` is thin.

use std::fs::{self, File};
use std::io::{self, Read as _, Write as _};
use std::path::{Path, PathBuf};

/// On-disk sidecar format version, emitted in the frontmatter (SIDECAR-005).
///
/// v2 stores the template and card as a single-line JSON object in a fenced
/// block, so a `chat_template` that itself contains ``` fences or `##` headings
/// round-trips losslessly (serde escapes newlines, so the fence close is
/// unambiguous). v1 (delimiter-sensitive markdown) is still parsed for
/// backward compatibility; unknown versions are rejected rather than mis-parsed.
const SIDECAR_VERSION: u32 = 2;
/// Byte ceiling for a local sidecar read (SIDECAR-003).
const MAX_SIDECAR_BYTES: u64 = 1024 * 1024;
/// Byte ceiling for a remote `tokenizer_config.json` read (SIDECAR-002).
const MAX_TOKENIZER_BYTES: u64 = 8 * 1024 * 1024;

/// A typed failure while fetching remote sidecar metadata (SIDECAR-006).
///
/// The caller deliberately downgrades this to a debug log and proceeds without
/// a sidecar, rather than swallowing the distinction between "no template" and
/// "fetch failed".
#[derive(Debug, thiserror::Error)]
pub(crate) enum SidecarError {
    /// The source URL was not a recognized Hugging Face `resolve` URL.
    #[error("unsupported Hugging Face source URL")]
    UnsupportedUrl,
    /// The HTTP request failed at the transport layer.
    #[error("fetch tokenizer config")]
    Request(#[source] reqwest::Error),
    /// The endpoint returned a non-success status.
    #[error("fetch tokenizer config returned {status}")]
    Status {
        /// The rendered status.
        status: String,
    },
    /// Reading the (bounded) response body failed.
    #[error("read tokenizer config body")]
    Body(#[source] io::Error),
    /// The response body exceeded the byte ceiling and was rejected rather than
    /// silently truncated (SIDECAR-002).
    #[error("tokenizer config exceeded {limit} bytes")]
    Oversized {
        /// The byte ceiling that was exceeded.
        limit: u64,
    },
    /// The response body was not valid JSON.
    #[error("decode tokenizer config JSON")]
    Decode(#[source] serde_json::Error),
}

/// Metadata extracted from the sidecar markdown file.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SidecarMeta {
    /// HF source URL that produced this sidecar.
    pub source: Option<String>,
    /// ISO-8601 timestamp of when the metadata was fetched.
    pub fetched: Option<String>,
    /// Raw Jinja chat template string from the tokenizer config.
    pub chat_template: Option<String>,
    /// Short model card excerpt, if available.
    pub card: Option<String>,
}

/// Returns the sidecar `.md` path for a given GGUF path.
pub(crate) fn sidecar_path(gguf: &Path) -> PathBuf {
    gguf.with_extension("md")
}

/// Reads and parses the sidecar file next to `gguf`, if it exists.
///
/// Returns `None` when the sidecar does not exist. Returns an error only on
/// genuine I/O failures (permissions, corrupt read).
pub(crate) fn read_sidecar(gguf: &Path) -> Result<Option<SidecarMeta>, io::Error> {
    let path = sidecar_path(gguf);
    let file = match File::open(&path) {
        Ok(file) => file,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e),
    };
    // Bounded read that *rejects* oversize rather than silently truncating a
    // corrupt sidecar into a plausible-looking prefix (SIDECAR-003): read one
    // byte past the ceiling and treat any excess as invalid data.
    let mut bytes = Vec::new();
    file.take(MAX_SIDECAR_BYTES + 1).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > MAX_SIDECAR_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("sidecar exceeds maximum size of {MAX_SIDECAR_BYTES} bytes"),
        ));
    }
    let text = String::from_utf8_lossy(&bytes);
    Ok(Some(parse_sidecar(&text)))
}

/// Writes the sidecar `.md` beside `gguf` atomically.
///
/// The content is written to a *uniquely named* temp file in the same directory
/// and renamed into place, so concurrent writers never collide on a shared
/// `.tmp` name and a reader never observes a half-written sidecar (SIDECAR-004).
/// The temp file is removed on every failure path, not just a failed rename.
pub(crate) fn write_sidecar(gguf: &Path, meta: &SidecarMeta) -> Result<(), io::Error> {
    let path = sidecar_path(gguf);
    let content = render_sidecar(meta);
    let temp = unique_temp_path(&path);

    // Write, sync, and rename; on any failure remove the temp so a crashed or
    // racing writer leaves no stray `.tmp.<pid>.<nanos>` file behind.
    let write_result = (|| -> Result<(), io::Error> {
        let mut file = File::create(&temp)?;
        file.write_all(content.as_bytes())?;
        file.sync_all()?;
        drop(file);
        fs::rename(&temp, &path)
    })();
    if write_result.is_err() {
        let _ignored = fs::remove_file(&temp);
    }
    write_result
}

/// Builds a per-writer-unique sibling temp path (`<name>.tmp.<pid>.<nanos>`) so
/// two provisioning passes for the same GGUF cannot clobber each other's temp.
fn unique_temp_path(path: &Path) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_nanos());
    let mut temp = path.to_path_buf();
    let mut name = temp.file_name().unwrap_or_default().to_owned();
    name.push(format!(".tmp.{}.{nanos}", std::process::id()));
    temp.set_file_name(name);
    temp
}

/// The structured body payload of a v2 sidecar (single-line JSON).
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct SidecarBody {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    chat_template: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    card: Option<String>,
}

/// Renders the sidecar markdown string from metadata (v2 format).
fn render_sidecar(meta: &SidecarMeta) -> String {
    let mut out = String::with_capacity(512);
    out.push_str("---\n");
    out.push_str("version: ");
    out.push_str(&SIDECAR_VERSION.to_string());
    out.push('\n');
    if let Some(source) = &meta.source {
        out.push_str("source: ");
        out.push_str(source);
        out.push('\n');
    }
    if let Some(fetched) = &meta.fetched {
        out.push_str("fetched: ");
        out.push_str(fetched);
        out.push('\n');
    }
    out.push_str("---\n");

    // The template/card go into a single-line JSON object inside a fenced block.
    // serde escapes all control characters (including newlines), so the JSON
    // occupies exactly one line and can never contain the `\n```` sequence that
    // closes the fence, even when the template embeds ``` fences or `##`
    // headings (SIDECAR-005).
    let body = SidecarBody {
        chat_template: meta.chat_template.clone(),
        card: meta.card.clone(),
    };
    if body.chat_template.is_some() || body.card.is_some() {
        let json = serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_owned());
        out.push_str("\n```json\n");
        out.push_str(&json);
        out.push_str("\n```\n");
    }

    out
}

/// Parses a sidecar markdown string into [`SidecarMeta`].
///
/// Version-aware: v2 reads the single-line JSON body; v1 falls back to the
/// legacy delimiter-based markdown; any other (unknown or missing) version is
/// rejected - only the frontmatter `source`/`fetched` are kept and no
/// template/card is trusted, rather than mis-parsing an unknown layout.
fn parse_sidecar(text: &str) -> SidecarMeta {
    let mut meta = SidecarMeta::default();

    let Some(rest) = text.strip_prefix("---\n") else {
        return meta;
    };
    let Some(fm_end) = rest.find("\n---\n") else {
        return meta;
    };
    let frontmatter = &rest[..fm_end];
    let body = &rest[fm_end + 5..]; // skip "\n---\n"

    let mut version = None;
    for line in frontmatter.lines() {
        if let Some(value) = line.strip_prefix("version: ") {
            version = value.trim().parse::<u32>().ok();
        } else if let Some(value) = line.strip_prefix("source: ") {
            meta.source = Some(value.to_owned());
        } else if let Some(value) = line.strip_prefix("fetched: ") {
            meta.fetched = Some(value.to_owned());
        }
    }

    match version {
        Some(2) => {
            if let Some(json) = extract_fenced_block(body, "", "json")
                && let Ok(parsed) = serde_json::from_str::<SidecarBody>(json.trim())
            {
                meta.chat_template = parsed.chat_template;
                meta.card = parsed.card;
            }
        }
        Some(1) => {
            meta.chat_template = extract_fenced_block(body, "## chat_template", "jinja");
            meta.card = extract_section_text(body, "## card");
        }
        _ => {
            // Unknown/missing version: reject the body, keep only frontmatter.
        }
    }

    meta
}

/// Extracts the content of a fenced code block following a heading.
fn extract_fenced_block(body: &str, heading: &str, lang: &str) -> Option<String> {
    let heading_pos = body.find(heading)?;
    let after_heading = &body[heading_pos + heading.len()..];
    let fence_open = format!("```{lang}\n");
    let fence_start = after_heading.find(&fence_open)?;
    let content_start = fence_start + fence_open.len();
    let remaining = &after_heading[content_start..];
    let fence_end = remaining.find("\n```")?;
    let content = &remaining[..fence_end];
    Some(content.to_owned())
}

/// Extracts plain text following a heading, up to the next heading or EOF.
fn extract_section_text(body: &str, heading: &str) -> Option<String> {
    let heading_pos = body.find(heading)?;
    let after_heading = &body[heading_pos + heading.len()..];
    // Skip the heading line's trailing newline(s)
    let trimmed = after_heading.trim_start_matches('\n');
    if trimmed.is_empty() {
        return None;
    }
    // Take until the next heading or EOF
    let end = trimmed.find("\n## ").unwrap_or(trimmed.len());
    let text = trimmed[..end].trim();
    if text.is_empty() {
        None
    } else {
        Some(text.to_owned())
    }
}

/// Fetches HF tokenizer config for `chat_template` from a HF URL.
///
/// Attempts to resolve the repo/revision from the download URL and fetch
/// `tokenizer_config.json` (read with a byte cap, SIDECAR-002). `Ok(None)`
/// means the response was valid but carried no usable template.
///
/// # Errors
/// Returns a [`SidecarError`] the caller can log and deliberately downgrade
/// (SIDECAR-006) when the URL is unsupported, the request fails, the status is
/// non-success, or the body cannot be read or decoded.
pub(crate) fn fetch_hf_chat_template(
    client: &reqwest::blocking::Client,
    source_url: &str,
    bearer: Option<&str>,
) -> Result<Option<String>, SidecarError> {
    let (repo, revision) = parse_hf_url(source_url).ok_or(SidecarError::UnsupportedUrl)?;
    let api_url = format!("https://huggingface.co/{repo}/raw/{revision}/tokenizer_config.json");
    let mut request = client.get(&api_url);
    if let Some(token) = bearer {
        request = request.bearer_auth(token);
    }
    let response = request.send().map_err(SidecarError::Request)?;
    if !response.status().is_success() {
        return Err(SidecarError::Status {
            status: response.status().to_string(),
        });
    }
    // Bounded body read that *detects* oversize instead of silently truncating
    // (SIDECAR-002): read one byte past the ceiling; if we got it, the body was
    // larger than allowed and a truncated JSON prefix must not be trusted.
    let mut buf = Vec::new();
    response
        .take(MAX_TOKENIZER_BYTES + 1)
        .read_to_end(&mut buf)
        .map_err(SidecarError::Body)?;
    if buf.len() as u64 > MAX_TOKENIZER_BYTES {
        return Err(SidecarError::Oversized {
            limit: MAX_TOKENIZER_BYTES,
        });
    }
    let json: serde_json::Value = serde_json::from_slice(&buf).map_err(SidecarError::Decode)?;
    // chat_template can be a string or an array of objects with "template" fields.
    let template = match &json["chat_template"] {
        serde_json::Value::String(s) => Some(s.clone()),
        serde_json::Value::Array(arr) => arr
            .iter()
            .find(|entry| entry.get("name").and_then(|n| n.as_str()) == Some("default"))
            .or_else(|| arr.first())
            .and_then(|entry| entry.get("template"))
            .and_then(|t| t.as_str())
            .map(String::from),
        _ => None,
    };
    Ok(template)
}

/// Parses a HF download URL into `(repo, revision)`.
///
/// Expects patterns like:
/// `https://huggingface.co/{org}/{model}/resolve/{rev}/{file}`
fn parse_hf_url(url: &str) -> Option<(String, String)> {
    let path = url.strip_prefix("https://huggingface.co/")?;
    // Remove query string
    let path = path.split('?').next().unwrap_or(path);
    let parts: Vec<&str> = path.splitn(5, '/').collect();
    // parts: [org, model, "resolve", revision, filename]
    if parts.len() >= 5 && parts[2] == "resolve" {
        Some((format!("{}/{}", parts[0], parts[1]), parts[3].to_owned()))
    } else {
        None
    }
}

/// Returns the current UTC timestamp as an ISO-8601 string suitable for the
/// `fetched` frontmatter field, formatted from [`std::time::SystemTime`] with no
/// external crate or subprocess.
pub(crate) fn utc_now_iso() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_secs());
    format_unix_utc(secs)
}

/// Format a Unix timestamp (seconds since 1970-01-01 UTC) as `YYYY-MM-DDThh:mm:ssZ`.
///
/// Uses Howard Hinnant's days-to-civil algorithm; valid for all dates at or
/// after the Unix epoch.
fn format_unix_utc(secs: u64) -> String {
    let days = secs / 86_400;
    let second_of_day = secs % 86_400;
    let (hour, minute, second) = (
        second_of_day / 3_600,
        (second_of_day % 3_600) / 60,
        second_of_day % 60,
    );

    let z = days + 719_468;
    let era = z / 146_097;
    let day_of_era = z - era * 146_097;
    let year_of_era =
        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
    let year = year_of_era + era * 400;
    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
    let mp = (5 * day_of_year + 2) / 153;
    let day = day_of_year - (153 * mp + 2) / 5 + 1;
    let month = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if month <= 2 { year + 1 } else { year };

    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use tempfile::TempDir;

    use super::*;

    fn sample_meta() -> SidecarMeta {
        SidecarMeta {
            source: Some("https://huggingface.co/google/gemma-3-27b-it-qat-q4_0-gguf/resolve/main/gemma-3-27b-it-q4_0.gguf".to_owned()),
            fetched: Some("2026-08-08T12:00:00Z".to_owned()),
            chat_template: Some("{{ bos_token }}{% for message in messages %}<start_of_turn>{{ message['role'] }}\n{{ message['content'] }}<end_of_turn>\n{% endfor %}".to_owned()),
            card: Some("Gemma 3 27B instruction-tuned model.".to_owned()),
        }
    }

    #[test]
    fn formats_unix_epoch_boundaries() {
        assert_eq!(super::format_unix_utc(0), "1970-01-01T00:00:00Z");
        // 2021-01-01T00:00:00Z == 1609459200
        assert_eq!(
            super::format_unix_utc(1_609_459_200),
            "2021-01-01T00:00:00Z"
        );
        // 2000-02-29T12:34:56Z (leap day) == 951827696
        assert_eq!(super::format_unix_utc(951_827_696), "2000-02-29T12:34:56Z");
    }

    #[test]
    fn sidecar_path_replaces_extension() {
        let gguf = PathBuf::from("/cache/models/gemma-3-27b-it-q4_0.gguf");
        assert_eq!(
            sidecar_path(&gguf),
            PathBuf::from("/cache/models/gemma-3-27b-it-q4_0.md")
        );
    }

    #[test]
    fn round_trip_sidecar() {
        let meta = sample_meta();
        let rendered = render_sidecar(&meta);
        let parsed = parse_sidecar(&rendered);
        assert_eq!(parsed, meta);
    }

    #[test]
    fn rendered_sidecar_carries_a_format_version() {
        // SIDECAR-005: the on-disk format is versioned.
        let rendered = render_sidecar(&sample_meta());
        assert!(
            rendered.contains(&format!("version: {SIDECAR_VERSION}")),
            "sidecar should record its format version"
        );
    }

    #[test]
    fn unknown_version_is_rejected_not_misparsed() {
        // SIDECAR-005: an unknown/newer version keeps only the frontmatter and
        // refuses to trust a body it does not understand.
        let rendered = render_sidecar(&sample_meta());
        let bumped = rendered.replacen(&format!("version: {SIDECAR_VERSION}"), "version: 999", 1);
        let parsed = parse_sidecar(&bumped);
        assert_eq!(parsed.source, sample_meta().source);
        assert_eq!(parsed.fetched, sample_meta().fetched);
        assert!(
            parsed.chat_template.is_none(),
            "unknown version body rejected"
        );
        assert!(parsed.card.is_none());
    }

    #[test]
    fn v1_legacy_sidecar_still_parses() {
        // Backward compatibility: a v1 markdown sidecar round-trips its template.
        let v1 = "---\nversion: 1\nsource: https://hf/x\n---\n\n## chat_template\n\n```jinja\n{{ bos }}\n```\n";
        let meta = parse_sidecar(v1);
        assert_eq!(meta.source.as_deref(), Some("https://hf/x"));
        assert_eq!(meta.chat_template.as_deref(), Some("{{ bos }}"));
    }

    #[test]
    fn v2_round_trips_template_with_embedded_fences_and_headings() {
        // SIDECAR-005: the previous delimiter-based format truncated a template
        // that itself contained ``` fences or `##` headings. The v2 JSON body
        // round-trips it losslessly.
        let hostile = SidecarMeta {
            source: Some("https://huggingface.co/x/y/resolve/main/m.gguf".to_owned()),
            fetched: Some("2026-08-10T00:00:00Z".to_owned()),
            chat_template: Some(
                "## not a heading\n```\nembedded fence\n```\n{{ content }}".to_owned(),
            ),
            card: Some("card with\n## heading and ``` fence".to_owned()),
        };
        let rendered = render_sidecar(&hostile);
        assert_eq!(parse_sidecar(&rendered), hostile);
    }

    #[test]
    fn write_and_read_sidecar_file() {
        let dir = TempDir::new().expect("tempdir");
        let gguf = dir.path().join("model.gguf");
        fs::write(&gguf, b"fake-gguf").expect("write gguf");

        let meta = sample_meta();
        write_sidecar(&gguf, &meta).expect("write sidecar");

        let read_back = read_sidecar(&gguf).expect("read").expect("should exist");
        assert_eq!(read_back, meta);
    }

    #[test]
    fn read_sidecar_returns_none_when_missing() {
        let dir = TempDir::new().expect("tempdir");
        let gguf = dir.path().join("absent.gguf");
        let result = read_sidecar(&gguf).expect("no io error");
        assert!(result.is_none());
    }

    #[test]
    fn parse_sidecar_minimal() {
        let text = "---\nsource: https://example.com/model.gguf\n---\n";
        let meta = parse_sidecar(text);
        assert_eq!(
            meta.source.as_deref(),
            Some("https://example.com/model.gguf")
        );
        assert!(meta.chat_template.is_none());
        assert!(meta.card.is_none());
    }

    #[test]
    fn parse_sidecar_no_frontmatter() {
        let meta = parse_sidecar("just some text");
        assert_eq!(meta, SidecarMeta::default());
    }

    #[test]
    fn parse_hf_url_extracts_repo_and_revision() {
        let url =
            "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf";
        let (repo, rev) = parse_hf_url(url).expect("should parse");
        assert_eq!(repo, "unsloth/Qwen3.5-9B-GGUF");
        assert_eq!(rev, "main");
    }

    #[test]
    fn parse_hf_url_with_query_string() {
        let url = "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf?download=true";
        let (repo, rev) = parse_hf_url(url).expect("should parse");
        assert_eq!(repo, "Qwen/Qwen3-0.6B-GGUF");
        assert_eq!(rev, "main");
    }

    #[test]
    fn parse_hf_url_rejects_non_hf() {
        assert!(parse_hf_url("https://example.com/foo/bar.gguf").is_none());
    }

    #[test]
    fn render_sidecar_without_optional_fields() {
        let meta = SidecarMeta {
            source: Some("https://example.com/model.gguf".to_owned()),
            fetched: Some("2026-01-01T00:00:00Z".to_owned()),
            chat_template: None,
            card: None,
        };
        let rendered = render_sidecar(&meta);
        assert!(rendered.contains("source: https://example.com/model.gguf"));
        assert!(!rendered.contains("## chat_template"));
        assert!(!rendered.contains("## card"));
    }
}