File size: 5,675 Bytes
e85df1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
//! 本机 GPU 状态诊断:读 Ollama `/api/ps` 的真实 VRAM 分配。
//!
//! # 为什么要读 `/api/ps` 而不是猜
//!
//! 大多数应用会通过 `num_gpu` 参数"控制"GPU 层数——**这是错的**。
//! Ollama 自己会按模型结构和显存选择 Metal/CUDA 后端与层数;
//! 强行传 `num_gpu` 会触发 runner 重载,反而更慢。
//!
//! 正确的做法:**让 Ollama 自己决定,然后如实报告它实际分配了多少显存。**

use serde::{Deserialize, Serialize};

/// 本机 Ollama 的默认地址。
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";

/// 单个模型的 GPU 驻留状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GpuModelStatus {
    /// 模型名(如 `qwen3-06b:latest`)。
    pub name: String,
    /// 模型总大小(字节)。
    pub model_bytes: u64,
    /// 实际分配在 VRAM 的字节数(`size_vram`)。
    pub gpu_memory_bytes: u64,
    /// 上下文长度(Ollama 新版才返回)。
    pub context_length: Option<u64>,
    /// 驻留过期时间(keep_alive 到期)。
    pub expires_at: Option<String>,
    /// GPU 驻留百分比(0~100,近似值)。
    /// 这是**驻留估算**,不是 GPU 利用率,也不是 tokens/s。
    pub gpu_residency_percent: u8,
}

/// 本机 GPU 加速总状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GpuAccelerationStatus {
    /// Ollama 是否在线。
    pub online: bool,
    /// 查询时间(毫秒时间戳)。
    pub checked_at_ms: i64,
    /// 已加载的模型列表。
    pub models: Vec<GpuModelStatus>,
}

#[derive(Debug, Default, Deserialize)]
struct PsResponse {
    #[serde(default)]
    models: Vec<PsModel>,
}

#[derive(Debug, Default, Deserialize)]
struct PsModel {
    #[serde(default)]
    name: String,
    #[serde(default)]
    size: u64,
    #[serde(default, rename = "size_vram")]
    size_vram: u64,
    #[serde(default, rename = "context_length")]
    context_length: Option<u64>,
    #[serde(default, rename = "expires_at")]
    expires_at: Option<String>,
}

/// 计算 GPU 驻留百分比(纯函数,可单测)。
pub fn residency_percent(size: u64, gpu: u64) -> u8 {
    if size == 0 {
        return 0;
    }
    ((gpu.min(size) as u128 * 100 / size as u128).min(100)) as u8
}

/// 把 `/api/ps` 的原始响应转成结构化状态(纯函数,可单测)。
pub fn from_ps_json(value: &str, now_ms: i64) -> Result<GpuAccelerationStatus, String> {
    let parsed: PsResponse =
        serde_json::from_str(value).map_err(|e| format!("无法解析 /api/ps 响应:{e}"))?;
    let models = parsed
        .models
        .into_iter()
        .map(|model| GpuModelStatus {
            gpu_residency_percent: residency_percent(model.size, model.size_vram),
            name: model.name,
            model_bytes: model.size,
            gpu_memory_bytes: model.size_vram,
            context_length: model.context_length,
            expires_at: model.expires_at,
        })
        .collect();
    Ok(GpuAccelerationStatus {
        online: true,
        checked_at_ms: now_ms,
        models,
    })
}

/// 查询本机 Ollama 的 GPU 状态(HTTP 请求)。
pub async fn query_status(base_url: Option<&str>) -> Result<GpuAccelerationStatus, String> {
    let base = base_url
        .unwrap_or(DEFAULT_OLLAMA_BASE_URL)
        .trim_end_matches('/');
    let client = reqwest::Client::builder()
        .connect_timeout(std::time::Duration::from_secs(1))
        .build()
        .map_err(|e| format!("初始化状态查询失败:{e}"))?;
    let response = client
        .get(format!("{base}/api/ps"))
        .timeout(std::time::Duration::from_secs(3))
        .send()
        .await
        .map_err(|e| format!("无法读取本机 Ollama GPU 状态:{e}"))?;
    if !response.status().is_success() {
        return Err(format!("本机 Ollama 状态接口返回 {}", response.status()));
    }
    let text = response
        .text()
        .await
        .map_err(|e| format!("无法读取响应体:{e}"))?;
    from_ps_json(&text, now_ms())
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reports_real_residency_and_handles_cpu_fallback() {
        let status = from_ps_json(
            r#"{"models":[
                {"name":"gpu-model","size":1000,"size_vram":1000,"context_length":4096,"expires_at":"soon"},
                {"name":"partial-model","size":1000,"size_vram":500},
                {"name":"cpu-model","size":1000,"size_vram":0},
                {"name":"unknown-size","size":0,"size_vram":0}
            ]}"#,
            0,
        )
        .unwrap();
        assert!(status.online);
        assert_eq!(status.models[0].gpu_residency_percent, 100);
        assert_eq!(status.models[1].gpu_residency_percent, 50);
        assert_eq!(status.models[2].gpu_residency_percent, 0);
        assert_eq!(status.models[3].gpu_residency_percent, 0);
        assert_eq!(status.models[0].context_length, Some(4096));
        // 序列化字段名(camelCase)
        let wire = serde_json::to_value(&status).unwrap();
        assert_eq!(wire["models"][0]["gpuResidencyPercent"], 100);
        assert_eq!(wire["models"][0]["gpuMemoryBytes"], 1000);
    }

    #[test]
    fn accepts_no_loaded_models() {
        let status = from_ps_json("{}", 0).unwrap();
        assert!(status.online);
        assert!(status.models.is_empty());
    }
}