Add GLM 5.3 Flash support

This commit is contained in:
Georg Bauer
2026-09-01 19:06:20 +02:00
parent 9a33c61ea6
commit 46d6a976a5
31 changed files with 7806 additions and 447 deletions

View File

@@ -3612,7 +3612,7 @@ pub(crate) fn parse_tool_calls(
model: ModelChoice,
text: &str,
) -> Result<(String, Vec<ToolCall>), String> {
let (content, calls) = if model == ModelChoice::Glm52 {
let (content, calls) = if model.is_glm() {
parse_glm_calls(text)?
} else {
crate::dsml::parse_tool_calls(text)?
@@ -3643,7 +3643,7 @@ fn system_prompt_with_tools(
ralph_child: bool,
) -> String {
let schemas = tool_schemas(dev_brain, ralph_child);
let tools = if model == ModelChoice::Glm52 {
let tools = if model.is_glm() {
format!(
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or code blocks as answers; edit files with tools, then summarize briefly.\n\n# Tools\n\n<tools>\n{schemas}\n</tools>\n\nFor a function call, output exactly: <tool_call>function-name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>\nTool calls are not allowed inside <think></think>. Pass numbers and booleans as JSON primitives, not quoted strings. When a tool fails validation or execution, use its code, field, expected, and received feedback to correct the next call. Preserve the current system configuration unless the user explicitly asks otherwise."
)

View File

@@ -35,6 +35,7 @@ use crate::settings::{
ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
StreamingCacheBudget,
};
use base64::Engine as _;
use iced::widget::{markdown, scrollable, text_editor};
use iced::{Size, Subscription, Task, keyboard, mouse, window};
use rfd::AsyncFileDialog;
@@ -115,6 +116,7 @@ pub(crate) struct App {
project_name_input: String,
model_download: ModelDownload,
pub(super) composer: text_editor::Content,
pub(super) pending_vision_image: Option<PendingVisionImage>,
pub(super) queued_inputs: VecDeque<String>,
pub(super) conversation: Vec<ChatMessage>,
/// Follow appended chat content until the user scrolls away from the tail.
@@ -191,6 +193,7 @@ struct ChatSnapshot {
selected_session: Option<i32>,
permission_mode: PermissionMode,
composer: text_editor::Content,
pending_vision_image: Option<PendingVisionImage>,
queued_inputs: VecDeque<String>,
conversation: Vec<ChatMessage>,
chat_follow_tail: bool,
@@ -231,6 +234,11 @@ struct ChatSnapshot {
skip_compaction_once: bool,
}
pub(super) struct PendingVisionImage {
pub(super) name: String,
marker: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ProjectChoice {
id: i32,
@@ -480,6 +488,9 @@ pub(crate) enum Message {
StopModelDownload,
DownloadProgressTick,
ComposerAction(text_editor::Action),
ChooseVisionImage,
VisionImagePicked(Result<Option<(String, Vec<u8>)>, String>),
RemoveVisionImage,
TranscriptAction(usize, text_editor::Action),
ToggleReasoning(usize),
OpenLink(markdown::Uri),
@@ -641,6 +652,7 @@ impl App {
project_name_input: String::new(),
model_download: ModelDownload::Idle,
composer: text_editor::Content::new(),
pending_vision_image: None,
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
chat_follow_tail: true,
@@ -805,6 +817,7 @@ impl App {
project_name_input: String::new(),
model_download: ModelDownload::Idle,
composer: text_editor::Content::new(),
pending_vision_image: None,
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
chat_follow_tail: true,
@@ -882,6 +895,7 @@ impl App {
selected_session: self.selected_session.take(),
permission_mode: self.permission_mode,
composer: std::mem::take(&mut self.composer),
pending_vision_image: self.pending_vision_image.take(),
queued_inputs: std::mem::take(&mut self.queued_inputs),
conversation: std::mem::take(&mut self.conversation),
chat_follow_tail: std::mem::replace(&mut self.chat_follow_tail, true),
@@ -932,6 +946,7 @@ impl App {
self.selected_session = snapshot.selected_session;
self.permission_mode = snapshot.permission_mode;
self.composer = snapshot.composer;
self.pending_vision_image = snapshot.pending_vision_image;
self.queued_inputs = snapshot.queued_inputs;
self.conversation = snapshot.conversation;
self.chat_follow_tail = snapshot.chat_follow_tail;
@@ -1290,6 +1305,55 @@ impl App {
}
Message::MetricsTick => self.sample_metrics(),
Message::ComposerAction(action) => self.composer.perform(action),
Message::ChooseVisionImage => {
if self.config.model != ModelChoice::Glm53Flash || self.generating {
return Task::none();
}
return Task::perform(
async {
let file = AsyncFileDialog::new()
.set_title("Attach an image")
.add_filter("Image", &["png", "jpg", "jpeg"])
.pick_file()
.await;
let Some(file) = file else {
return Ok::<_, String>(None);
};
let name = file.file_name();
let bytes = file.read().await;
Ok::<_, String>(Some((name, bytes)))
},
Message::VisionImagePicked,
);
}
Message::VisionImagePicked(result) => match result {
Ok(Some((name, bytes))) => {
if bytes.is_empty() || bytes.len() > 64 * 1024 * 1024 {
self.error = Some("Image must be between 1 byte and 64 MiB.".into());
} else {
let mime = match image::guess_format(&bytes) {
Ok(image::ImageFormat::Png) => Some("image/png"),
Ok(image::ImageFormat::Jpeg) => Some("image/jpeg"),
_ => None,
};
if let Some(mime) = mime {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
self.pending_vision_image = Some(PendingVisionImage {
name,
marker: crate::engine::vision_data_marker(&format!(
"data:{mime};base64,{encoded}"
)),
});
self.error = None;
} else {
self.error = Some("Only PNG and JPEG images are supported.".into());
}
}
}
Ok(None) => {}
Err(error) => self.error = Some(error),
},
Message::RemoveVisionImage => self.pending_vision_image = None,
Message::TranscriptAction(index, action) => {
if !action.is_edit()
&& let Some(message) = self.conversation.get_mut(index)

View File

@@ -517,7 +517,10 @@ impl App {
if self.selected_project.is_none() {
return;
}
let prompt = self.composer.text().trim().to_owned();
let mut prompt = self.composer.text().trim().to_owned();
if prompt.is_empty() && self.pending_vision_image.is_some() {
prompt = "Describe this image.".into();
}
if prompt.is_empty() {
return;
}
@@ -659,13 +662,20 @@ impl App {
#[cfg(target_os = "macos")]
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")]
let (visible_prompt, base_model_prompt) = vision_prompts(
&prompt,
self.pending_vision_image
.as_ref()
.map(|image| (image.name.as_str(), image.marker.as_str())),
);
#[cfg(target_os = "macos")]
let model_prompt = if self.config.a2ui_enabled {
format!(
"{prompt}\n\nA2UI client metadata:\n{}",
"{base_model_prompt}\n\nA2UI client metadata:\n{}",
self.a2ui.client_metadata()
)
} else {
prompt.clone()
base_model_prompt
};
#[cfg(target_os = "macos")]
let mut injected_system = self.workspace_instruction_messages(&[], opening_turn);
@@ -750,8 +760,8 @@ impl App {
};
let mut saved = match database.start_chat_turn(
session_id,
&prompt,
(model_prompt != prompt).then_some(model_prompt.as_str()),
&visible_prompt,
(model_prompt != visible_prompt).then_some(model_prompt.as_str()),
&injected_system,
assistant_reasoning,
) {
@@ -811,6 +821,7 @@ impl App {
}
assistant.reasoning_open = assistant_reasoning;
self.composer = text_editor::Content::new();
self.pending_vision_image = None;
self.conversation.push(user);
self.conversation.push(assistant);
self.generating = true;
@@ -2459,6 +2470,18 @@ impl App {
}
}
fn vision_prompts(prompt: &str, image: Option<(&str, &str)>) -> (String, String) {
image.map_or_else(
|| (prompt.to_owned(), prompt.to_owned()),
|(name, marker)| {
(
format!("[Image: {name}]\n{prompt}"),
format!("{marker}\n{prompt}"),
)
},
)
}
/// Reduces a model reply to a single sidebar-sized line, or `None` if nothing
/// usable came back.
pub(super) fn session_title(reply: &str) -> Option<String> {
@@ -2485,7 +2508,7 @@ mod tests {
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start,
correction_already_sent, extension_context_visibility, has_chat_after_last_compaction,
has_misplaced_tool_call, is_empty_response, promote_legacy_turn_summaries, queued_prompt,
sync_a2ui_message, title_context,
sync_a2ui_message, title_context, vision_prompts,
};
use crate::engine::ChatTurn;
use crate::model::ModelChoice;
@@ -2553,6 +2576,20 @@ mod tests {
assert_eq!(user.content, "visible question");
}
#[test]
fn attached_image_marker_is_model_only() {
let (visible, model) = vision_prompts(
"Describe this.",
Some(("diagram.png", "<|vision_start|>payload<|vision_end|>")),
);
assert_eq!(visible, "[Image: diagram.png]\nDescribe this.");
assert_eq!(
model,
"<|vision_start|>payload<|vision_end|>\nDescribe this."
);
}
#[test]
fn legacy_generation_rows_collapse_into_their_user_turn() {
let mut user = assistant(None, "question");

View File

@@ -593,6 +593,9 @@ impl App {
return;
}
self.config = config;
if self.config.model != ModelChoice::Glm53Flash {
self.pending_vision_image = None;
}
self.context_limit = self.config.active_generation().context_tokens.max(0) as u32;
#[cfg(target_os = "macos")]
{
@@ -653,9 +656,11 @@ impl App {
self.preference_draft.default_reasoning_mode = mode;
self.preference_draft.load_generation(model, mode);
self.preference_draft.load_acceleration(model);
if model == ModelChoice::Glm52 {
if model.is_glm() {
self.preference_draft.power_percent.clear();
self.preference_draft.prefill_chunk.clear();
}
if model == ModelChoice::Glm52 {
self.preference_draft.directional_steering_file.clear();
self.preference_draft.directional_steering_ffn.clear();
self.preference_draft.directional_steering_attn.clear();
@@ -851,7 +856,7 @@ impl App {
}
Message::PreferenceGlmMtpChanged(value) => {
self.preference_draft.glm_mtp =
self.preference_draft.acceleration_model == ModelChoice::Glm52 && value;
self.preference_draft.acceleration_model.supports_glm_mtp() && value;
if !self.preference_draft.glm_mtp {
self.preference_draft.glm_mtp_timing = false;
}
@@ -859,7 +864,7 @@ impl App {
}
Message::PreferenceGlmMtpTimingChanged(value) => {
self.preference_draft.glm_mtp_timing =
self.preference_draft.acceleration_model == ModelChoice::Glm52 && value;
self.preference_draft.acceleration_model.supports_glm_mtp() && value;
if self.preference_draft.glm_mtp_timing {
self.preference_draft.glm_mtp = true;
}
@@ -907,7 +912,7 @@ impl App {
self.preference_error = None;
}
Message::PreferenceSsdFullLayersChanged(value) => {
if self.preference_draft.acceleration_model == ModelChoice::Glm52 {
if self.preference_draft.acceleration_model.is_glm() {
self.preference_draft.ssd_full_layers = value;
}
self.preference_error = None;

View File

@@ -250,7 +250,8 @@ impl App {
.padding(8)
.style(stop_button_style)
.on_press(Message::StopGeneration)
} else if self.composer.text().trim().is_empty() {
} else if self.composer.text().trim().is_empty() && self.pending_vision_image.is_none()
{
action_button(icon(ICON_SEND, 18)).padding(8)
} else {
action_button(icon(ICON_SEND, 18))
@@ -263,6 +264,20 @@ impl App {
self.context_used.min(self.context_limit) as f32 / self.context_limit as f32
};
let mut composer_content = column![composer].spacing(6);
if let Some(image) = &self.pending_vision_image {
composer_content = composer_content.push(
row![
icon(ICON_PAPERCLIP, 14),
text(format!("Image · {}", image.name)).size(12),
button(text("Remove").size(11))
.padding(0)
.style(button::text)
.on_press(Message::RemoveVisionImage),
]
.spacing(7)
.align_y(Alignment::Center),
);
}
for queued in &self.queued_inputs {
let mut queued = queued.replace('\n', " ");
if queued.chars().count() > 120 {
@@ -322,10 +337,32 @@ impl App {
.padding([2, 6])
.into()
};
let vision_ready = self.config.model == ModelChoice::Glm53Flash
&& model::engine_artifacts(ModelChoice::Glm53Flash, false, &models_path())
.vision
.is_some();
let attach = if vision_ready && !self.generating && self.pending_vision_image.is_none()
{
action_button(icon(ICON_PAPERCLIP, 19))
.padding(4)
.on_press(Message::ChooseVisionImage)
} else {
action_button(icon(ICON_PAPERCLIP, 19)).padding(4)
};
composer_content =
composer_content.push(
row![
icon(ICON_PAPERCLIP, 19),
tooltip(
attach,
container(text(if vision_ready {
"Attach PNG or JPEG"
} else {
"GLM 5.3 Flash vision sidecar is not ready"
}))
.padding(8)
.style(preference_group_style),
tooltip::Position::Top,
),
tooltip(
context_pie(context_fraction, 19),
container(

View File

@@ -14,12 +14,16 @@ impl App {
.on_toggle_maybe(dspark_toggle),
"Speculative decoding with the managed DSpark draft artifact: a small model proposes tokens that the main model verifies in one pass. Usually a large speedup; the target model may also stream routed experts from SSD.",
);
let glm_mtp_toggle: Option<fn(bool) -> Message> =
(self.preference_draft.acceleration_model == ModelChoice::Glm52)
.then_some(Message::PreferenceGlmMtpChanged);
let glm_mtp_timing_toggle: Option<fn(bool) -> Message> =
(self.preference_draft.acceleration_model == ModelChoice::Glm52)
.then_some(Message::PreferenceGlmMtpTimingChanged);
let glm_mtp_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_glm_mtp()
.then_some(Message::PreferenceGlmMtpChanged);
let glm_mtp_timing_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
.supports_glm_mtp()
.then_some(Message::PreferenceGlmMtpTimingChanged);
let dspark_strict_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.acceleration_model
@@ -71,14 +75,16 @@ impl App {
"0.8 (DeepSeek V4 Flash default)",
&self.preference_draft.dspark_confidence_threshold,
);
if self.preference_draft.model != ModelChoice::Glm52 {
if !self.preference_draft.model.is_glm() {
power = power.on_input(Message::PreferencePowerChanged);
prefill = prefill.on_input(Message::PreferencePrefillChunkChanged);
}
if self.preference_draft.model != ModelChoice::Glm52 {
steering_file = steering_file.on_input(Message::PreferenceSteeringFileChanged);
steering_ffn = steering_ffn.on_input(Message::PreferenceSteeringFfnChanged);
steering_attn = steering_attn.on_input(Message::PreferenceSteeringAttnChanged);
}
if self.preference_draft.acceleration_model == ModelChoice::Glm52 {
if self.preference_draft.acceleration_model.is_glm() {
ssd_full_layers = ssd_full_layers.on_input(Message::PreferenceSsdFullLayersChanged);
}
if self.preference_draft.acceleration_model.supports_dspark() {
@@ -467,6 +473,12 @@ impl App {
text_input("32768", &self.preference_draft.context_tokens)
.on_input(Message::PreferenceContextChanged),
),
text(if self.preference_draft.generation_model == ModelChoice::Glm53Flash {
"GLM 5.3 Flash: 32768 is the recommended default on this 128 GB machine; 50000 is the validated extended-session target."
} else {
""
})
.size(12),
preference_input_row(
"Maximum generated tokens",
"Hard stop for a single reply, counted from the first generated token. It bounds runaway answers and reasoning loops; it does not reserve memory.",
@@ -550,8 +562,8 @@ impl App {
.on_toggle(Message::PreferenceWarmWeightsChanged),
"Reads every mapped weight page once at load, so the first reply is not interrupted by page faults from disk. Loading takes longer and memory pressure rises immediately.",
),
text(if self.preference_draft.model == ModelChoice::Glm52 {
"GLM 5.2 uses full GPU power and selects prefill chunks automatically."
text(if self.preference_draft.model.is_glm() {
"GLM uses full GPU power and selects prefill chunks automatically."
} else {
"Blank numeric values preserve DS4's automatic engine behavior."
})
@@ -595,7 +607,7 @@ impl App {
toggle(self.preference_draft.glm_mtp)
.label("Enable integrated GLM MTP")
.on_toggle_maybe(glm_mtp_toggle),
"Uses the prediction head built into GLM 5.2 for speculative decoding, so no separate draft model is loaded. Available for GLM 5.2 only.",
"Uses the prediction head built into GLM for speculative decoding, so no separate draft model is loaded.",
),
hint(
toggle(self.preference_draft.glm_mtp_timing)
@@ -623,7 +635,7 @@ impl App {
),
text(if self.preference_draft.acceleration_model.supports_dspark() {
"DeepSeek V4 Flash 0731 uses its managed DSpark support artifact."
} else if self.preference_draft.acceleration_model == ModelChoice::Glm52 {
} else if self.preference_draft.acceleration_model.supports_glm_mtp() {
"GLM MTP is integrated; DSpark is unavailable for this model."
} else {
"No speculative-decoding support is available for this model."

View File

@@ -12,8 +12,10 @@ use crate::model::{ModelChoice, validate_engine_artifacts};
#[cfg(target_os = "macos")]
use crate::settings::TurnSettings;
use crate::settings::{EngineSettings, ReasoningMode};
#[cfg(target_os = "macos")]
use base64::Engine as _;
use gguf::{
F16, F32, Gguf, I32, IQ2_XXS, MXFP4, Q2_K, Q4_0, Q4_K, Q5_K, Q6_K, Q8_0, Tensor, Value,
BF16, F16, F32, Gguf, I32, IQ2_XXS, MXFP4, Q2_K, Q4_0, Q4_K, Q5_K, Q6_K, Q8_0, Tensor, Value,
};
#[cfg(target_os = "macos")]
use kvstore::{KvStore, StoreReason};
@@ -32,6 +34,19 @@ use std::time::Instant;
use tokenizer::Tokenizer;
use validation::{SupportKind, validate_main, validate_support};
const VISION_DATA_START: &str = "<|ds4server_image_data|>";
const VISION_DATA_END: &str = "<|/ds4server_image_data|>";
const VISION_TOKEN_START: &str = "\u{fdd0}ds4-image:";
const VISION_TOKEN_END: &str = "\u{fdd1}";
const VISION_IMAGE_TOKEN: i32 = 154_854;
const VISION_START_TOKEN: i32 = 154_830;
const VISION_END_TOKEN: i32 = 154_831;
type VisionOverlays = Vec<(u32, metal::VisionEmbedding)>;
pub(crate) fn vision_data_marker(uri: &str) -> String {
format!("{VISION_DATA_START}{uri}{VISION_DATA_END}")
}
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn mmap(
@@ -47,14 +62,14 @@ unsafe extern "C" {
fn munmap(address: *mut std::ffi::c_void, length: usize) -> i32;
}
pub(crate) use validation::validate_model_artifact;
pub(crate) use validation::{validate_model_artifact, validate_vision_artifact};
#[cfg(target_os = "macos")]
pub(crate) use kvstore::sweep_unreachable as sweep_transient_cache;
#[cfg(target_os = "macos")]
pub(crate) use metal::configure_sources as configure_metal_sources;
const DENSE: &[u32] = &[Q8_0, Q4_K, Q4_0];
const DENSE: &[u32] = &[BF16, Q8_0, Q4_K, Q4_0];
const ROUTED: &[u32] = &[Q8_0, IQ2_XXS, Q2_K, Q4_K, Q5_K, Q6_K, MXFP4];
const PLAIN: &[u32] = &[F16, F32];
const DSPARK_DENSE: &[u32] = &[F16, F32, Q8_0];
@@ -211,9 +226,53 @@ const GLM: Shape = Shape {
original_context: 1_048_576,
};
const GLM53_FLASH: Shape = Shape {
model: ModelChoice::Glm53Flash,
family: ModelFamily::Glm,
layers: 46,
embd: 4096,
vocab: 154_880,
heads: 64,
head_kv: 1,
head_dim: 512,
value_dim: 256,
rot: 0,
out_groups: 0,
lora_q: 1536,
lora_o: 0,
experts: 288,
experts_used: 8,
expert_shared: 1,
ff_expert: 2048,
ff_dense: 12_288,
hash_layers: 0,
sliding_window: 0,
indexer_heads: 32,
indexer_head_dim: 128,
indexer_top_k: 2048,
hc: 4,
hc_sinkhorn: 20,
nextn: 1,
leading_dense: 3,
kv_lora: 512,
key_mla: 256,
value_mla: 256,
rms_epsilon: 1.0e-5,
hc_epsilon: 1.0e-6,
expert_weight_scale: 2.5,
swiglu_clamp: 10.0,
rope_base: 0.0,
rope_scale: 0.0,
rope_beta_fast: 0.0,
rope_beta_slow: 0.0,
compress_rope_base: 0.0,
original_context: 1_048_576,
};
pub(crate) struct Model {
main: Gguf,
support: Option<Gguf>,
vision: Option<Gguf>,
support_kind: Option<SupportKind>,
shape: Shape,
tokenizer: Tokenizer,
@@ -226,6 +285,7 @@ pub(crate) struct ModelSummary {
pub(crate) tensor_count: usize,
pub(crate) vocabulary_size: usize,
pub(crate) support_loaded: bool,
pub(crate) vision_loaded: bool,
}
impl Model {
@@ -249,6 +309,14 @@ impl Model {
model.support = Some(support);
model.support_kind = Some(kind);
}
if let Some(path) = &settings.artifacts.vision {
validate_vision_artifact(path)?;
let vision = Gguf::open(path)?;
if settings.execution.warm_weights {
vision.warm()?;
}
model.vision = Some(vision);
}
Ok(model)
}
@@ -266,6 +334,7 @@ impl Model {
Ok(Self {
main,
support: None,
vision: None,
support_kind: None,
shape,
tokenizer,
@@ -275,14 +344,21 @@ impl Model {
pub(crate) fn summary(&self) -> ModelSummary {
ModelSummary {
model: self.shape.model,
mapped_bytes: self.main.len() + self.support.as_ref().map_or(0, Gguf::len),
mapped_bytes: self.main.len()
+ self.support.as_ref().map_or(0, Gguf::len)
+ self.vision.as_ref().map_or(0, Gguf::len),
tensor_count: self.main.tensors.len()
+ self
.support
.as_ref()
.map_or(0, |support| support.tensors.len()),
.map_or(0, |support| support.tensors.len())
+ self
.vision
.as_ref()
.map_or(0, |vision| vision.tensors.len()),
vocabulary_size: self.tokenizer.vocab_size(),
support_loaded: self.support.is_some(),
vision_loaded: self.vision.is_some(),
}
}
@@ -293,6 +369,9 @@ impl Model {
if let Some(support) = &self.support {
hash.update(support.checkpoint_identity());
}
if let Some(vision) = &self.vision {
hash.update(vision.checkpoint_identity());
}
hash.finalize().into()
}
@@ -732,7 +811,7 @@ impl Generator {
}
phase("Updating system prompt cache…");
let completed = self.prefill_suffix(&tokens, reused, cancelled, progress)?;
let completed = self.prefill_suffix(&tokens, reused, false, cancelled, progress)?;
if completed != tokens.len() - reused {
return Err("generation cancelled while updating the system prompt cache".into());
}
@@ -1055,11 +1134,12 @@ impl Generator {
&mut self,
tokens: &[i32],
reused: usize,
has_vision: bool,
cancelled: &AtomicBool,
progress: &mut impl FnMut(u32, u32, Option<f32>),
) -> Result<usize, String> {
let suffix = &tokens[reused..];
if (reused == 0 && tokens.len() > 1) || suffix.len() >= 4 {
if has_vision || (reused == 0 && tokens.len() > 1) || suffix.len() >= 4 {
let context = self.executor.context();
self.executor.prefill(suffix, |used| {
progress(used, context, None);
@@ -1079,6 +1159,87 @@ impl Generator {
}
}
fn render_multimodal_conversation(
&mut self,
system: &str,
messages: &[ChatTurn],
reasoning: ReasoningMode,
) -> Result<(Vec<i32>, VisionOverlays), String> {
let mut rendered = messages.to_vec();
let mut embeddings = Vec::new();
let mut total_images = 0_usize;
let mut total_bytes = 0_usize;
for message in &mut rendered {
if !message.content.contains(VISION_DATA_START) {
continue;
}
if !message.user && !message.tool {
return Err("vision input is allowed only in user or tool messages".into());
}
let mut content = String::with_capacity(message.content.len());
let mut rest = message.content.as_str();
while let Some(start) = rest.find(VISION_DATA_START) {
content.push_str(&rest[..start]);
let encoded = &rest[start + VISION_DATA_START.len()..];
let end = encoded
.find(VISION_DATA_END)
.ok_or("unterminated image input")?;
let uri = &encoded[..end];
let payload = uri
.strip_prefix("data:image/png;base64,")
.or_else(|| uri.strip_prefix("data:image/jpeg;base64,"))
.ok_or("image input must be an inline PNG or JPEG data URI")?;
total_images += 1;
if total_images > 16 {
return Err("a request may contain at most 16 images".into());
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(payload)
.map_err(|_| "image data URI contains invalid base64")?;
total_bytes = total_bytes
.checked_add(bytes.len())
.ok_or("image input size overflow")?;
if total_bytes > 64 * 1024 * 1024 {
return Err("image inputs exceed the 64 MiB request limit".into());
}
let embedding = self.executor.encode_vision(&bytes)?;
content.push_str(VISION_TOKEN_START);
content.push_str(&embedding.tokens.to_string());
content.push_str(VISION_TOKEN_END);
embeddings.push(embedding);
rest = &encoded[end + VISION_DATA_END.len()..];
}
content.push_str(rest);
message.content = content;
}
let tokens = self
.executor
.model()
.render_conversation(system, &rendered, reasoning);
let mut overlays = Vec::with_capacity(embeddings.len());
let mut cursor = 0_usize;
for embedding in embeddings {
let count = embedding.tokens as usize;
let relative = tokens[cursor..]
.windows(count + 2)
.position(|window| {
window[0] == VISION_START_TOKEN
&& window[count + 1] == VISION_END_TOKEN
&& window[1..count + 1]
.iter()
.all(|token| *token == VISION_IMAGE_TOKEN)
})
.ok_or("rendered prompt lost an image placeholder")?;
let start = cursor + relative + 1;
overlays.push((
u32::try_from(start).map_err(|_| "image prompt position overflow")?,
embedding,
));
cursor = start + count + 1;
}
Ok((tokens, overlays))
}
fn generate_inner(
&mut self,
messages: &[ChatTurn],
@@ -1087,29 +1248,41 @@ impl Generator {
emit: &mut impl FnMut(bool, String),
progress: &mut impl FnMut(u32, u32, Option<f32>),
) -> Result<(GenerationOutput, bool), String> {
let tokens = match messages.split_last() {
Some((latest, history))
if latest.user
&& self.executor.checkpoint_tag()
== conversation_tag(
&settings.system_prompt,
settings.reasoning_mode,
history,
) =>
{
let mut tokens = self.executor.tokens().to_vec();
tokens.extend(self.executor.model().render_continuation(
&latest.content,
settings.reasoning_mode,
latest.skip_previous_eos,
));
tokens
}
_ => self.executor.model().render_conversation(
let has_vision = messages
.iter()
.any(|message| message.content.contains(VISION_DATA_START));
let (tokens, overlays) = if has_vision {
self.render_multimodal_conversation(
&settings.system_prompt,
messages,
settings.reasoning_mode,
),
)?
} else {
let tokens = match messages.split_last() {
Some((latest, history))
if latest.user
&& self.executor.checkpoint_tag()
== conversation_tag(
&settings.system_prompt,
settings.reasoning_mode,
history,
) =>
{
let mut tokens = self.executor.tokens().to_vec();
tokens.extend(self.executor.model().render_continuation(
&latest.content,
settings.reasoning_mode,
latest.skip_previous_eos,
));
tokens
}
_ => self.executor.model().render_conversation(
&settings.system_prompt,
messages,
settings.reasoning_mode,
),
};
(tokens, Vec::new())
};
if tokens.is_empty() {
return Err("the rendered prompt is empty".into());
@@ -1122,6 +1295,7 @@ impl Generator {
));
}
let reused = self.executor.align_prompt(&tokens)?;
self.executor.set_vision_overlays(overlays)?;
self.metrics.kv_prefix_reused(reused);
progress(self.executor.position(), self.executor.context(), None);
let mut rng = Rng::new(settings.seed.unwrap_or(0x4453_3453_4552_5645));
@@ -1140,7 +1314,7 @@ impl Generator {
let mut pending_utf8 = Vec::new();
let prompt_tokens = tokens.len();
let suffix = &tokens[reused..];
let completed = self.prefill_suffix(&tokens, reused, cancelled, progress)?;
let completed = self.prefill_suffix(&tokens, reused, has_vision, cancelled, progress)?;
self.publish_execution_stats();
if completed != suffix.len() {
return Ok((

View File

@@ -19,6 +19,7 @@ pub(super) const Q5_K: u32 = 13;
pub(super) const Q6_K: u32 = 14;
pub(super) const IQ2_XXS: u32 = 16;
pub(super) const I32: u32 = 26;
pub(super) const BF16: u32 = 30;
pub(super) const MXFP4: u32 = 39;
#[derive(Clone, Debug)]
@@ -488,6 +489,7 @@ impl<'a> Cursor<'a> {
| "deepseek4.dspark.target_layer_ids"
| "deepseek4.dspark_target_layer_ids"
| "dspark.target_layer_ids"
| "glm5-next.layer_types"
);
if !keep {
for _ in 0..len {

View File

@@ -3,12 +3,14 @@ mod glm;
mod gpu;
mod hotlist;
mod profile;
mod vision;
pub(super) use vision::VisionEmbedding;
use glm::GlmExecutor;
use gpu::*;
use profile::ExpertProfile;
use super::gguf::{F16, F32, Gguf, IQ2_XXS, MXFP4, Q2_K, Q4_K, Q8_0, Tensor as GgufTensor};
use super::gguf::{BF16, F16, F32, Gguf, IQ2_XXS, MXFP4, Q2_K, Q4_K, Q8_0, Tensor as GgufTensor};
use super::validation::{DsparkConfig, SupportKind, dspark_config};
use super::{Model, ModelFamily, Rng, exact_delta_sample};
use crate::model::ModelChoice;
@@ -42,7 +44,7 @@ fn environment_present(name: &CStr) -> bool {
!unsafe { getenv(name.as_ptr()) }.is_null()
}
const SOURCES: [(&str, &str); 19] = [
const SOURCES: [(&str, &str); 22] = [
("DS4_METAL_FLASH_ATTN_SOURCE", "flash_attn.metal"),
("DS4_METAL_DENSE_SOURCE", "dense.metal"),
("DS4_METAL_MOE_SOURCE", "moe.metal"),
@@ -62,6 +64,9 @@ const SOURCES: [(&str, &str); 19] = [
("DS4_METAL_NORM_SOURCE", "norm.metal"),
("DS4_METAL_BIN_SOURCE", "bin.metal"),
("DS4_METAL_SET_ROWS_SOURCE", "set_rows.metal"),
("DS4_METAL_GLM53_BF16_SOURCE", "glm53_bf16.metal"),
("DS4_METAL_GLM53_VISION_SOURCE", "glm53_vision.metal"),
("DS4_METAL_GLM53_KDA_SOURCE", "glm53_kda.metal"),
];
// The Metal boundary uses this only to decide whether diagnostic logs get ANSI
@@ -1593,7 +1598,7 @@ impl Dspark {
}
}
struct Steering {
pub(super) struct Steering {
directions: Buffer,
attention_scale: f32,
ffn_scale: f32,
@@ -1615,6 +1620,7 @@ impl Steering {
let expected = model
.shape
.layers
.saturating_sub(model.shape.nextn)
.checked_mul(model.shape.embd as u32)
.and_then(|values| values.checked_mul(4))
.ok_or("directional steering size overflow")? as usize;
@@ -2168,7 +2174,9 @@ impl SsdPlan {
let hotlist = match model.shape.model {
ModelChoice::DeepSeekV4Flash0731 => hotlist::FLASH,
ModelChoice::DeepSeekV4Pro => hotlist::PRO,
ModelChoice::Glm52 => unreachable!("GLM uses its dedicated executor"),
ModelChoice::Glm52 | ModelChoice::Glm53Flash => {
unreachable!("GLM uses its dedicated executor")
}
};
for &(layer, expert) in hotlist {
if loaded == self.preload_experts {
@@ -4435,6 +4443,7 @@ impl Executor {
quality,
ssd,
speculative,
steering,
expert_profile_path,
)
.map(Box::new)
@@ -4481,6 +4490,24 @@ impl Executor {
}
}
pub(super) fn encode_vision(&self, encoded: &[u8]) -> Result<vision::VisionEmbedding, String> {
match self {
Self::Glm(executor) => executor.encode_vision(encoded),
Self::DeepSeek(_) => Err("vision input requires GLM 5.3 Flash".into()),
}
}
pub(super) fn set_vision_overlays(
&mut self,
overlays: Vec<(u32, vision::VisionEmbedding)>,
) -> Result<(), String> {
match self {
Self::Glm(executor) => executor.set_vision_overlays(overlays),
Self::DeepSeek(_) if overlays.is_empty() => Ok(()),
Self::DeepSeek(_) => Err("vision input requires GLM 5.3 Flash".into()),
}
}
pub(super) fn eval_speculative_greedy(
&mut self,
token: i32,
@@ -7648,7 +7675,7 @@ fn compression_ratio(shape: super::Shape, layer: u32) -> u32 {
}
crate::model::ModelChoice::DeepSeekV4Flash0731
| crate::model::ModelChoice::DeepSeekV4Pro => 128,
crate::model::ModelChoice::Glm52 => 0,
crate::model::ModelChoice::Glm52 | crate::model::ModelChoice::Glm53Flash => 0,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,42 @@ pub(super) struct GpuTensor {
_private: [u8; 0],
}
#[derive(Clone, Copy, Default)]
#[repr(C)]
pub(super) struct Glm53VisionLayerWeights {
pub(super) norm1: u64,
pub(super) qkv_weight: u64,
pub(super) qkv_bias: u64,
pub(super) q_norm: u64,
pub(super) k_norm: u64,
pub(super) attn_proj_weight: u64,
pub(super) attn_proj_bias: u64,
pub(super) norm2: u64,
pub(super) gate_weight: u64,
pub(super) gate_bias: u64,
pub(super) up_weight: u64,
pub(super) up_bias: u64,
pub(super) down_weight: u64,
pub(super) down_bias: u64,
}
#[derive(Clone, Copy, Default)]
#[repr(C)]
pub(super) struct Glm53VisionWeights {
pub(super) patch_weight: u64,
pub(super) patch_bias: u64,
pub(super) post_norm: u64,
pub(super) downsample_weight: u64,
pub(super) downsample_bias: u64,
pub(super) merger_proj: u64,
pub(super) merger_norm: u64,
pub(super) merger_norm_bias: u64,
pub(super) merger_gate: u64,
pub(super) merger_up: u64,
pub(super) merger_down: u64,
pub(super) layer: [Glm53VisionLayerWeights; 24],
}
#[repr(C)]
pub(super) struct StreamExpertTable {
pub(super) model_map: *const c_void,
@@ -93,6 +129,12 @@ unsafe extern "C" {
count: u32,
) -> i32;
pub(super) fn ds4_gpu_flush_commands() -> i32;
pub(super) fn ds4_gpu_flush_encoder() -> i32;
pub(super) fn ds4_gpu_argmax_tensor(
out: *mut GpuTensor,
logits: *const GpuTensor,
vocab: u32,
) -> i32;
pub(super) fn ds4_gpu_device_is_pre_m5_apple_silicon() -> i32;
pub(super) fn ds4_gpu_device_is_m5_apple_silicon() -> i32;
#[cfg(test)]
@@ -227,6 +269,65 @@ unsafe extern "C" {
embd: u32,
hc: u32,
) -> i32;
pub(super) fn ds4_gpu_repeat_hc_rows_tensor(
out: *mut GpuTensor,
x: *const GpuTensor,
rows: u32,
embd: u32,
hc: u32,
) -> i32;
pub(super) fn ds4_gpu_glm53_embedding_bf16(
out: *mut GpuTensor,
map: *const c_void,
size: u64,
weight: u64,
tokens: *const GpuTensor,
rows: u32,
embd: u32,
vocab: u32,
) -> i32;
pub(super) fn ds4_gpu_glm53_vision_encode(
out: *mut f32,
patches: *const f32,
grid_h: u32,
grid_w: u32,
map: *const c_void,
size: u64,
weights: *const Glm53VisionWeights,
) -> i32;
pub(super) fn ds4_gpu_glm53_scatter_image_hc(
hc: *mut GpuTensor,
image: *const GpuTensor,
dst_row: u32,
image_row: u32,
rows: u32,
total_rows: u32,
embd: u32,
hc_count: u32,
) -> i32;
pub(super) fn ds4_gpu_glm53_matmul_bf16(
out: *mut GpuTensor,
map: *const c_void,
size: u64,
weight: u64,
input: u32,
output: u32,
x: *const GpuTensor,
rows: u32,
) -> i32;
pub(super) fn ds4_gpu_glm53_matmul_bf16_qkv(
q: *mut GpuTensor,
k: *mut GpuTensor,
v: *mut GpuTensor,
map: *const c_void,
size: u64,
q_weight: u64,
k_weight: u64,
v_weight: u64,
input: u32,
output: u32,
x: *const GpuTensor,
) -> i32;
pub(super) fn ds4_gpu_attention_noncausal_raw_batch_heads_tensor(
out: *mut GpuTensor,
map: *const c_void,
@@ -417,6 +518,35 @@ unsafe extern "C" {
beta_slow: f32,
cache_f16: bool,
) -> i32;
pub(super) fn ds4_gpu_glm53_indexer_pool_update_tensor(
cache: *mut GpuTensor,
tail_k: *mut GpuTensor,
tail_gate: *mut GpuTensor,
raw_k: *const GpuTensor,
gate: *const GpuTensor,
map: *const c_void,
size: u64,
norm_weight: u64,
norm_bias: u64,
ape: u64,
pos: u32,
rows: u32,
cache_cap: u32,
head_dim: u32,
pool_size: u32,
eps: f32,
cache_f16: bool,
) -> i32;
pub(super) fn ds4_gpu_glm53_expand_pool_selection_tensor(
selected: *mut GpuTensor,
pools: *const GpuTensor,
rows: u32,
pos: u32,
selected_pools: u32,
top_k: u32,
pool_size: u32,
width: u32,
) -> i32;
pub(super) fn ds4_gpu_glm_fill_selected_range_tensor(
selected: *mut GpuTensor,
count: u32,
@@ -467,6 +597,66 @@ unsafe extern "C" {
scale: f32,
cache_f16: bool,
) -> i32;
pub(super) fn ds4_gpu_glm53_indexer_scores_batch_tensor(
scores: *mut GpuTensor,
q: *const GpuTensor,
weights: *const GpuTensor,
cache: *const GpuTensor,
visible: u32,
rows: u32,
pos: u32,
pool_size: u32,
heads: u32,
head_dim: u32,
scale: f32,
cache_f16: bool,
) -> i32;
pub(super) fn ds4_gpu_glm53_kda_decode(
out: *mut GpuTensor,
conv: *mut GpuTensor,
recurrent: *mut GpuTensor,
q: *const GpuTensor,
k: *const GpuTensor,
v: *const GpuTensor,
gate: *const GpuTensor,
beta: *const GpuTensor,
output_gate: *const GpuTensor,
map: *const c_void,
size: u64,
q_conv: u64,
k_conv: u64,
v_conv: u64,
a_log: u64,
dt_bias: u64,
output_norm: u64,
heads: u32,
rows: u32,
gate_lower_bound: f32,
eps: f32,
) -> i32;
pub(super) fn ds4_gpu_glm53_kda_prefill(
out: *mut GpuTensor,
conv: *mut GpuTensor,
recurrent: *mut GpuTensor,
q: *mut GpuTensor,
k: *mut GpuTensor,
v: *mut GpuTensor,
gate: *mut GpuTensor,
beta: *const GpuTensor,
output_gate: *const GpuTensor,
map: *const c_void,
size: u64,
q_conv: u64,
k_conv: u64,
v_conv: u64,
a_log: u64,
dt_bias: u64,
output_norm: u64,
heads: u32,
rows: u32,
gate_lower_bound: f32,
eps: f32,
) -> i32;
pub(super) fn ds4_gpu_glm_qk_lowrank_typed_tensor(
out: *mut GpuTensor,
q: *const GpuTensor,
@@ -554,6 +744,19 @@ unsafe extern "C" {
beta_fast: f32,
beta_slow: f32,
) -> i32;
pub(super) fn ds4_gpu_glm_attention_dense_compact_lora_causal_tensor(
out: *mut GpuTensor,
qk_low: *const GpuTensor,
kv_cache: *const GpuTensor,
q_row0: u32,
rows: u32,
selected: u32,
cache_cap: u32,
cache_f16: bool,
heads: u32,
kv_lora: u32,
q_nope: u32,
) -> i32;
pub(super) fn ds4_gpu_glm_attention_indexed_batch_lora_valid_tensor(
out: *mut GpuTensor,
q: *const GpuTensor,
@@ -777,6 +980,20 @@ unsafe extern "C" {
eps: f32,
norm_eps: f32,
) -> i32;
pub(super) fn ds4_gpu_hc_split_weighted_sum_tensor(
out: *mut GpuTensor,
split: *mut GpuTensor,
mix: *const GpuTensor,
residual: *const GpuTensor,
map: *const c_void,
size: u64,
scale: u64,
base: u64,
embd: u32,
hc: u32,
iterations: u32,
eps: f32,
) -> i32;
pub(super) fn ds4_gpu_hc_rms_norm_mix_f16_available() -> i32;
pub(super) fn ds4_gpu_hc_rms_norm_mix_f16_tensor(
out: *mut GpuTensor,
@@ -1359,6 +1576,15 @@ unsafe extern "C" {
embd: u32,
hc: u32,
) -> i32;
pub(super) fn ds4_gpu_hc_expand_tensor(
out: *mut GpuTensor,
block: *const GpuTensor,
residual: *const GpuTensor,
post: *const GpuTensor,
combine: *const GpuTensor,
embd: u32,
hc: u32,
) -> i32;
pub(super) fn ds4_gpu_hc_expand_add_split_tensor(
out: *mut GpuTensor,
block: *const GpuTensor,
@@ -1463,6 +1689,44 @@ unsafe extern "C" {
x: *const GpuTensor,
clamp: f32,
) -> i32;
pub(super) fn ds4_gpu_shared_mid_swiglu_q8_0_tensor(
mid: *mut GpuTensor,
map: *const c_void,
size: u64,
gate_weight: u64,
up_weight: u64,
input: u64,
output: u64,
x: *const GpuTensor,
clamp: f32,
) -> i32;
pub(super) fn ds4_gpu_shared_gate_up_swiglu_q8_0_model_view_tensor(
gate: *mut GpuTensor,
up: *mut GpuTensor,
mid: *mut GpuTensor,
map: *const c_void,
size: u64,
gate_weight: u64,
up_weight: u64,
input: u64,
output: u64,
x: *const GpuTensor,
clamp: f32,
) -> i32;
pub(super) fn ds4_gpu_shared_gate_up_swiglu_q8_0_rows_tensor(
gate: *mut GpuTensor,
up: *mut GpuTensor,
mid: *mut GpuTensor,
map: *const c_void,
size: u64,
gate_weight: u64,
up_weight: u64,
input: u64,
output: u64,
x: *const GpuTensor,
rows: u64,
clamp: f32,
) -> i32;
pub(super) fn ds4_gpu_router_shared_gate_up_q8_0_tensor(
router_logits: *mut GpuTensor,
gate: *mut GpuTensor,
@@ -1598,6 +1862,21 @@ impl Context {
return Err(error);
}
}
if let Some(vision) = &model.vision {
let mapped = unsafe {
ds4_gpu_set_model_map_range(
vision.map_ptr().cast(),
vision.len(),
vision.data_offset(),
vision.len() - vision.data_offset(),
vision.max_tensor_bytes(),
)
};
if let Err(error) = check(mapped, "vision-model mapping") {
unsafe { ds4_gpu_cleanup() };
return Err(error);
}
}
unsafe { ds4_gpu_set_quality(quality) };
let model_file = File::open(model.main.path()).map_err(|error| {
unsafe { ds4_gpu_cleanup() };

311
src/engine/metal/vision.rs Normal file
View File

@@ -0,0 +1,311 @@
use super::gpu::{Glm53VisionLayerWeights, Glm53VisionWeights, ds4_gpu_glm53_vision_encode};
use super::{Gguf, call};
use image::{DynamicImage, ImageDecoder, ImageReader};
use std::io::Cursor;
const EMBEDDING: usize = 4096;
const PATCH: u32 = 14;
const MERGE: u32 = 2;
const MIN_TOKENS: u32 = 16;
const MAX_TOKENS: u32 = 8000;
pub(super) struct VisionEncoder {
weights: Glm53VisionWeights,
}
pub(in crate::engine) struct VisionEmbedding {
pub(in crate::engine) values: Vec<f32>,
pub(in crate::engine) tokens: u32,
pub(in crate::engine) width: u32,
pub(in crate::engine) height: u32,
pub(in crate::engine) content_width: u32,
pub(in crate::engine) content_height: u32,
}
impl VisionEncoder {
pub(super) fn bind(model: &Gguf) -> Result<Self, String> {
let offset = |name: &str| model.tensor(name).map(|tensor| tensor.offset);
let mut weights = Glm53VisionWeights {
patch_weight: offset("model.visual.patch_embed.proj.weight")?,
patch_bias: offset("model.visual.patch_embed.proj.bias")?,
post_norm: offset("model.visual.post_layernorm.weight")?,
downsample_weight: offset("model.visual.downsample.weight")?,
downsample_bias: offset("model.visual.downsample.bias")?,
merger_proj: offset("model.visual.merger.proj.weight")?,
merger_norm: offset("model.visual.merger.post_projection_norm.weight")?,
merger_norm_bias: offset("model.visual.merger.post_projection_norm.bias")?,
merger_gate: offset("model.visual.merger.gate_proj.weight")?,
merger_up: offset("model.visual.merger.up_proj.weight")?,
merger_down: offset("model.visual.merger.down_proj.weight")?,
..Glm53VisionWeights::default()
};
for (layer, target) in weights.layer.iter_mut().enumerate() {
let name = |suffix: &str| format!("model.visual.blocks.{layer}.{suffix}");
*target = Glm53VisionLayerWeights {
norm1: offset(&name("norm1.weight"))?,
qkv_weight: offset(&name("attn.qkv.weight"))?,
qkv_bias: offset(&name("attn.qkv.bias"))?,
q_norm: offset(&name("attn.q_norm.weight"))?,
k_norm: offset(&name("attn.k_norm.weight"))?,
attn_proj_weight: offset(&name("attn.proj.weight"))?,
attn_proj_bias: offset(&name("attn.proj.bias"))?,
norm2: offset(&name("norm2.weight"))?,
gate_weight: offset(&name("mlp.gate_proj.weight"))?,
gate_bias: offset(&name("mlp.gate_proj.bias"))?,
up_weight: offset(&name("mlp.up_proj.weight"))?,
up_bias: offset(&name("mlp.up_proj.bias"))?,
down_weight: offset(&name("mlp.down_proj.weight"))?,
down_bias: offset(&name("mlp.down_proj.bias"))?,
};
}
Ok(Self { weights })
}
pub(super) fn encode(&self, model: &Gguf, encoded: &[u8]) -> Result<VisionEmbedding, String> {
if encoded.is_empty() || encoded.len() > 64 * 1024 * 1024 {
return Err("image is empty or exceeds the 64 MiB encoded limit".into());
}
let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format()
.map_err(|error| error.to_string())?;
let mut decoder = reader.into_decoder().map_err(|error| error.to_string())?;
let orientation = decoder.orientation().map_err(|error| error.to_string())?;
let mut image = DynamicImage::from_decoder(decoder).map_err(|error| error.to_string())?;
image.apply_orientation(orientation);
let rgb = image.into_rgb8();
let (width, height) = rgb.dimensions();
if width == 0
|| height == 0
|| width > 16_384
|| height > 16_384
|| u64::from(width) * u64::from(height) > 64 * 1024 * 1024
{
return Err("image dimensions exceed the GLM 5.3 vision limits".into());
}
let patches = preprocess(rgb.as_raw(), width, height)?;
let tokens = patches.grid_height * patches.grid_width / 4;
let mut values = vec![0.0_f32; tokens as usize * EMBEDDING];
call(
unsafe {
ds4_gpu_glm53_vision_encode(
values.as_mut_ptr(),
patches.values.as_ptr(),
patches.grid_height,
patches.grid_width,
model.map_ptr().cast(),
model.len(),
&self.weights,
)
},
"encoding a GLM 5.3 image",
)?;
Ok(VisionEmbedding {
values,
tokens,
width,
height,
content_width: patches.content_width,
content_height: patches.content_height,
})
}
}
struct Patches {
values: Vec<f32>,
content_width: u32,
content_height: u32,
grid_width: u32,
grid_height: u32,
}
fn preprocess(rgb: &[u8], width: u32, height: u32) -> Result<Patches, String> {
const MEAN: [f32; 3] = [0.48145466, 0.4578275, 0.40821073];
const STDDEV: [f32; 3] = [0.26862954, 0.261_302_6, 0.275_777_1];
let (target_height, target_width) = smart_resize(height, width)?;
let mut scale = (target_height as f64 / height as f64).min(target_width as f64 / width as f64);
if 2 * u64::from(height) * u64::from(width) >= 2 * 28 * 28 * MIN_TOKENS as u64 && scale > 1.0 {
scale = 1.0;
}
let content_height = ((height as f64 * scale).floor() as u32).clamp(1, target_height);
let content_width = ((width as f64 * scale).floor() as u32).clamp(1, target_width);
let mut canvas = vec![0.0_f32; target_height as usize * target_width as usize * 3];
if content_width == width && content_height == height {
for y in 0..height {
for x in 0..width {
let source = (y as usize * width as usize + x as usize) * 3;
let target = (y as usize * target_width as usize + x as usize) * 3;
for channel in 0..3 {
canvas[target + channel] = f32::from(rgb[source + channel]);
}
}
}
} else {
resize_bicubic(
rgb,
width,
height,
&mut canvas,
content_width,
content_height,
target_width,
);
}
for y in 0..target_height {
for x in 0..target_width {
let pixel = (y as usize * target_width as usize + x as usize) * 3;
for channel in 0..3 {
let value = if x < content_width && y < content_height {
canvas[pixel + channel]
} else {
0.0
};
canvas[pixel + channel] = (value / 255.0 - MEAN[channel]) / STDDEV[channel];
}
}
}
let grid_height = target_height / PATCH;
let grid_width = target_width / PATCH;
let patch_values = grid_height as usize * grid_width as usize * 3 * 2 * 14 * 14;
let mut values = Vec::with_capacity(patch_values);
for block_y in 0..grid_height / MERGE {
for block_x in 0..grid_width / MERGE {
for merge_y in 0..MERGE {
for merge_x in 0..MERGE {
let patch_y = block_y * MERGE + merge_y;
let patch_x = block_x * MERGE + merge_x;
for channel in 0..3 {
for _ in 0..2 {
for y in 0..PATCH {
for x in 0..PATCH {
let pixel = ((patch_y * PATCH + y) as usize
* target_width as usize
+ (patch_x * PATCH + x) as usize)
* 3;
values.push(canvas[pixel + channel]);
}
}
}
}
}
}
}
}
if values.len() != patch_values {
return Err("internal GLM 5.3 vision patch layout mismatch".into());
}
Ok(Patches {
values,
content_width,
content_height,
grid_width,
grid_height,
})
}
fn smart_resize(height: u32, width: u32) -> Result<(u32, u32), String> {
let factor = 28_u32;
let align = |value: u32| value.div_ceil(factor) * factor;
let pixels_per_token = 2_u64 * factor as u64 * factor as u64;
let min_pixels = MIN_TOKENS as u64 * pixels_per_token;
let max_pixels = MAX_TOKENS as u64 * pixels_per_token;
let mut aligned_height = align(height);
let mut aligned_width = align(width);
let mut budget = 2_u64 * aligned_height as u64 * aligned_width as u64;
if budget < min_pixels {
let scale = (min_pixels as f64 / (2.0 * height as f64 * width as f64)).sqrt();
aligned_height = align((height as f64 * scale).ceil() as u32);
aligned_width = align((width as f64 * scale).ceil() as u32);
budget = 2_u64 * aligned_height as u64 * aligned_width as u64;
}
if budget > max_pixels {
let (mut low, mut high) = (1_u32, height);
aligned_height = factor;
aligned_width = factor;
while low <= high {
let content_height = low + (high - low) / 2;
let content_width =
((width as f64 * content_height as f64 / height as f64).floor() as u32).max(1);
let candidate_height = align(content_height);
let candidate_width = align(content_width);
if 2_u64 * candidate_height as u64 * candidate_width as u64 <= max_pixels {
aligned_height = candidate_height;
aligned_width = candidate_width;
low = content_height + 1;
} else {
high = content_height - 1;
}
}
}
Ok((aligned_height, aligned_width))
}
#[allow(clippy::too_many_arguments)]
fn resize_bicubic(
source: &[u8],
source_width: u32,
source_height: u32,
target: &mut [f32],
target_width: u32,
target_height: u32,
target_stride: u32,
) {
let scale_x = source_width as f64 / target_width as f64;
let scale_y = source_height as f64 / target_height as f64;
let filter_x = if scale_x >= 1.0 { 1.0 / scale_x } else { 1.0 };
let filter_y = if scale_y >= 1.0 { 1.0 / scale_y } else { 1.0 };
let support_x = if scale_x >= 1.0 { 2.0 * scale_x } else { 2.0 };
let support_y = if scale_y >= 1.0 { 2.0 * scale_y } else { 2.0 };
for dy in 0..target_height {
let center_y = scale_y * (dy as f64 + 0.5);
let y0 = (center_y - support_y + 0.5).max(0.0) as u32;
let y1 = (center_y + support_y + 0.5).min(source_height as f64) as u32;
for dx in 0..target_width {
let center_x = scale_x * (dx as f64 + 0.5);
let x0 = (center_x - support_x + 0.5).max(0.0) as u32;
let x1 = (center_x + support_x + 0.5).min(source_width as f64) as u32;
let mut sum = [0.0; 3];
let mut weight_sum = 0.0;
for iy in y0..y1 {
let wy = cubic((iy as f64 + 0.5 - center_y) * filter_y);
for ix in x0..x1 {
let weight = wy * cubic((ix as f64 + 0.5 - center_x) * filter_x);
let pixel = (iy as usize * source_width as usize + ix as usize) * 3;
for channel in 0..3 {
sum[channel] += source[pixel + channel] as f64 * weight;
}
weight_sum += weight;
}
}
let pixel = (dy as usize * target_stride as usize + dx as usize) * 3;
for channel in 0..3 {
target[pixel + channel] =
(sum[channel] / weight_sum).round().clamp(0.0, 255.0) as f32;
}
}
}
}
fn cubic(mut x: f64) -> f64 {
const A: f64 = -0.5;
x = x.abs();
if x < 1.0 {
((A + 2.0) * x - (A + 3.0)) * x * x + 1.0
} else if x < 2.0 {
((A * x - 5.0 * A) * x + 8.0 * A) * x - 4.0 * A
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::smart_resize;
#[test]
fn glm53_resize_matches_reference_token_grids() {
assert_eq!(smart_resize(28, 28), Ok((112, 112)));
assert_eq!(smart_resize(1024, 1024), Ok((1036, 1036)));
assert_eq!(smart_resize(1080, 1920), Ok((1092, 1932)));
}
}

View File

@@ -1,5 +1,8 @@
use super::gguf::Gguf;
use super::{ChatTurn, ModelFamily};
use super::{
ChatTurn, ModelFamily, VISION_END_TOKEN, VISION_IMAGE_TOKEN, VISION_START_TOKEN,
VISION_TOKEN_END, VISION_TOKEN_START,
};
use crate::settings::ReasoningMode;
use std::collections::HashMap;
@@ -160,6 +163,22 @@ impl Tokenizer {
let mut span = 0;
let mut position = 0;
while position < bytes.len() {
if bytes[position..].starts_with(VISION_TOKEN_START.as_bytes())
&& let Some(relative_end) =
text[position + VISION_TOKEN_START.len()..].find(VISION_TOKEN_END)
{
let count_start = position + VISION_TOKEN_START.len();
let count_end = count_start + relative_end;
if let Ok(count) = text[count_start..count_end].parse::<usize>() {
self.tokenize_plain(&text[span..position], &mut output);
output.push(VISION_START_TOKEN);
output.extend(std::iter::repeat_n(VISION_IMAGE_TOKEN, count));
output.push(VISION_END_TOKEN);
position = count_end + VISION_TOKEN_END.len();
span = position;
continue;
}
}
let special = self
.rendered_specials
.iter()

View File

@@ -9,7 +9,7 @@ pub(crate) fn validate_model_artifact(
let model = Gguf::open(path)?;
let shape = match expected {
ModelChoice::DeepSeekV4Flash0731 => FLASH_0731,
ModelChoice::DeepSeekV4Pro | ModelChoice::Glm52 => {
ModelChoice::DeepSeekV4Pro | ModelChoice::Glm52 | ModelChoice::Glm53Flash => {
return Err(format!("{expected} does not use an external support GGUF"));
}
};
@@ -35,6 +35,79 @@ pub(crate) fn validate_model_artifact(
}
}
pub(crate) fn validate_vision_artifact(path: &Path) -> Result<(), String> {
let model = Gguf::open(path)?;
if model.bytes("general.architecture")? != b"glm5-next-vision" {
return Err("vision GGUF architecture is not glm5-next-vision".into());
}
if model.tensors.len() != 347 {
return Err(format!(
"vision GGUF has {} tensors, expected 347",
model.tensors.len()
));
}
for (key, expected) in [
("block_count", 24),
("embedding_length", 1024),
("feed_forward_length", 4096),
("attention.head_count", 16),
("projection_length", 4096),
("projection.feed_forward_length", 10_240),
("patch_size", 14),
("temporal_patch_size", 2),
("spatial_merge_size", 2),
("image_token_id", VISION_IMAGE_TOKEN as u64),
("image_start_token_id", VISION_START_TOKEN as u64),
("image_end_token_id", VISION_END_TOKEN as u64),
] {
expect_u64(&model, &format!("glm5-next-vision.{key}"), expected)?;
}
let bf16 = &[BF16];
for (name, dims) in [
(
"model.visual.patch_embed.proj.weight",
vec![14, 14, 2, 3, 1024],
),
("model.visual.patch_embed.proj.bias", vec![1024]),
("model.visual.post_layernorm.weight", vec![1024]),
("model.visual.downsample.weight", vec![2, 2, 1024, 4096]),
("model.visual.downsample.bias", vec![4096]),
("model.visual.merger.proj.weight", vec![4096, 4096]),
(
"model.visual.merger.post_projection_norm.weight",
vec![4096],
),
("model.visual.merger.post_projection_norm.bias", vec![4096]),
("model.visual.merger.gate_proj.weight", vec![4096, 10_240]),
("model.visual.merger.up_proj.weight", vec![4096, 10_240]),
("model.visual.merger.down_proj.weight", vec![10_240, 4096]),
] {
expect(&model, name, bf16, &dims)?;
}
for layer in 0..24 {
let name = |suffix: &str| format!("model.visual.blocks.{layer}.{suffix}");
for (suffix, dims) in [
("norm1.weight", vec![1024]),
("attn.qkv.weight", vec![1024, 3072]),
("attn.qkv.bias", vec![3072]),
("attn.q_norm.weight", vec![64]),
("attn.k_norm.weight", vec![64]),
("attn.proj.weight", vec![1024, 1024]),
("attn.proj.bias", vec![1024]),
("norm2.weight", vec![1024]),
("mlp.gate_proj.weight", vec![1024, 4096]),
("mlp.gate_proj.bias", vec![4096]),
("mlp.up_proj.weight", vec![1024, 4096]),
("mlp.up_proj.bias", vec![4096]),
("mlp.down_proj.weight", vec![4096, 1024]),
("mlp.down_proj.bias", vec![1024]),
] {
expect(&model, &name(suffix), bf16, &dims)?;
}
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SupportKind {
DSpark,
@@ -116,14 +189,11 @@ pub(super) fn validate_support(model: &Gguf, shape: &Shape) -> Result<SupportKin
}
pub(super) fn validate_main(model: &Gguf, expected: ModelChoice) -> Result<Shape, String> {
let family = if model.bytes("general.architecture").ok() == Some(b"glm-dsa") {
ModelFamily::Glm
} else {
ModelFamily::DeepSeek
};
let shape = match family {
ModelFamily::Glm => GLM,
ModelFamily::DeepSeek => match (model.u32("deepseek4.block_count")?, expected) {
let architecture = model.bytes("general.architecture")?;
let shape = match architecture {
b"glm-dsa" => GLM,
b"glm5-next" => GLM53_FLASH,
_ => match (model.u32("deepseek4.block_count")?, expected) {
(43, ModelChoice::DeepSeekV4Flash0731) => FLASH_0731,
(43, _) => FLASH_0731,
(61, _) => PRO,
@@ -143,6 +213,9 @@ pub(super) fn validate_main(model: &Gguf, expected: ModelChoice) -> Result<Shape
}
fn validate_metadata(model: &Gguf, shape: &Shape) -> Result<(), String> {
if shape.model == ModelChoice::Glm53Flash {
return validate_glm53_metadata(model, shape);
}
let prefix = if shape.family == ModelFamily::Glm {
"glm-dsa"
} else {
@@ -266,13 +339,102 @@ fn validate_metadata(model: &Gguf, shape: &Shape) -> Result<(), String> {
Ok(())
}
fn validate_glm53_metadata(model: &Gguf, shape: &Shape) -> Result<(), String> {
let prefix = "glm5-next";
for (key, expected) in [
("block_count", u64::from(shape.layers)),
("trunk_block_count", u64::from(shape.layers - shape.nextn)),
("nextn_predict_layers", u64::from(shape.nextn)),
("context_length", shape.original_context),
("embedding_length", shape.embd),
("vocab_size", shape.vocab),
("feed_forward_length", shape.ff_dense),
("expert_feed_forward_length", shape.ff_expert),
("expert_count", shape.experts),
("expert_used_count", shape.experts_used),
("expert_shared_count", shape.expert_shared),
("leading_dense_block_count", u64::from(shape.leading_dense)),
("attention.head_count", shape.heads),
("attention.key_length", shape.key_mla),
("attention.value_length", shape.value_mla),
("attention.q_lora_rank", shape.lora_q),
("attention.kv_lora_rank", shape.kv_lora),
("attention.rope_dimension_count", shape.rot),
("attention.indexer.head_count", shape.indexer_heads),
("attention.indexer.key_length", shape.indexer_head_dim),
("attention.indexer.top_k", shape.indexer_top_k),
("attention.indexer.pool_size", 4),
("linear_attention.head_count", 64),
("linear_attention.head_dimension", 128),
("linear_attention.conv_kernel", 4),
("hyper_connection.count", shape.hc),
("hyper_connection.sinkhorn_iterations", shape.hc_sinkhorn),
] {
expect_u64(model, &format!("{prefix}.{key}"), expected)?;
}
for (key, expected) in [
("expert_weights_scale", shape.expert_weight_scale),
("swiglu_limit", shape.swiglu_clamp),
("attention.layer_norm_rms_epsilon", shape.rms_epsilon),
("linear_attention.gate_lower_bound", -5.0),
("hyper_connection.epsilon", shape.hc_epsilon),
] {
expect_float(model, &format!("{prefix}.{key}"), expected)?;
}
if !model.boolean("glm5-next.expert_weights_norm")? {
return Err("glm5-next.expert_weights_norm must be true".into());
}
let layer_types = model.u32s("glm5-next.layer_types")?;
if layer_types.len() != shape.layers as usize {
return Err("glm5-next.layer_types must contain one entry per layer".into());
}
for (layer, &kind) in layer_types.iter().enumerate() {
let expected =
u32::from(layer + shape.nextn as usize >= shape.layers as usize || layer % 4 == 3);
if kind != expected {
return Err(format!(
"unexpected GLM 5.3 attention type at layer {layer}"
));
}
}
Ok(())
}
fn validate_tensors(model: &Gguf, shape: &Shape) -> Result<(), String> {
match shape.family {
ModelFamily::DeepSeek => validate_deepseek_tensors(model, shape),
ModelFamily::Glm if shape.model == ModelChoice::Glm53Flash => {
validate_glm53_tensors(model, shape)
}
ModelFamily::Glm => validate_glm_tensors(model, shape),
}
}
fn validate_glm53_tensors(model: &Gguf, shape: &Shape) -> Result<(), String> {
expect(
model,
"token_embd.weight",
DENSE,
&[shape.embd, shape.vocab],
)?;
expect(model, "output_norm.weight", &[F32], &[shape.embd])?;
expect(model, "output.weight", DENSE, &[shape.embd, shape.vocab])?;
expect(model, "blk.0.kda_q.weight", DENSE, &[shape.embd, 8192])?;
expect(
model,
"blk.3.attn_q_a.weight",
DENSE,
&[shape.embd, shape.lora_q],
)?;
expect(
model,
"blk.45.nextn.eh_proj.weight",
DENSE,
&[2 * shape.embd, shape.embd],
)?;
Ok(())
}
fn validate_deepseek_tensors(model: &Gguf, shape: &Shape) -> Result<(), String> {
let hc_dim = shape.embd * shape.hc;
let hc_mix = 2 * shape.hc + shape.hc * shape.hc;
@@ -902,7 +1064,7 @@ fn compression_ratio(shape: &Shape, layer: u32) -> u32 {
4
}
ModelChoice::DeepSeekV4Flash0731 | ModelChoice::DeepSeekV4Pro => 128,
ModelChoice::Glm52 => 0,
ModelChoice::Glm52 | ModelChoice::Glm53Flash => 0,
}
}
@@ -1157,4 +1319,18 @@ mod tests {
validate_model_artifact(&path, ModelChoice::DeepSeekV4Flash0731, true).unwrap();
}
}
#[test]
fn configured_glm53_vision_fixture_passes_the_exact_layout() {
if let Some(path) = std::env::var_os("DS4SERVER_GLM53_VISION") {
validate_vision_artifact(Path::new(&path)).unwrap();
}
}
#[test]
fn configured_glm53_main_fixture_passes_the_exact_layout() {
if let Some(path) = std::env::var_os("DS4SERVER_GLM53_MODEL") {
validate_model_artifact(Path::new(&path), ModelChoice::Glm53Flash, false).unwrap();
}
}
}

View File

@@ -847,6 +847,7 @@ fn model_code(model: ModelChoice) -> u8 {
ModelChoice::DeepSeekV4Pro => 2,
ModelChoice::Glm52 => 3,
ModelChoice::DeepSeekV4Flash0731 => 4,
ModelChoice::Glm53Flash => 5,
}
}
@@ -856,6 +857,7 @@ fn model_name(value: u8) -> &'static str {
2 => "DeepSeek V4 Pro",
3 => "GLM 5.2",
4 => "DeepSeek V4 Flash 0731",
5 => "GLM 5.3 Flash Q2",
_ => "No model loaded",
}
}

View File

@@ -9,20 +9,24 @@ use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [
pub(crate) const MODEL_CHOICES: [ModelChoice; 4] = [
ModelChoice::DeepSeekV4Flash0731,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
ModelChoice::Glm53Flash,
];
pub(crate) const MANAGED_ARTIFACTS: [ManagedArtifactId; 4] = [
pub(crate) const MANAGED_ARTIFACTS: [ManagedArtifactId; 6] = [
ManagedArtifactId::DeepSeekV4Flash0731,
ManagedArtifactId::DeepSeekV4Flash0731Dspark,
ManagedArtifactId::DeepSeekV4Pro,
ManagedArtifactId::Glm52,
ManagedArtifactId::Glm53Flash,
ManagedArtifactId::Glm53FlashVision,
];
const DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf";
const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf";
const GLM53_REPOSITORY: &str = "antirez/glm-5.3-flash-gguf";
const FLASH_0731: Artifact = Artifact {
label: "DeepSeek V4 Flash 0731 model",
@@ -31,6 +35,7 @@ const FLASH_0731: Artifact = Artifact {
size: 86_720_111_488,
sha256: "ca22ae2f838e14077c22bc1c1417b71b45b5e5a3687bd96c2ac6e17fdb6261c0",
support: Some(false),
vision: false,
};
const FLASH_0731_DSPARK: Artifact = Artifact {
label: "DeepSeek V4 Flash 0731 DSpark support",
@@ -39,6 +44,7 @@ const FLASH_0731_DSPARK: Artifact = Artifact {
size: 5_989_114_272,
sha256: "7e319924541db3f7a163ed7e11d7532a70d48228ab59d36cb81e1d4511885360",
support: Some(true),
vision: false,
};
const PRO: Artifact = Artifact {
label: "DeepSeek V4 Pro 0813 model",
@@ -47,6 +53,7 @@ const PRO: Artifact = Artifact {
size: 464_627_334_560,
sha256: "c4d997ab9894b6c78b759f7869fe1726b6314b6515f6ff82607df3797c5eb193",
support: Some(false),
vision: false,
};
const GLM: Artifact = Artifact {
label: "GLM 5.2 model",
@@ -55,6 +62,25 @@ const GLM: Artifact = Artifact {
size: 211_075_856_448,
sha256: "a49de64c5020432bdae23de36a423a9660a5621bc0db8d12b66bd8814b07fea0",
support: Some(false),
vision: false,
};
const GLM53_FLASH: Artifact = Artifact {
label: "GLM 5.3 Flash Q2 model",
file_name: "GLM-5.3-Flash-Q2.gguf",
repository: GLM53_REPOSITORY,
size: 96_505_816_384,
sha256: "e81fd6241c6e55a64e1e14e47a3eab61a173fa8d7e4b5c1d1848827119705b32",
support: Some(false),
vision: false,
};
const GLM53_FLASH_VISION: Artifact = Artifact {
label: "GLM 5.3 Flash vision encoder",
file_name: "GLM-5.3-Flash-Vision-Encoder.gguf",
repository: GLM53_REPOSITORY,
size: 1_127_280_960,
sha256: "ae23e14c6979e889051b2e4a39351abcdafb161e18e606fae4d8c40095a4bf3a",
support: None,
vision: true,
};
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
@@ -66,6 +92,8 @@ pub(crate) enum ModelChoice {
DeepSeekV4Pro,
#[serde(rename = "glm-5.2")]
Glm52,
#[serde(rename = "glm-5.3-flash")]
Glm53Flash,
}
impl ModelChoice {
@@ -74,6 +102,7 @@ impl ModelChoice {
Self::DeepSeekV4Flash0731 => "deepseek-v4-flash-0731",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
Self::Glm53Flash => "glm-5.3-flash",
}
}
@@ -85,26 +114,40 @@ impl ModelChoice {
self == Self::DeepSeekV4Flash0731
}
pub(crate) fn is_glm(self) -> bool {
matches!(self, Self::Glm52 | Self::Glm53Flash)
}
pub(crate) fn supports_glm_mtp(self) -> bool {
self.is_glm()
}
fn main_artifact(self) -> &'static Artifact {
match self {
Self::DeepSeekV4Flash0731 => &FLASH_0731,
Self::DeepSeekV4Pro => &PRO,
Self::Glm52 => &GLM,
Self::Glm53Flash => &GLM53_FLASH,
}
}
fn dspark_artifact(self) -> Option<&'static Artifact> {
match self {
Self::DeepSeekV4Flash0731 => Some(&FLASH_0731_DSPARK),
Self::DeepSeekV4Pro | Self::Glm52 => None,
Self::DeepSeekV4Pro | Self::Glm52 | Self::Glm53Flash => None,
}
}
fn vision_artifact(self) -> Option<&'static Artifact> {
(self == Self::Glm53Flash).then_some(&GLM53_FLASH_VISION)
}
#[cfg(test)]
fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> {
[
Some(self.main_artifact()),
dspark_enabled.then(|| self.dspark_artifact()).flatten(),
self.vision_artifact(),
]
.into_iter()
.flatten()
@@ -115,6 +158,7 @@ impl ModelChoice {
pub(crate) struct EngineArtifacts {
pub(crate) model: PathBuf,
pub(crate) support: Option<PathBuf>,
pub(crate) vision: Option<PathBuf>,
}
pub(crate) fn engine_artifacts(
@@ -131,6 +175,11 @@ pub(crate) fn engine_artifacts(
} else {
None
},
vision: model.vision_artifact().and_then(|artifact| {
artifact
.is_installed(model, models_path)
.then(|| artifact.path(model, models_path))
}),
}
}
@@ -158,7 +207,14 @@ pub(crate) fn validate_engine_artifacts(
"{} is not compatible with the selected {model} checkpoint",
path.display()
)),
}?;
if let Some(path) = artifacts.vision.as_deref() {
model
.vision_artifact()
.ok_or_else(|| format!("{} is not compatible with {model}", path.display()))?
.validate_installed_path(path)?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -167,6 +223,8 @@ pub(crate) enum ManagedArtifactId {
DeepSeekV4Flash0731Dspark,
DeepSeekV4Pro,
Glm52,
Glm53Flash,
Glm53FlashVision,
}
impl ManagedArtifactId {
@@ -177,6 +235,7 @@ impl ManagedArtifactId {
}
Self::DeepSeekV4Pro => ModelChoice::DeepSeekV4Pro,
Self::Glm52 => ModelChoice::Glm52,
Self::Glm53Flash | Self::Glm53FlashVision => ModelChoice::Glm53Flash,
}
}
@@ -186,6 +245,8 @@ impl ManagedArtifactId {
Self::DeepSeekV4Flash0731Dspark => &FLASH_0731_DSPARK,
Self::DeepSeekV4Pro => &PRO,
Self::Glm52 => &GLM,
Self::Glm53Flash => &GLM53_FLASH,
Self::Glm53FlashVision => &GLM53_FLASH_VISION,
}
}
}
@@ -280,6 +341,7 @@ impl fmt::Display for ModelChoice {
Self::DeepSeekV4Flash0731 => "DeepSeek V4 Flash 0731",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro 0813",
Self::Glm52 => "GLM 5.2",
Self::Glm53Flash => "GLM 5.3 Flash Q2",
})
}
}
@@ -291,6 +353,7 @@ struct Artifact {
size: u64,
sha256: &'static str,
support: Option<bool>,
vision: bool,
}
impl Artifact {

View File

@@ -205,7 +205,9 @@ fn verify(
path.display()
));
}
if let Some(support) = artifact.support {
if artifact.vision {
crate::engine::validate_vision_artifact(path)?;
} else if let Some(support) = artifact.support {
crate::engine::validate_model_artifact(path, model, support)?;
}
Ok(DownloadOutcome::Complete)
@@ -330,8 +332,8 @@ mod tests {
Some(ModelChoice::DeepSeekV4Flash0731)
);
assert!(ModelChoice::from_id("unknown").is_none());
assert_eq!(MODEL_CHOICES.len(), 3);
assert_eq!(MANAGED_ARTIFACTS.len(), 4);
assert_eq!(MODEL_CHOICES.len(), 4);
assert_eq!(MANAGED_ARTIFACTS.len(), 6);
assert_eq!(ModelChoice::Glm52.main_artifact().size, 211_075_856_448);
assert_eq!(
ModelChoice::DeepSeekV4Flash0731.main_artifact().size,
@@ -339,6 +341,7 @@ mod tests {
);
assert_eq!(ModelChoice::DeepSeekV4Flash0731.artifacts(true).count(), 2);
assert_eq!(ModelChoice::Glm52.artifacts(true).count(), 1);
assert_eq!(ModelChoice::Glm53Flash.artifacts(true).count(), 2);
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -366,6 +369,7 @@ mod tests {
size: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
support: None,
vision: false,
};
let partial = empty.partial_path(ModelChoice::DeepSeekV4Flash0731, &models_path);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
@@ -393,6 +397,7 @@ mod tests {
&EngineArtifacts {
model: installed.clone(),
support: None,
vision: None,
},
)
.is_err()
@@ -404,6 +409,7 @@ mod tests {
&EngineArtifacts {
model: installed,
support: None,
vision: None,
},
)
.is_err()
@@ -428,6 +434,7 @@ mod tests {
size: 3,
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
support: None,
vision: false,
};
let verified_bytes = AtomicU64::new(999);
@@ -593,6 +600,7 @@ mod tests {
size: 10,
sha256: "unused",
support: None,
vision: false,
};
let partial = artifact.partial_path(ModelChoice::DeepSeekV4Flash0731, &directory);
fs::create_dir_all(partial.parent().unwrap()).unwrap();

View File

@@ -554,6 +554,12 @@ fn model_alias(id: &str) -> Option<ModelChoice> {
| "zai/glm-5.2"
| "zai/glm-5.2-chat"
| "zai/glm-5.2-reasoner" => Some(ModelChoice::Glm52),
"glm-5.3-flash"
| "glm-5.3-flash-chat"
| "glm-5.3-flash-reasoner"
| "zai/glm-5.3-flash"
| "zai/glm-5.3-flash-chat"
| "zai/glm-5.3-flash-reasoner" => Some(ModelChoice::Glm53Flash),
_ => ModelChoice::from_id(id),
}
}
@@ -900,6 +906,13 @@ mod tests {
content_text(&json!([{"type": "text", "text": "one"}, " two"])),
"one two"
);
assert_eq!(
content_text(&json!([{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,YQ=="}
}])),
"<|ds4server_image_data|>data:image/png;base64,YQ==<|/ds4server_image_data|>"
);
}
#[test]

View File

@@ -54,6 +54,23 @@ pub(super) fn anthropic_request(value: Value) -> Result<ChatRequest, (u16, Strin
for block in blocks {
match block.get("type").and_then(Value::as_str).unwrap_or("text") {
"text" => text.push_str(block.get("text").and_then(Value::as_str).unwrap_or("")),
"image" => {
let source = block.get("source").and_then(Value::as_object);
let media = source
.and_then(|source| source.get("media_type"))
.and_then(Value::as_str)
.unwrap_or("");
let data = source
.and_then(|source| source.get("data"))
.and_then(Value::as_str)
.unwrap_or("");
if !matches!(media, "image/png" | "image/jpeg") || data.is_empty() {
return Err((400, "image input must be inline PNG or JPEG base64".into()));
}
text.push_str(&crate::engine::vision_data_marker(&format!(
"data:{media};base64,{data}"
)));
}
"thinking" | "redacted_thinking" => reasoning.push_str(
block
.get("thinking")
@@ -382,6 +399,24 @@ fn responses_content_text(value: &Value) -> Result<String, (u16, String)> {
text.push_str(value);
}
}
Value::Object(part)
if part.get("type").and_then(Value::as_str) == Some("input_image") =>
{
let url = part
.get("image_url")
.or_else(|| part.get("url"))
.and_then(Value::as_str)
.ok_or_else(|| (400, "invalid image input".into()))?;
if !url.starts_with("data:image/png;base64,")
&& !url.starts_with("data:image/jpeg;base64,")
{
return Err((
400,
"image input must be an inline PNG or JPEG data URI".into(),
));
}
text.push_str(&crate::engine::vision_data_marker(url));
}
_ => return Err((400, "invalid JSON request".into())),
}
}
@@ -574,7 +609,12 @@ fn request_reasoning(
|| (explicit_thinking.is_none()
&& matches!(
model_id,
"deepseek-chat" | "glm-5.2-chat" | "glm-5.2-no-think" | "glm-5.2-nothink"
"deepseek-chat"
| "glm-5.2-chat"
| "glm-5.2-no-think"
| "glm-5.2-nothink"
| "glm-5.3-flash-chat"
| "zai/glm-5.3-flash-chat"
))
{
reasoning = ReasoningMode::Direct;

View File

@@ -365,6 +365,7 @@ pub(super) fn render_messages(
let mut turns = Vec::<ChatTurn>::new();
for message in messages {
validate_inline_images(&message.content)?;
let content = content_text(&message.content);
match message.role.as_str() {
"system" | "developer" => {
@@ -426,6 +427,30 @@ pub(super) fn render_messages(
Ok((system, turns))
}
fn validate_inline_images(value: &Value) -> Result<(), (u16, String)> {
let Some(parts) = value.as_array() else {
return Ok(());
};
for part in parts {
let Some(image) = part.get("image_url") else {
continue;
};
let url = image
.get("url")
.or(Some(image))
.and_then(Value::as_str)
.ok_or_else(|| (400, "invalid image input".into()))?;
if !url.starts_with("data:image/png;base64,") && !url.starts_with("data:image/jpeg;base64,")
{
return Err((
400,
"image input must be an inline PNG or JPEG data URI".into(),
));
}
}
Ok(())
}
pub(super) fn validate_tool_results(
state: &State,
messages: &[ApiMessage],
@@ -779,8 +804,18 @@ pub(super) fn content_text(value: &Value) -> String {
Value::Array(parts) => parts
.iter()
.filter_map(|part| match part {
Value::String(text) => Some(text.as_str()),
Value::Object(object) => object.get("text").and_then(Value::as_str),
Value::String(text) => Some(text.clone()),
Value::Object(object) => object
.get("text")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| {
let url = object
.get("image_url")
.and_then(|value| value.get("url").or(Some(value)))
.and_then(Value::as_str)?;
Some(crate::engine::vision_data_marker(url))
}),
_ => None,
})
.collect(),

View File

@@ -34,8 +34,8 @@ impl SpeculativePreferences {
if self.glm_mtp_timing && !self.glm_mtp {
return Err("GLM MTP timing requires GLM MTP.".into());
}
if model != ModelChoice::Glm52 && (self.glm_mtp || self.glm_mtp_timing) {
return Err("GLM MTP is available only for GLM 5.2.".into());
if !model.supports_glm_mtp() && (self.glm_mtp || self.glm_mtp_timing) {
return Err("GLM MTP is available only for GLM models.".into());
}
if self.dspark_enabled && !model.supports_dspark() {
return Err("DSpark is not available for the selected model.".into());
@@ -166,8 +166,8 @@ impl SsdPreferences {
{
return Err("SSD full-layer count is too large.".into());
}
if self.full_layers.is_some_and(|layers| layers > 0) && model != ModelChoice::Glm52 {
return Err("Fully resident SSD layers are available only for GLM 5.2.".into());
if self.full_layers.is_some_and(|layers| layers > 0) && !model.is_glm() {
return Err("Fully resident SSD layers are available only for GLM models.".into());
}
if let Some(StreamingCacheBudget::Gib(gib)) = self.cache {
validate_gib("SSD cache budget", gib)?;
@@ -443,12 +443,12 @@ impl ExecutionPreferences {
{
return Err("Prefill chunk is too large.".into());
}
if model == ModelChoice::Glm52 {
if model.is_glm() {
if self.power_percent.is_some_and(|power| power != 100) {
return Err("GLM 5.2 currently requires 100% GPU power.".into());
return Err("GLM currently requires 100% GPU power.".into());
}
if self.prefill_chunk.is_some() {
return Err("GLM 5.2 selects its prefill chunk automatically.".into());
return Err("GLM selects its prefill chunk automatically.".into());
}
}
Ok(())
@@ -569,7 +569,7 @@ impl GenerationPreferences {
model: ModelChoice,
kv_cache: KvCacheSettings,
) -> TurnSettings {
let glm = model == ModelChoice::Glm52;
let glm = model.is_glm();
TurnSettings {
kv_cache,
context_tokens: self.context_tokens,