Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ strategy = "ngram-suffix"
}

#[test]
fn standalone_ngram_rejected_for_single_stage_serving() {
fn standalone_ngram_uses_native_plugin_verify_path_for_single_stage_serving() {
let mesh_config = parse_config(
r#"
[defaults.speculative]
Expand Down Expand Up @@ -789,16 +789,15 @@ ngram_max_proposal_tokens = 48
.to_embedded_openai_args(4096, true)
.expect("staged serving should build OpenAI args");

// Single-stage/direct serving has no N-gram verify path, so it must reject
// rather than silently run target-only while reporting a proposer.
let error = resolved
// Single-stage/direct serving uses the native plugin ingress, whose
// proposal is verified and repaired by the local target runtime.
let openai = resolved
.to_embedded_openai_args(0, false)
.expect_err("single-stage standalone N-gram must be rejected");
assert!(
error
.to_string()
.contains("requires multi-stage split serving"),
"{error}"
.expect("single-stage standalone N-gram should use the native plugin verify path");
assert_eq!(openai.speculative_window, 48);
assert_eq!(
openai.speculative.ngram.as_ref().map(|ngram| ngram.kind),
Some(skippy_server::NgramProposerKind::Suffix)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,27 +280,14 @@ impl ResolvedSkippyConfig {
if self.skippy.prefill_controls_explicit {
bail!("skippy prefill chunk controls require staged serving");
}
// A stage with no downstream delegates to generate_local_tokens, which
// has no N-gram verification path. Reject a standalone N-gram plan here
// rather than silently running target-only while reporting a proposer.
if self.speculative.decode.ngram.is_some()
&& !self.speculative.decode.native_mtp.enabled
{
bail!(
"standalone N-gram speculation ({}) requires multi-stage split serving; \
single-stage and direct GGUF requests have no N-gram verification path",
self.speculative.decode.effective_strategy
);
}
}
Ok(())
}

fn speculative_mode_for_embedded(&self, staged: bool) -> &'static str {
fn speculative_mode_for_embedded(&self, _staged: bool) -> &'static str {
if self.speculative.mode == "draft" && self.speculative.draft_model_path.is_some() {
"draft"
} else if staged
&& self.speculative.decode.ngram.is_some()
} else if self.speculative.decode.ngram.is_some()
&& !self.speculative.decode.native_mtp.enabled
{
"ngram"
Expand Down
17 changes: 14 additions & 3 deletions crates/mesh-native-serving-plugin-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,20 @@ layout of the prefix understood by an older plugin. Removing or reordering
fields, changing a field's type, or changing a callback signature requires a
new versioned ABI table and continued support for the previous table.

The V1 activation contract lends the complete native tokenizer inventory through
`ActivationContext`. A plugin copies or transforms the inventory before
returning from `activate`.
The V2 activation contract lends a model-bound tokenizer capability through
`ActivationContext`. The capability includes the opaque native tokenizer
inventory, the model identity and binding digest, explicit input/output
limits, and a bounded encode callback for tagged ordinary-byte and opaque
control pieces. Mesh does not know or own Rosetta vocabulary: a consumer
builds any translation from the inventory and calls the capability outside the
proposal deadline. Unsupported or non-lossless input is rejected; it is never
decoded with replacement semantics. A plugin copies or transforms the
inventory before returning from `activate` and must not call the encode
callback from proposal callbacks.

The V2 table is a breaking replacement for V1. Plugins must resolve
`mesh_native_serving_plugin_v2` and validate the V2 ABI/version and structure
sizes before use.

Each proposal query carries the capacity Skippy can verify at that exact decode
position. The native host adapter also applies a bounded implementation cap so
Expand Down
116 changes: 106 additions & 10 deletions crates/mesh-native-serving-plugin-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@

use std::ffi::{c_char, c_void};

pub const NATIVE_SERVING_PLUGIN_ABI_V1: u32 = 1;
pub const NATIVE_SERVING_PLUGIN_ENTRY_V1: &[u8] = b"mesh_native_serving_plugin_v1\0";
pub const NATIVE_SERVING_PLUGIN_ABI_V2: u32 = 2;
pub const NATIVE_SERVING_PLUGIN_ENTRY_V2: &[u8] = b"mesh_native_serving_plugin_v2\0";
pub const MAX_DECISION_ID_BYTES: usize = 64;
pub const TOKENIZER_INVENTORY_SCHEMA: u32 = 1;
pub const TOKENIZER_CAPABILITY_ABI: u32 = 1;
pub const MAX_TOKENIZER_INVENTORY_ENTRIES: usize = 1_000_000;
pub const MAX_TOKENIZER_INPUT_PIECES: usize = 4_096;

/// Host-owned typed inventory. This Rust value never crosses the ABI directly.
#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -37,8 +40,14 @@ pub struct TokenizerInventoryToken {

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TokenizerInventoryPiece {
Bytes { bytes: Vec<u8> },
Control { identity: String },
Bytes {
bytes: Vec<u8>,
},
/// Opaque bytes for a native special-token descriptor. Mesh never parses
/// or names this value; the plugin owns its interpretation.
Control {
descriptor: Vec<u8>,
},
}

pub type PluginInstance = *mut c_void;
Expand Down Expand Up @@ -92,6 +101,70 @@ pub struct TokenizerInventoryView {
pub entry_count: usize,
}

#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct TokenizerLimits {
pub max_input_bytes: usize,
pub max_output_tokens: usize,
}

#[repr(transparent)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TokenizerInputPieceKind(pub u32);

impl TokenizerInputPieceKind {
pub const BYTES: Self = Self(0);
pub const CONTROL: Self = Self(1);
}

impl Default for TokenizerInputPieceKind {
fn default() -> Self {
Self::BYTES
}
}

#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct TokenizerInputPiece {
pub kind: TokenizerInputPieceKind,
pub bytes: ByteSlice,
}

#[repr(transparent)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TokenizerEncodeStatus(pub u32);

impl TokenizerEncodeStatus {
pub const OK: Self = Self(0);
pub const INVALID_ARGUMENT: Self = Self(1);
pub const UNSUPPORTED_INPUT: Self = Self(2);
pub const OUTPUT_TOO_SMALL: Self = Self(3);
pub const LIMIT_EXCEEDED: Self = Self(4);
pub const UNAVAILABLE: Self = Self(5);
pub const INTERNAL_ERROR: Self = Self(6);
}

/// A model-bound, host-owned tokenizer capability. The table and its
/// inventory view are lent only for `activate`; the `context` passed to
/// `encode` remains valid until plugin shutdown. Plugins must copy any
/// activation data they need and must perform preparation/encoding outside the
/// latency-sensitive proposal callbacks. Mesh never invokes `encode` from the
/// proposal path.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct TokenizerCapability {
pub struct_size: usize,
pub abi_version: u32,
pub model_id: ByteSlice,
pub source_model_sha256: ByteSlice,
pub tokenizer_id: ByteSlice,
pub limits: TokenizerLimits,
pub binding_digest: [u8; 32],
pub inventory: *const TokenizerInventoryView,
pub context: *mut c_void,
pub encode: EncodeTokenizer,
}

#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct TokenSlice {
Expand Down Expand Up @@ -183,14 +256,23 @@ impl ProposalDiscardReason {

pub type MonotonicNowNs = unsafe extern "C" fn(context: *mut c_void) -> u64;

pub type EncodeTokenizer = unsafe extern "C" fn(
context: *mut c_void,
input_pieces: *const TokenizerInputPiece,
input_piece_count: usize,
output_tokens: *mut i32,
output_capacity: usize,
output_length: *mut usize,
) -> TokenizerEncodeStatus;

#[repr(C)]
#[derive(Clone, Copy)]
pub struct ActivationContext {
pub struct_size: usize,
pub model_id: ByteSlice,
pub source_model_sha256: ByteSlice,
pub tokenizer_id: ByteSlice,
pub tokenizer_inventory: *const TokenizerInventoryView,
pub tokenizer_capability: *const TokenizerCapability,
pub config_path: ByteSlice,
pub state_directory: ByteSlice,
pub proposal_deadline_ns: u64,
Expand Down Expand Up @@ -336,7 +418,7 @@ pub type LastError =
unsafe extern "C" fn(instance: PluginInstance, output: *mut c_char, capacity: usize) -> usize;

#[repr(C)]
pub struct NativeServingPluginV1 {
pub struct NativeServingPluginV2 {
pub abi_version: u32,
pub struct_size: usize,
pub plugin_name: ByteSlice,
Expand All @@ -358,9 +440,9 @@ pub struct NativeServingPluginV1 {
// `plugin_name` to remain immutable and valid for the loaded library's entire
// lifetime. The host copies the name during load and only calls function
// pointers afterward.
unsafe impl Sync for NativeServingPluginV1 {}
unsafe impl Sync for NativeServingPluginV2 {}

pub type NativeServingPluginEntryV1 = unsafe extern "C" fn() -> *const NativeServingPluginV1;
pub type NativeServingPluginEntryV2 = unsafe extern "C" fn() -> *const NativeServingPluginV2;

#[cfg(test)]
mod tests {
Expand All @@ -379,8 +461,22 @@ mod tests {
}

#[test]
fn initial_contract_is_v1() {
fn initial_contract_is_v2() {
assert_eq!(MAX_DECISION_ID_BYTES, 64);
assert_eq!(NATIVE_SERVING_PLUGIN_ABI_V1, 1);
assert_eq!(NATIVE_SERVING_PLUGIN_ABI_V2, 2);
assert_eq!(TOKENIZER_CAPABILITY_ABI, 1);
}

#[test]
fn structured_input_piece_kinds_are_stable_and_opaque() {
assert_eq!(TokenizerInputPieceKind::BYTES.0, 0);
assert_eq!(TokenizerInputPieceKind::CONTROL.0, 1);
let descriptor = [0xff, 0x00];
let piece = TokenizerInputPiece {
kind: TokenizerInputPieceKind::CONTROL,
bytes: ByteSlice::from_bytes(&descriptor),
};
assert_eq!(piece.bytes.length, 2);
assert_eq!(piece.bytes.pointer, descriptor.as_ptr());
}
}
1 change: 1 addition & 0 deletions crates/mesh-native-serving-plugin-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ anyhow.workspace = true
libloading = "0.8"
mesh-native-serving-plugin-api = { path = "../mesh-native-serving-plugin-api", version = "0.72.1" }
skippy-server = { path = "../skippy-server", version = "0.72.1" }
skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.72.1" }

[lints]
workspace = true
10 changes: 7 additions & 3 deletions crates/mesh-native-serving-plugin-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Skippy's decode thread waits only until its own absolute deadline and never
joins or drains plugin work. Lifecycle and authoritative verification outcomes
remain ordered through the same bounded queue.

At activation, the host passes a borrowed view of the model's already-bound
native tokenizer inventory over the plugin ABI. The view and its byte slices
are valid only for that call; plugins must not retain them.
At activation, the host passes a model-bound tokenizer capability over the
plugin ABI. Its inventory view and input-piece slices are borrowed, while the
capability callback context remains owned by the active plugin host until
shutdown. The host bounds and copies structured input before calling the
loaded tokenizer. Opaque controls are never decoded as text: unsupported
controls return `UNSUPPORTED_INPUT`. Tokenizer calls are preparation-time
operations and are not part of proposal dispatch.
Loading
Loading