From 5eccf33a9bacdd1c7e2a712f87fe66cb2fd8f945 Mon Sep 17 00:00:00 2001 From: RemiliaForever Date: Wed, 15 Jul 2026 18:08:26 +0800 Subject: [PATCH 1/2] feat(sdk): stamp pulled models with their QAIRT version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI Hub's release-assets.json declares, per asset, the QAIRT version its Genie artifacts were exported against (tool_versions.qairt). Parse that field and persist it into each model's geniex.json as QairtVersion at pull time, so a later run can tell whether cached artifacts still match the runtime this build ships — the "stale artifact after a GenieX update" case from #1229. The value is empty for non-AI-Hub / llama.cpp models and omitted from the serialized manifest when empty; legacy manifests without the key still deserialize. Expose it through the FFI so bindings can read it: append qairt_version to geniex_ModelPaths and thread it through the Go and Python model-paths wrappers (FFI surfaces updated in lockstep per CONTRIBUTING §4). Compatibility checking on top of this stamp is left for a follow-up. Signed-off-by: RemiliaForever --- bindings/go/model_manager.go | 4 ++ bindings/python/geniex/_ffi/_types.py | 1 + bindings/python/geniex/model_manager.py | 4 ++ sdk/model-manager/crates/core/src/manifest.rs | 61 ++++++++++++++++- .../crates/core/src/manifest_builder.rs | 1 + sdk/model-manager/crates/core/src/paths.rs | 6 ++ sdk/model-manager/crates/core/src/query.rs | 1 + .../crates/core/src/source/ai_hub/manifest.rs | 68 +++++++++++++++++++ .../crates/core/src/source/ai_hub/mod.rs | 1 + .../crates/core/src/source/ai_hub/selector.rs | 3 +- .../crates/core/src/source/dockerhub.rs | 1 + .../crates/core/src/source/localfs.rs | 2 + sdk/model-manager/crates/core/src/store.rs | 1 + .../crates/core/tests/ai_hub_pull.rs | 4 +- sdk/model-manager/crates/ffi/src/store.rs | 4 ++ sdk/model-manager/include/geniex_model.h | 1 + 16 files changed, 160 insertions(+), 3 deletions(-) diff --git a/bindings/go/model_manager.go b/bindings/go/model_manager.go index faae92e0f..83cec7b26 100644 --- a/bindings/go/model_manager.go +++ b/bindings/go/model_manager.go @@ -343,6 +343,9 @@ type ModelPaths struct { ModelName string RuntimeID string ModelType ModelType + // QairtVersion is the QAIRT version the model's assets were pulled for + // (from AI Hub metadata); empty for non-AI-Hub / llama.cpp models. + QairtVersion string } // ModelGetPaths resolves "org/repo[:precision]" (or alias) to absolute on-disk paths. @@ -362,6 +365,7 @@ func ModelGetPaths(name string) (*ModelPaths, error) { ModelName: C.GoString(out.model_name), RuntimeID: C.GoString(out.plugin_id), ModelType: ModelType(out.model_type), + QairtVersion: C.GoString(out.qairt_version), }, nil } diff --git a/bindings/python/geniex/_ffi/_types.py b/bindings/python/geniex/_ffi/_types.py index d53114ee0..7ebe89646 100644 --- a/bindings/python/geniex/_ffi/_types.py +++ b/bindings/python/geniex/_ffi/_types.py @@ -338,6 +338,7 @@ class geniex_ModelPaths(Structure): ('model_name', c_char_p), ('plugin_id', c_char_p), ('model_type', c_int32), + ('qairt_version', c_char_p), ] diff --git a/bindings/python/geniex/model_manager.py b/bindings/python/geniex/model_manager.py index c93cce1c2..b1de175a2 100644 --- a/bindings/python/geniex/model_manager.py +++ b/bindings/python/geniex/model_manager.py @@ -74,6 +74,9 @@ class ModelPaths: model_type: str # "llm" or "vlm" mmproj_path: str | None = None tokenizer_path: str | None = None + # QAIRT version the model's assets were pulled for (AI Hub metadata); + # empty for non-AI-Hub / llama.cpp models. + qairt_version: str = '' @dataclass(frozen=True) @@ -406,6 +409,7 @@ def get_paths(model_name: str) -> ModelPaths: model_type=_type_str(out.model_type), mmproj_path=out.mmproj_path.decode() if out.mmproj_path else None, tokenizer_path=out.tokenizer_path.decode() if out.tokenizer_path else None, + qairt_version=out.qairt_version.decode() if out.qairt_version else '', ) finally: lib.geniex_model_paths_free(byref(out)) diff --git a/sdk/model-manager/crates/core/src/manifest.rs b/sdk/model-manager/crates/core/src/manifest.rs index 0494cbb10..47273fe7f 100644 --- a/sdk/model-manager/crates/core/src/manifest.rs +++ b/sdk/model-manager/crates/core/src/manifest.rs @@ -45,7 +45,9 @@ where /// Historical `DeviceId` and `MinSDKVersion` keys are accepted (serde /// silently drops unknown JSON fields on deserialize) but no longer /// serialised — qairt / llama_cpp plugins don't read them and AI Hub -/// hub already tracks the chipset out-of-band. +/// hub already tracks the chipset out-of-band. `QairtVersion` (below) is +/// the current, purposeful version stamp, sourced from AI Hub asset +/// metadata rather than a placeholder. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelManifest { #[serde(rename = "Name")] @@ -62,6 +64,16 @@ pub struct ModelManifest { skip_serializing_if = "String::is_empty" )] pub precision: String, + /// QAIRT version the model's assets were exported against, captured at + /// pull time from AI Hub's `release-assets.json` (`tool_versions.qairt`). + /// Lets a later run detect artifacts left stale by a GenieX update whose + /// bundled QAIRT no longer matches. Empty for non-AI-Hub / llama.cpp models. + #[serde( + rename = "QairtVersion", + default, + skip_serializing_if = "String::is_empty" + )] + pub qairt_version: String, #[serde(rename = "ModelFile", default, deserialize_with = "null_as_default")] pub model_file: HashMap, #[serde(rename = "MMProjFile", default, deserialize_with = "null_as_default")] @@ -104,3 +116,50 @@ pub struct DownloadInfo { pub total_downloaded: i64, pub total_size: i64, } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ModelManifest { + ModelManifest { + name: "org/repo".into(), + model_name: "repo".into(), + model_type: ModelType::Llm, + plugin_id: "qairt".into(), + precision: "W4A16".into(), + qairt_version: "2.45.0.260326154327".into(), + model_file: HashMap::new(), + mmproj_file: ModelFileInfo::default(), + tokenizer_file: ModelFileInfo::default(), + extra_files: Vec::new(), + } + } + + #[test] + fn serializes_qairt_version_key() { + let json = serde_json::to_string(&sample()).unwrap(); + assert!( + json.contains(r#""QairtVersion":"2.45.0.260326154327""#), + "missing QairtVersion key: {json}" + ); + } + + #[test] + fn empty_qairt_version_is_omitted() { + // Non-AI-Hub models leave the stamp empty; the key must not appear. + let mut m = sample(); + m.qairt_version = String::new(); + let json = serde_json::to_string(&m).unwrap(); + assert!(!json.contains("QairtVersion"), "unexpected key: {json}"); + } + + #[test] + fn legacy_manifest_without_qairt_version_deserializes() { + // geniex.json written before this field must still load. + let legacy = + r#"{"Name":"org/repo","ModelName":"repo","ModelType":"llm","PluginId":"qairt"}"#; + let m: ModelManifest = serde_json::from_str(legacy).unwrap(); + assert!(m.qairt_version.is_empty()); + } +} diff --git a/sdk/model-manager/crates/core/src/manifest_builder.rs b/sdk/model-manager/crates/core/src/manifest_builder.rs index 18aaf292c..69b3fe073 100644 --- a/sdk/model-manager/crates/core/src/manifest_builder.rs +++ b/sdk/model-manager/crates/core/src/manifest_builder.rs @@ -252,6 +252,7 @@ pub fn infer_manifest_from_names( model_type, plugin_id, precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file, tokenizer_file, diff --git a/sdk/model-manager/crates/core/src/paths.rs b/sdk/model-manager/crates/core/src/paths.rs index e657548e8..cac8eb185 100644 --- a/sdk/model-manager/crates/core/src/paths.rs +++ b/sdk/model-manager/crates/core/src/paths.rs @@ -16,6 +16,10 @@ pub struct ModelPaths { pub model_name: String, pub plugin_id: String, pub model_type: ModelType, + /// QAIRT version the model's assets were exported against (from the + /// manifest). Empty for non-AI-Hub / llama.cpp models. Lets callers + /// detect artifacts left stale by a GenieX update. + pub qairt_version: String, } /// Resolve file paths from a manifest + local base directory + optional quant hint. @@ -84,6 +88,7 @@ pub fn resolve_model_paths( model_name: manifest.model_name.clone(), plugin_id: manifest.plugin_id.clone(), model_type: manifest.model_type.clone(), + qairt_version: manifest.qairt_version.clone(), }, )) } @@ -145,6 +150,7 @@ mod tests { model_type: ModelType::Llm, plugin_id: "llama_cpp".to_string(), precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/src/query.rs b/sdk/model-manager/crates/core/src/query.rs index caf6beebe..0f59c3a33 100644 --- a/sdk/model-manager/crates/core/src/query.rs +++ b/sdk/model-manager/crates/core/src/query.rs @@ -114,6 +114,7 @@ mod tests { model_type: ModelType::Llm, plugin_id: "llama_cpp".to_string(), precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/src/source/ai_hub/manifest.rs b/sdk/model-manager/crates/core/src/source/ai_hub/manifest.rs index 262141e06..9d722a3a1 100644 --- a/sdk/model-manager/crates/core/src/source/ai_hub/manifest.rs +++ b/sdk/model-manager/crates/core/src/source/ai_hub/manifest.rs @@ -96,6 +96,18 @@ pub struct AssetDetails { pub download_url: String, #[serde(default)] pub uncompressed_size: Option, + /// Tool versions the asset was built against, e.g. `{"qairt": "2.45.0.…"}`. + /// Genie/QAIRT assets carry `qairt`; llama.cpp assets ship an empty map. + #[serde(default)] + pub tool_versions: ToolVersions, +} + +/// Compiler/runtime versions an AI Hub asset was exported with. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ToolVersions { + #[serde(default)] + pub qairt: String, } /// `platform.json`: chipset catalogue with aliases used to canonicalize @@ -117,3 +129,59 @@ pub struct ChipsetInfo { #[serde(default)] pub aliases: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + // Trimmed from a live release-assets.json (qwen3_4b, v0.57.0). Genie + // assets carry tool_versions.qairt; llama.cpp assets ship an empty map. + const RELEASE_ASSETS: &str = r#"{ + "model_id": "qwen3_4b", + "aihm_version": "0.57.0", + "assets": [ + { + "precision": "PRECISION_W4A16", + "runtime": "RUNTIME_GENIE", + "chipset": "qualcomm-snapdragon-x-elite", + "download_url": "https://example.invalid/qwen3_4b.zip", + "tool_versions": {"qairt": "2.45.0.260326154327"} + }, + { + "precision": "PRECISION_W4A16", + "runtime": "RUNTIME_GENIEX_LLAMACPP", + "chipset": null, + "download_url": "https://example.invalid/qwen3_4b-llamacpp.zip", + "tool_versions": {} + } + ] + }"#; + + #[test] + fn parses_qairt_tool_version_from_genie_asset() { + let ra: ModelReleaseAssets = serde_json::from_str(RELEASE_ASSETS).unwrap(); + let genie = ra + .assets + .iter() + .find(|a| a.runtime == "RUNTIME_GENIE") + .unwrap(); + assert_eq!(genie.tool_versions.qairt, "2.45.0.260326154327"); + } + + #[test] + fn tool_versions_defaults_empty_when_absent_or_bare() { + let ra: ModelReleaseAssets = serde_json::from_str(RELEASE_ASSETS).unwrap(); + let llama = ra + .assets + .iter() + .find(|a| a.runtime == "RUNTIME_GENIEX_LLAMACPP") + .unwrap(); + assert!(llama.tool_versions.qairt.is_empty()); + + // Assets predating the tool_versions field must still parse. + let legacy = + r#"{"runtime":"RUNTIME_GENIE","precision":"","download_url":"","chipset":null}"#; + let asset: AssetDetails = serde_json::from_str(legacy).unwrap(); + assert!(asset.tool_versions.qairt.is_empty()); + } +} diff --git a/sdk/model-manager/crates/core/src/source/ai_hub/mod.rs b/sdk/model-manager/crates/core/src/source/ai_hub/mod.rs index e2c308171..b211a82ba 100644 --- a/sdk/model-manager/crates/core/src/source/ai_hub/mod.rs +++ b/sdk/model-manager/crates/core/src/source/ai_hub/mod.rs @@ -348,6 +348,7 @@ impl ModelSource for AiHubSource { model_type, plugin_id: "qairt".to_string(), precision: precision_label, + qairt_version: asset.tool_versions.qairt.clone(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/src/source/ai_hub/selector.rs b/sdk/model-manager/crates/core/src/source/ai_hub/selector.rs index ce34238b4..7fd488b8d 100644 --- a/sdk/model-manager/crates/core/src/source/ai_hub/selector.rs +++ b/sdk/model-manager/crates/core/src/source/ai_hub/selector.rs @@ -162,7 +162,7 @@ impl std::fmt::Display for UnavailableChipset { #[cfg(test)] mod tests { use super::*; - use crate::source::ai_hub::manifest::ChipsetInfo; + use crate::source::ai_hub::manifest::{ChipsetInfo, ToolVersions}; fn platform(entries: &[(&str, &[&str])]) -> PlatformInfo { PlatformInfo { @@ -199,6 +199,7 @@ mod tests { precision: precision.to_string(), download_url: format!("https://example.invalid/{chipset}-{precision}.zip"), uncompressed_size: Some(1), + tool_versions: ToolVersions::default(), } } diff --git a/sdk/model-manager/crates/core/src/source/dockerhub.rs b/sdk/model-manager/crates/core/src/source/dockerhub.rs index 864705d9b..899728700 100644 --- a/sdk/model-manager/crates/core/src/source/dockerhub.rs +++ b/sdk/model-manager/crates/core/src/source/dockerhub.rs @@ -530,6 +530,7 @@ fn build_plan( model_type, plugin_id: "llama_cpp".to_string(), precision: config.quantization.clone(), + qairt_version: String::new(), model_file, mmproj_file, tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/src/source/localfs.rs b/sdk/model-manager/crates/core/src/source/localfs.rs index 232586c18..d56b7be30 100644 --- a/sdk/model-manager/crates/core/src/source/localfs.rs +++ b/sdk/model-manager/crates/core/src/source/localfs.rs @@ -224,6 +224,7 @@ impl LocalFsSource { model_type, plugin_id: QAIRT_PLUGIN_ID.to_string(), precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), @@ -315,6 +316,7 @@ impl LocalFsSource { model_type, plugin_id: QAIRT_PLUGIN_ID.to_string(), precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/src/store.rs b/sdk/model-manager/crates/core/src/store.rs index 329c48f27..a1a12841c 100644 --- a/sdk/model-manager/crates/core/src/store.rs +++ b/sdk/model-manager/crates/core/src/store.rs @@ -304,6 +304,7 @@ mod tests { model_type: ModelType::Llm, plugin_id: "llama_cpp".to_string(), precision: String::new(), + qairt_version: String::new(), model_file, mmproj_file: ModelFileInfo::default(), tokenizer_file: ModelFileInfo::default(), diff --git a/sdk/model-manager/crates/core/tests/ai_hub_pull.rs b/sdk/model-manager/crates/core/tests/ai_hub_pull.rs index 18496fe0d..c23a6ca3d 100644 --- a/sdk/model-manager/crates/core/tests/ai_hub_pull.rs +++ b/sdk/model-manager/crates/core/tests/ai_hub_pull.rs @@ -134,7 +134,8 @@ async fn ai_hub_pull_writes_manifest_and_extracts_flat() { "runtime": "RUNTIME_GENIE", "precision": "PRECISION_W4A16", "download_url": "{asset_url}", - "uncompressed_size": {} + "uncompressed_size": {}, + "tool_versions": {{"qairt": "2.45.0.260326154327"}} }} ] }}"#, @@ -212,6 +213,7 @@ async fn ai_hub_pull_writes_manifest_and_extracts_flat() { let mf = store.get_manifest("tests/TestNet").unwrap(); assert_eq!(mf.plugin_id, "qairt"); assert_eq!(mf.precision, "W4A16"); + assert_eq!(mf.qairt_version, "2.45.0.260326154327"); let entry = mf.model_file.get("N/A").expect("N/A quant entry"); assert_eq!(entry.name, "model-00.bin"); assert!(entry.downloaded); diff --git a/sdk/model-manager/crates/ffi/src/store.rs b/sdk/model-manager/crates/ffi/src/store.rs index 7e3dd00ff..282e07f4a 100644 --- a/sdk/model-manager/crates/ffi/src/store.rs +++ b/sdk/model-manager/crates/ffi/src/store.rs @@ -33,6 +33,7 @@ pub struct GenieXModelPaths { pub model_name: *mut c_char, pub plugin_id: *mut c_char, pub model_type: GenieXModelType, + pub qairt_version: *mut c_char, } impl GenieXModelPaths { @@ -45,6 +46,7 @@ impl GenieXModelPaths { model_name: std::ptr::null_mut(), plugin_id: std::ptr::null_mut(), model_type: GenieXModelType::Llm, + qairt_version: std::ptr::null_mut(), } } } @@ -73,6 +75,7 @@ pub extern "C" fn geniex_model_get_paths( (*out_paths).model_dir = str_to_cptr(&paths.model_dir.to_string_lossy()); (*out_paths).model_name = str_to_cptr(&paths.model_name); (*out_paths).plugin_id = str_to_cptr(&paths.plugin_id); + (*out_paths).qairt_version = str_to_cptr(&paths.qairt_version); (*out_paths).mmproj_path = paths .mmproj_path .as_ref() @@ -104,6 +107,7 @@ pub unsafe extern "C" fn geniex_model_paths_free(paths: *mut GenieXModelPaths) { free_cptr(p.model_dir); free_cptr(p.model_name); free_cptr(p.plugin_id); + free_cptr(p.qairt_version); *paths = GenieXModelPaths::null(); } diff --git a/sdk/model-manager/include/geniex_model.h b/sdk/model-manager/include/geniex_model.h index ef06879c1..679672bf6 100644 --- a/sdk/model-manager/include/geniex_model.h +++ b/sdk/model-manager/include/geniex_model.h @@ -101,6 +101,7 @@ typedef struct { char* model_name; /**< Architecture name, e.g. "qwen3-4b". */ char* plugin_id; /**< Plugin ID, e.g. "llama_cpp". */ geniex_ModelType model_type; /**< LLM or VLM. */ + char* qairt_version; /**< QAIRT version the assets were built for; "" if none. */ } geniex_ModelPaths; /** From e6ac4599a6c727182f3a78346cadf7cb10d261c7 Mon Sep 17 00:00:00 2001 From: RemiliaForever Date: Wed, 15 Jul 2026 18:09:31 +0800 Subject: [PATCH 2/2] refactor(cli): drop unused AI Hub version/base-URL config The pinned aihm release version and base URL are owned by the Rust model-manager (sdk/model-manager/crates/core/src/config.rs); the CLI's DefaultAIHubBaseURL / DefaultAIHubVersion constants and the AIHubVersion config field were dead mirrors with no remaining readers. Remove them and the stale viper default. Signed-off-by: RemiliaForever --- cli/internal/config/config.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index c69dbdea1..3c9fafe69 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -11,14 +11,6 @@ import ( "github.com/spf13/viper" ) -// DefaultAIHubBaseURL is the public root for Qualcomm AI Hub release assets. -const DefaultAIHubBaseURL = "https://qaihub-public-assets.s3.us-west-2.amazonaws.com/qai-hub-models" - -// DefaultAIHubVersion is the pinned aihm release the CLI consumes. The public -// bucket has no `latest` alias; manifests are only at -// /releases//manifest.json. Override via GENIEX_AIHUBVERSION. -const DefaultAIHubVersion = "v0.57.0" - type Config struct { // Global settings DataDir string @@ -39,17 +31,15 @@ type Config struct { KeyFile string // TLS private key file path // Env only params - HFToken string - Log string - AIHubVersion string // Override the pinned aihm release version + HFToken string + Log string } // init sets up viper defaults and env binding. Runs once at package load. func init() { // ENV only param need to set default here - viper.SetDefault("hftoken", "") // Default empty token - viper.SetDefault("log", "none") // Default log level - viper.SetDefault("aihubversion", DefaultAIHubVersion) // Pinned aihm release version + viper.SetDefault("hftoken", "") // Default empty token + viper.SetDefault("log", "none") // Default log level viper.SetEnvPrefix("geniex") viper.AutomaticEnv()