Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 2 additions & 6 deletions bindings/android/app/src/main/cpp/model_manager_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,11 @@ jobject build_model_detail(JNIEnv* env, const geniex_ModelDetail& d) {
// Build a com.geniex.sdk.bean.ModelQuery from a geniex_ModelQueryOutput.
jobject build_model_query(JNIEnv* env, const geniex_ModelQueryOutput& out) {
jclass candCls = env->FindClass("com/geniex/sdk/bean/PrecisionCandidate");
jmethodID candCtor = env->GetMethodID(candCls, "<init>", "(Ljava/lang/String;JZ)V");
jmethodID candCtor = env->GetMethodID(candCls, "<init>", "(Ljava/lang/String;J)V");
jobjectArray jCandidates = env->NewObjectArray(out.candidate_count, candCls, nullptr);
for (int32_t i = 0; i < out.candidate_count; ++i) {
jstring jQuant = env->NewStringUTF(out.candidates[i].quant ? out.candidates[i].quant : "");
jobject item = env->NewObject(candCls,
candCtor,
jQuant,
static_cast<jlong>(out.candidates[i].size),
static_cast<jboolean>(out.candidates[i].is_default));
jobject item = env->NewObject(candCls, candCtor, jQuant, static_cast<jlong>(out.candidates[i].size));
env->SetObjectArrayElement(jCandidates, i, item);
env->DeleteLocalRef(item);
env->DeleteLocalRef(jQuant);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ package com.geniex.sdk.bean
data class PrecisionCandidate(
val precision: String,
val size: Long,
val is_default: Boolean,
)
6 changes: 3 additions & 3 deletions bindings/go/model_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,12 @@ func ModelGetPaths(name string) (*ModelPaths, error) {
}, nil
}

// PrecisionCandidate mirrors geniex_QuantCandidate.
// PrecisionCandidate mirrors geniex_QuantCandidate. Candidates in
// ModelQueryResult.Candidates are sorted by SDK priority — grab the head
// for the recommended pick.
type PrecisionCandidate struct {
Precision string
Size int64
IsDefault bool
}

// ModelQueryResult mirrors geniex_ModelQueryOutput.
Expand Down Expand Up @@ -428,7 +429,6 @@ func ModelQuery(input ModelPullInput) (*ModelQueryResult, error) {
result.Candidates[i] = PrecisionCandidate{
Precision: C.GoString(c.quant),
Size: int64(c.size),
IsDefault: bool(c.is_default),
}
}
}
Expand Down
1 change: 0 additions & 1 deletion bindings/python/geniex/_ffi/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,6 @@ class geniex_QuantCandidate(Structure):
_fields_ = [
('quant', c_char_p),
('size', c_int64),
('is_default', c_bool),
]


Expand Down
18 changes: 10 additions & 8 deletions bindings/python/geniex/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ class PrecisionCandidate:

precision: str
size: int
is_default: bool


@dataclass(frozen=True)
class ModelQuery:
"""Result of a plan-only :func:`query`."""
"""Result of a plan-only :func:`query`.

``candidates`` is sorted by SDK priority — grab index 0 for the
recommended pick.
"""

model_name: str
runtime: str
Expand Down Expand Up @@ -359,7 +362,6 @@ def query(
PrecisionCandidate(
precision=out.candidates[i].quant.decode() if out.candidates[i].quant else '',
size=out.candidates[i].size,
is_default=bool(out.candidates[i].is_default),
)
for i in range(out.candidate_count)
]
Expand Down Expand Up @@ -524,14 +526,14 @@ def ensure_cached(
except GenieXError:
full_name = name_part

# No precision + remote source: resolve the hub default before pulling so
# only one variant is downloaded instead of all of them.
# No precision + remote source: pick the head of the SDK's priority-
# sorted candidate list so only one variant is downloaded instead of
# every quant the repo publishes.
if precision is None and local_path is None:
try:
result = query(full_name, hub=hub, hf_token=hf_token)
default = next((c.precision for c in result.candidates if c.is_default), None)
if default:
precision = default
if result.candidates:
precision = result.candidates[0].precision
except GenieXError:
pass # offline or unsupported hub; let pull decide

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Copyright 2024-2026 Qualcomm Technologies, Inc. and/or its subsidiaries.
# SPDX-License-Identifier: BSD-3-Clause

"""Unit tests for the ensure_cached default-precision resolution (#1098).
"""Unit tests for the ensure_cached precision-selection flow (#1098).

When precision=None, ensure_cached must call query() to pick the hub's
default precision and pass it to pull() so only one variant is downloaded.
When precision=None, ensure_cached must call query() and pass the SDK's
priority-sorted head precision to pull() so only one variant is downloaded.
"""

from __future__ import annotations
Expand All @@ -14,13 +14,12 @@
from geniex.model_manager import ModelPaths, ModelQuery, PrecisionCandidate


def _make_query(*precisions: tuple[str, bool]) -> ModelQuery:
"""Build a ModelQuery with the given (precision, is_default) pairs."""
def _make_query(*precisions: str) -> ModelQuery:
return ModelQuery(
model_name='org/repo',
runtime='llama_cpp',
model_type='llm',
candidates=[PrecisionCandidate(precision=p, size=0, is_default=d) for p, d in precisions],
candidates=[PrecisionCandidate(precision=p, size=0) for p in precisions],
)


Expand All @@ -34,11 +33,11 @@ def _fake_paths() -> ModelPaths:
)


def test_ensure_cached_resolves_default_precision(monkeypatch):
"""pull() receives the is_default precision when none is specified."""
def test_ensure_cached_pulls_head_precision(monkeypatch):
"""pull() receives candidates[0].precision when none is specified."""
pulled = {}

monkeypatch.setattr(mm, 'query', lambda name, **_kw: _make_query(('Q8_0', False), ('Q4_0', True)))
monkeypatch.setattr(mm, 'query', lambda name, **_kw: _make_query('Q4_0', 'Q4_K_M', 'Q8_0'))
monkeypatch.setattr(mm, 'pull', lambda name, *, precision=None, **_kw: pulled.update(precision=precision))
monkeypatch.setattr(mm, 'get_paths', lambda _key: _fake_paths())
monkeypatch.setattr(mm, 'resolve_alias', lambda name: name)
Expand All @@ -53,7 +52,7 @@ def test_ensure_cached_explicit_precision_skips_query(monkeypatch):
"""An explicit precision bypasses query() entirely."""
queried = []

monkeypatch.setattr(mm, 'query', lambda *_a, **_kw: queried.append(True) or _make_query(('Q4_0', True)))
monkeypatch.setattr(mm, 'query', lambda *_a, **_kw: queried.append(True) or _make_query('Q4_0'))
monkeypatch.setattr(mm, 'pull', lambda *_a, **_kw: None)
monkeypatch.setattr(mm, 'get_paths', lambda _key: _fake_paths())
monkeypatch.setattr(mm, 'resolve_alias', lambda name: name)
Expand All @@ -64,21 +63,6 @@ def test_ensure_cached_explicit_precision_skips_query(monkeypatch):
assert queried == [], 'query() should not be called when precision is explicit'


def test_ensure_cached_local_path_skips_query(monkeypatch):
"""A local_path pull bypasses query() — the manifest is already on disk."""
queried = []

monkeypatch.setattr(mm, 'query', lambda *_a, **_kw: queried.append(True) or _make_query(('Q4_0', True)))
monkeypatch.setattr(mm, 'pull', lambda *_a, **_kw: None)
monkeypatch.setattr(mm, 'get_paths', lambda _key: _fake_paths())
monkeypatch.setattr(mm, 'resolve_alias', lambda name: name)
monkeypatch.setattr(mm, '_ensure_init', lambda: None)

mm.ensure_cached('org/repo', local_path='/some/path')

assert queried == []


def test_ensure_cached_query_failure_falls_through(monkeypatch):
"""A query() GenieXError is swallowed and pull() is still called."""
pulled = {}
Expand All @@ -91,5 +75,4 @@ def test_ensure_cached_query_failure_falls_through(monkeypatch):

mm.ensure_cached('org/repo')

# precision stays None — pull decides on its own
assert pulled['precision'] is None
18 changes: 6 additions & 12 deletions cli/cmd/geniex/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,9 @@ func pullModel(ctx context.Context, name string, quant string) error {
}

// choosePrecision picks a precision from the remote candidates: the only one
// when there's a single option, otherwise an interactive picker that defaults
// to the SDK-recommended quant.
// when there's a single option, otherwise an interactive picker pre-filled
// with candidates[0] (the SDK sorts by priority, so head is the recommended
// pick).
func choosePrecision(candidates []geniex_sdk.PrecisionCandidate) (string, error) {
if len(candidates) == 0 {
return "", fmt.Errorf("no precision available for this model")
Expand All @@ -488,24 +489,17 @@ func choosePrecision(candidates []geniex_sdk.PrecisionCandidate) (string, error)
return candidates[0].Precision, nil
}

var defaultQuant string
var options []huh.Option[string]
options := make([]huh.Option[string], 0, len(candidates))
for _, c := range candidates {
var sz string
sz := "—"
if c.Size > 0 {
sz = humanize.IBytes(uint64(c.Size))
} else {
sz = "—"
}
label := fmt.Sprintf("%-10s [%7s]", c.Precision, sz)
if c.IsDefault {
label += " (default)"
defaultQuant = c.Precision
}
options = append(options, huh.NewOption(label, c.Precision))
}

chosen := defaultQuant
chosen := candidates[0].Precision
if err := huh.NewSelect[string]().
Title("Choose a precision version to download").
Options(options...).
Expand Down
31 changes: 28 additions & 3 deletions sdk/model-manager/crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ pub enum Error {
#[error("quantization '{0}' exists but is not downloaded for model '{1}'")]
QuantNotDownloaded(String, String),

#[error("no downloaded quantization found for model '{0}'")]
NoDownloadedQuant(String),

#[error("model manager not initialized; call geniex_model_init() first")]
NotInitialized,

Expand Down Expand Up @@ -83,6 +80,16 @@ pub enum Error {
#[error("http error: {0}")]
Http(String),

/// URL syntax error. `context` names what was being built
/// (`"HF endpoint"`, `"docker manifest url"`, …) so the log line is
/// actionable without pattern-matching on the underlying message.
#[error("invalid url ({context}): {source}")]
InvalidUrl {
context: String,
#[source]
source: url::ParseError,
},

#[error("download cancelled")]
Cancelled,

Expand All @@ -95,3 +102,21 @@ pub enum Error {
#[error("could not infer manifest from directory: {0}")]
ManifestInferenceFailed(String),
}

impl Error {
/// Wrap a `url::ParseError` with `context` describing the URL role.
pub fn invalid_url(context: impl Into<String>, source: url::ParseError) -> Self {
Error::InvalidUrl {
context: context.into(),
source,
}
}
}

/// Parse a serde-JSON document, tagging failures with `what` for log grep.
pub fn parse_manifest<'a, T: serde::Deserialize<'a>>(
what: &'static str,
bytes: &'a [u8],
) -> Result<T> {
serde_json::from_slice(bytes).map_err(|source| Error::ManifestParse { what, source })
}
22 changes: 8 additions & 14 deletions sdk/model-manager/crates/core/src/executor/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,6 @@ pub struct ChunkPlan {
pub chunks: Vec<ChunkRange>,
}

impl ChunkPlan {
pub fn num_chunks(&self) -> usize {
self.chunks.len()
}
}

/// Build a chunk plan for `file_size`. A `file_size` of 0 yields a
/// zero-chunk plan; the caller is responsible for still creating an empty
/// output file.
Expand All @@ -62,7 +56,7 @@ pub fn plan_chunks_with_floor(file_size: u64, min_chunk_size: u64) -> ChunkPlan
};
let mut chunks = Vec::new();
if file_size > 0 {
let n = (file_size + chunk_size - 1) / chunk_size;
let n = file_size.div_ceil(chunk_size);
for i in 0..n {
let offset = i * chunk_size;
let len = core::cmp::min(chunk_size, file_size - offset);
Expand Down Expand Up @@ -92,7 +86,7 @@ fn effective_min_chunk_size() -> u64 {
/// if the file is missing or length-mismatched (which signals either a
/// fresh pull or a chunk-size change since the last attempt).
pub fn load_or_init_bitmap(marker_path: &Path, plan: &ChunkPlan) -> Result<Vec<u8>> {
let expected = plan.num_chunks();
let expected = plan.chunks.len();
match fs::read(marker_path) {
Ok(buf) if buf.len() == expected => Ok(buf),
Ok(_) | Err(_) => {
Expand Down Expand Up @@ -170,7 +164,7 @@ mod tests {
#[test]
fn tiny_file_is_single_chunk() {
let plan = plan_chunks_with_floor(1024, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), 1);
assert_eq!(plan.chunks.len(), 1);
assert_eq!(plan.chunks[0].offset, 0);
assert_eq!(plan.chunks[0].len, 1024);
assert_eq!(plan.chunk_size, MIN_CHUNK_SIZE);
Expand All @@ -179,14 +173,14 @@ mod tests {
#[test]
fn exact_min_chunk_is_one_chunk() {
let plan = plan_chunks_with_floor(MIN_CHUNK_SIZE, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), 1);
assert_eq!(plan.chunks.len(), 1);
assert_eq!(plan.chunks[0].len, MIN_CHUNK_SIZE);
}

#[test]
fn thirty_two_mib_splits_into_two() {
let plan = plan_chunks_with_floor(2 * MIN_CHUNK_SIZE, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), 2);
assert_eq!(plan.chunks.len(), 2);
assert_eq!(plan.chunks[0].len, MIN_CHUNK_SIZE);
assert_eq!(plan.chunks[1].len, MIN_CHUNK_SIZE);
}
Expand All @@ -196,7 +190,7 @@ mod tests {
// 4 GiB at the default floor → 128 chunks of 32 MiB each.
let size = 4u64 * 1024 * 1024 * 1024;
let plan = plan_chunks_with_floor(size, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), MAX_CHUNKS_PER_FILE as usize);
assert_eq!(plan.chunks.len(), MAX_CHUNKS_PER_FILE as usize);
assert_eq!(plan.chunk_size, size / MAX_CHUNKS_PER_FILE);
assert_eq!(
plan.chunks.iter().map(|c| c.len).sum::<u64>(),
Expand All @@ -209,15 +203,15 @@ mod tests {
fn last_chunk_takes_the_remainder() {
let size = MIN_CHUNK_SIZE + 123;
let plan = plan_chunks_with_floor(size, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), 2);
assert_eq!(plan.chunks.len(), 2);
assert_eq!(plan.chunks[1].len, 123);
assert_eq!(plan.chunks[1].offset, MIN_CHUNK_SIZE);
}

#[test]
fn zero_size_yields_no_chunks() {
let plan = plan_chunks_with_floor(0, MIN_CHUNK_SIZE);
assert_eq!(plan.num_chunks(), 0);
assert_eq!(plan.chunks.len(), 0);
}

#[test]
Expand Down
Loading
Loading