Skip to content

Commit 52f3cd8

Browse files
committed
AI offer: don't prompt local-model download on Intel
- `get_ai_status` returned `Offer` on Intel Macs (default provider is "local"), so the install toast appeared and only revealed the arch limitation after the user clicked Download and `start_ai_download` rejected. Now gated upstream. - Extracted pure `compute_ai_status` helper so the gate and dismissal logic are unit-testable without the `MANAGER` singleton or the compile-time `cfg!(target_arch)`. - Added 7 tests covering provider/install/server/arch/dismissal combinations. - Clarified `ai/mod.rs` module docstring: only the **local** path needs Apple Silicon. Cloud AI works on any hardware. - Added a Gotcha to `ai/CLAUDE.md` so this trap doesn't get reintroduced.
1 parent 06ea72f commit 52f3cd8

3 files changed

Lines changed: 119 additions & 25 deletions

File tree

apps/desktop/src-tauri/src/ai/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ privacy-focused users. The architecture doesn't fight this switch: it's just a d
139139

140140
## Gotchas
141141

142+
**Gotcha**: Only **local** AI requires Apple Silicon. Cloud AI (BYOK OpenAI / Anthropic / Gemini / any OpenAI-compatible endpoint) works on Intel Macs too. Don't gate the whole AI subsystem on `is_local_ai_supported()` — gate only the local-specific code paths (`start_ai_server`, `start_ai_download`, and the `Offer` branch of `compute_ai_status` in `manager.rs`). The frontend has its own short-circuit: `ai-state.svelte.ts::initAiState` returns early when `ai.provider === "cloud"` so the install toast never fires for cloud users, regardless of arch. A previous version of `get_ai_status` returned `Offer` on Intel because the default provider is `"local"`; users saw the download toast and only learned their hardware couldn't run it after clicking Download and hitting the `start_ai_download` rejection. Now `compute_ai_status` gates `Offer` on `local_ai_supported`.
143+
142144
**Gotcha**: `genai` requires `base_url` to end with `/`. Without the trailing slash, `Url::join("chat/completions")` strips the last segment and you'd hit `https://api.openai.com/chat/completions` (404) instead of `/v1/chat/completions`. `client.rs::build_client` normalizes by appending `/` if missing.
143145

144146
**Gotcha**: `genai 0.6` auto-routes `gpt-5*`, `*-codex`, `*-pro` to the Responses API, but `o1*`/`o3*`/`o4*`/`chatgpt-*` stay on Chat Completions even though they also reject custom `temperature`. We layer `is_openai_chat_reasoning_model()` on top to strip `temperature`/`top_p` and substitute `ReasoningEffort::Low` for those. The heuristic also matches `gpt-5*` as defense-in-depth in case `genai`'s routing rule changes.

apps/desktop/src-tauri/src/ai/manager.rs

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,59 @@ pub fn shutdown() {
116116
#[specta::specta]
117117
pub fn get_ai_status() -> AiStatus {
118118
let manager = MANAGER.lock_ignore_poison();
119-
match &*manager {
120-
Some(m) if m.provider == "off" => AiStatus::Unavailable,
121-
Some(m) if m.state.installed && m.child_pid.is_some() => AiStatus::Available,
122-
Some(m) if m.state.installed => AiStatus::Unavailable, // installed but server not running
123-
Some(m) => {
124-
// Check if dismissed
125-
if let Some(until) = m.state.dismissed_until
126-
&& is_still_dismissed(until)
127-
{
128-
return AiStatus::Unavailable;
129-
}
130-
AiStatus::Offer
131-
}
132-
None => AiStatus::Unavailable,
119+
let Some(m) = manager.as_ref() else {
120+
return AiStatus::Unavailable;
121+
};
122+
compute_ai_status(
123+
&m.provider,
124+
m.state.installed,
125+
m.child_pid.is_some(),
126+
m.state.dismissed_until,
127+
is_local_ai_supported(),
128+
current_unix_seconds(),
129+
)
130+
}
131+
132+
/// Pure decision function for [`get_ai_status`]. Split out so the global `MANAGER` lock
133+
/// and the compile-time `cfg!(target_arch)` gate don't have to participate in tests.
134+
fn compute_ai_status(
135+
provider: &str,
136+
installed: bool,
137+
server_running: bool,
138+
dismissed_until: Option<u64>,
139+
local_ai_supported: bool,
140+
now_secs: u64,
141+
) -> AiStatus {
142+
if provider == "off" {
143+
return AiStatus::Unavailable;
133144
}
145+
if installed && server_running {
146+
return AiStatus::Available;
147+
}
148+
if installed {
149+
return AiStatus::Unavailable; // installed but server not running
150+
}
151+
// Not installed. Only offer the local-model download if the hardware can run it;
152+
// otherwise the user sees the toast, clicks Download, and only then discovers
153+
// `start_ai_download` rejects with "Local AI not supported on this hardware".
154+
// Cloud AI is unaffected: the frontend short-circuits this status path when
155+
// `ai.provider === "cloud"` (see `ai-state.svelte.ts::initAiState`).
156+
if !local_ai_supported {
157+
return AiStatus::Unavailable;
158+
}
159+
if let Some(until) = dismissed_until
160+
&& now_secs < until
161+
{
162+
return AiStatus::Unavailable;
163+
}
164+
AiStatus::Offer
165+
}
166+
167+
fn current_unix_seconds() -> u64 {
168+
SystemTime::now()
169+
.duration_since(UNIX_EPOCH)
170+
.unwrap_or_default()
171+
.as_secs()
134172
}
135173

136174
/// Returns the port the llama-server is listening on, if running.
@@ -823,14 +861,6 @@ fn cleanup_stale_partial_download(m: &mut ManagerState) {
823861
}
824862
}
825863

826-
fn is_still_dismissed(until_timestamp: u64) -> bool {
827-
let now = SystemTime::now()
828-
.duration_since(UNIX_EPOCH)
829-
.unwrap_or_default()
830-
.as_secs();
831-
now < until_timestamp
832-
}
833-
834864
async fn do_download<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
835865
let ai_dir = get_ai_dir(app);
836866
fs::create_dir_all(&ai_dir).map_err(|e| format!("Failed to create AI directory: {e}"))?;
@@ -1077,4 +1107,61 @@ mod tests {
10771107
let status = get_ai_status();
10781108
assert_eq!(status, AiStatus::Unavailable);
10791109
}
1110+
1111+
// --- compute_ai_status: pure decision function ---
1112+
1113+
const NOW: u64 = 1_700_000_000;
1114+
1115+
#[test]
1116+
fn compute_ai_status_provider_off_is_unavailable() {
1117+
let s = compute_ai_status("off", true, true, None, true, NOW);
1118+
assert_eq!(s, AiStatus::Unavailable);
1119+
}
1120+
1121+
#[test]
1122+
fn compute_ai_status_installed_and_running_is_available() {
1123+
let s = compute_ai_status("local", true, true, None, true, NOW);
1124+
assert_eq!(s, AiStatus::Available);
1125+
}
1126+
1127+
#[test]
1128+
fn compute_ai_status_installed_but_server_down_is_unavailable() {
1129+
let s = compute_ai_status("local", true, false, None, true, NOW);
1130+
assert_eq!(s, AiStatus::Unavailable);
1131+
}
1132+
1133+
#[test]
1134+
fn compute_ai_status_not_installed_offers_on_apple_silicon() {
1135+
let s = compute_ai_status("local", false, false, None, true, NOW);
1136+
assert_eq!(s, AiStatus::Offer);
1137+
}
1138+
1139+
#[test]
1140+
fn compute_ai_status_not_installed_does_not_offer_on_intel() {
1141+
// The bug this guard fixes: Intel users with default provider="local" used to see
1142+
// the AI download toast, only to be rejected by `start_ai_download` on click.
1143+
let s = compute_ai_status("local", false, false, None, false, NOW);
1144+
assert_eq!(s, AiStatus::Unavailable);
1145+
}
1146+
1147+
#[test]
1148+
fn compute_ai_status_intel_with_installed_state_still_unavailable() {
1149+
// Defense in depth: even if state somehow says installed on Intel (e.g. user copied
1150+
// their data dir across machines), we still don't claim Available because the binary
1151+
// is ARM64-only and won't run.
1152+
let s = compute_ai_status("local", true, false, None, false, NOW);
1153+
assert_eq!(s, AiStatus::Unavailable);
1154+
}
1155+
1156+
#[test]
1157+
fn compute_ai_status_dismissed_offer_is_hidden() {
1158+
let s = compute_ai_status("local", false, false, Some(NOW + 60), true, NOW);
1159+
assert_eq!(s, AiStatus::Unavailable);
1160+
}
1161+
1162+
#[test]
1163+
fn compute_ai_status_expired_dismissal_offers_again() {
1164+
let s = compute_ai_status("local", false, false, Some(NOW - 60), true, NOW);
1165+
assert_eq!(s, AiStatus::Offer);
1166+
}
10801167
}

apps/desktop/src-tauri/src/ai/mod.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
//! Local AI features powered by local LLMs via llama-server.
1+
//! AI features. Two paths:
22
//!
3-
//! AI features require Apple Silicon (M1 or later). Intel Macs are not supported
4-
//! because the bundled llama-server binary is ARM64-only.
3+
//! - **Local LLM** via bundled `llama-server`. Requires Apple Silicon (M1+) because the
4+
//! binary is ARM64-only. Gated by [`is_local_ai_supported`].
5+
//! - **Cloud AI** (OpenAI / Anthropic / Gemini / any OpenAI-compatible endpoint, BYOK).
6+
//! Works on any hardware, including Intel Macs.
7+
//!
8+
//! Don't conflate the two: an Intel user can absolutely use AI features, just not the
9+
//! local path. Code that turns off "AI" wholesale on non-aarch64 is a bug.
510
//!
611
//! ## Model registry
712
//!

0 commit comments

Comments
 (0)