From a5f1e73ea35da706530eb4dde81508a2bd7e5f45 Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:18:23 -0700 Subject: [PATCH 01/34] Clarify provider trust diagnostics --- provider/src/main.rs | 463 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 415 insertions(+), 48 deletions(-) diff --git a/provider/src/main.rs b/provider/src/main.rs index 25678857..78b51663 100644 --- a/provider/src/main.rs +++ b/provider/src/main.rs @@ -59,10 +59,6 @@ const DEFAULT_COORDINATOR_WS_URL: &str = match option_env!("DARKBLOOM_COORDINATO Some(v) => v, None => "wss://api.darkbloom.dev/ws/provider", }; -const DEFAULT_ENROLL_PROFILE_URL: &str = match option_env!("DARKBLOOM_ENROLL_PROFILE_URL") { - Some(v) => v, - None => "https://api.darkbloom.dev/enroll.mobileconfig", -}; const DEFAULT_INSTALL_URL: &str = match option_env!("DARKBLOOM_INSTALL_URL") { Some(v) => v, None => "https://api.darkbloom.dev/install.sh", @@ -96,6 +92,52 @@ struct CatalogModel { min_ram_gb: i32, } +#[derive(Debug, Clone, serde::Deserialize)] +struct CoordinatorAttestationResponse { + #[serde(default)] + providers: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct CoordinatorProviderTrust { + #[serde(default)] + provider_id: String, + #[serde(default)] + serial_number: String, + #[serde(default)] + trust_level: String, + #[serde(default)] + status: String, + #[serde(default)] + mdm_verified: bool, + #[serde(default)] + acme_verified: bool, + #[serde(default)] + mda_verified: bool, + #[serde(default)] + secure_enclave: bool, + #[serde(default)] + sip_enabled: bool, + #[serde(default)] + secure_boot_enabled: bool, + #[serde(default)] + authenticated_root_enabled: bool, +} + +impl CoordinatorProviderTrust { + fn is_online(&self) -> bool { + self.status.eq_ignore_ascii_case("online") + } + + fn is_hardware_verified(&self) -> bool { + self.trust_level.eq_ignore_ascii_case("hardware") + } + + fn short_provider_id(&self) -> &str { + self.provider_id.get(..8).unwrap_or(&self.provider_id) + } +} + fn default_model_type() -> String { "text".into() } @@ -405,6 +447,47 @@ fn coordinator_http_base(coordinator_url: &str) -> String { .to_string() } +fn prefer_provider_record( + a: &CoordinatorProviderTrust, + b: &CoordinatorProviderTrust, +) -> std::cmp::Ordering { + b.is_hardware_verified() + .cmp(&a.is_hardware_verified()) + .then_with(|| b.is_online().cmp(&a.is_online())) + .then_with(|| a.provider_id.cmp(&b.provider_id)) +} + +async fn fetch_coordinator_provider_trust( + coordinator_url: &str, + serial_number: &str, +) -> Result> { + let base_url = coordinator_http_base(coordinator_url); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + let resp = client + .get(format!("{base_url}/v1/providers/attestation")) + .send() + .await + .with_context(|| format!("failed to query {base_url}/v1/providers/attestation"))?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("coordinator returned HTTP {status}"); + } + + let body: CoordinatorAttestationResponse = resp + .json() + .await + .context("failed to parse coordinator attestation response")?; + let mut providers: Vec<_> = body + .providers + .into_iter() + .filter(|p| p.serial_number == serial_number) + .collect(); + providers.sort_by(prefer_provider_record); + Ok(providers) +} + fn model_cache_dir(model_id: &str) -> std::path::PathBuf { dirs::home_dir() .unwrap_or_default() @@ -1497,9 +1580,9 @@ enum Command { #[arg(long, default_value = DEFAULT_COORDINATOR_WS_URL)] coordinator: String, - /// MDM enrollment profile URL - #[arg(long, default_value = DEFAULT_ENROLL_PROFILE_URL)] - profile_url: String, + /// Legacy static MDM enrollment profile URL. Prefer the default dynamic enrollment flow. + #[arg(long, hide = true)] + profile_url: Option, /// Model to serve (auto-selects if not specified) #[arg(long)] @@ -1552,6 +1635,10 @@ enum Command { /// Coordinator URL to test connectivity #[arg(long, default_value = DEFAULT_COORDINATOR_HTTP_URL)] coordinator: String, + + /// Include provider ID, serial, and coordinator trust details for support + #[arg(long)] + support: bool, }, /// Start the provider in the background (uses existing config) @@ -1685,7 +1772,10 @@ async fn main() -> Result<()> { coordinator, } => cmd_models(action, coordinator, model).await, Command::Earnings { coordinator } => cmd_earnings(coordinator).await, - Command::Doctor { coordinator } => cmd_doctor(coordinator).await, + Command::Doctor { + coordinator, + support, + } => cmd_doctor(coordinator, support).await, Command::Start { coordinator, model, @@ -1817,7 +1907,7 @@ async fn cmd_key_status() -> Result<()> { async fn cmd_install( coordinator_url: String, - profile_url: String, + profile_url: Option, model_override: Option, ) -> Result<()> { println!("╔══════════════════════════════════════════╗"); @@ -1845,42 +1935,65 @@ async fn cmd_install( println!(" ✓ E2E key: ephemeral (generated at startup)"); println!(); - // Step 3: MDM enrollment (skip if already enrolled) + // Step 3: MDM enrollment profile. Local profile presence is not the same + // as coordinator hardware trust; doctor checks the network-side state. println!("Step 3/6: MDM enrollment..."); let already_enrolled = security::check_mdm_enrolled(); if already_enrolled { - println!(" ✓ Already enrolled in MDM — skipping"); + println!(" ✓ Local MDM profile present"); + println!(" Coordinator hardware trust will be verified after provider registration."); } else { - let profile_path = std::env::temp_dir().join("EigenInference-Enroll.mobileconfig"); - println!(" Downloading enrollment profile..."); - let client = reqwest::Client::new(); - let resp = client.get(&profile_url).send().await?; - if !resp.status().is_success() { - println!( - " ⚠ Could not download profile (HTTP {}). Skipping MDM enrollment.", - resp.status() - ); - println!(" You can enroll later: darkbloom enroll"); - } else { - let profile_bytes = resp.bytes().await?; - std::fs::write(&profile_path, &profile_bytes)?; + match get_serial_number() { + Ok(serial) => { + let profile_path = std::env::temp_dir() + .join(format!("EigenInference-Enroll-{serial}.mobileconfig")); + println!(" Requesting enrollment profile..."); + let client = reqwest::Client::new(); + let resp = if let Some(ref legacy_url) = profile_url { + client.get(legacy_url).send().await? + } else { + let enroll_url = + format!("{}/v1/enroll", coordinator_http_base(&coordinator_url)); + client + .post(&enroll_url) + .json(&serde_json::json!({"serial_number": serial})) + .send() + .await? + }; - #[cfg(target_os = "macos")] - { - println!(" Opening enrollment profile..."); - println!(" Install it in System Settings → General → Device Management"); - println!(" (Only queries security status — no access to personal data)"); - println!(); - let _ = std::process::Command::new("open") - .arg(&profile_path) - .status(); - } + if !resp.status().is_success() { + println!( + " ⚠ Could not download profile (HTTP {}). Skipping MDM enrollment.", + resp.status() + ); + println!(" You can enroll later: darkbloom enroll"); + } else { + let profile_bytes = resp.bytes().await?; + std::fs::write(&profile_path, &profile_bytes)?; - println!(" Press Enter after installing (or to skip)..."); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; + #[cfg(target_os = "macos")] + { + println!(" Opening enrollment profile..."); + println!(" Install it in System Settings → General → Device Management"); + println!(" (Only queries security status — no access to personal data)"); + println!(); + let _ = std::process::Command::new("open") + .arg(&profile_path) + .status(); + } + + println!(" Press Enter after installing (or to skip)..."); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + println!(" Enrollment profile opened; coordinator verification is pending."); + } + } + Err(e) => { + println!(" ⚠ Could not read serial number ({e}). Skipping MDM enrollment."); + println!(" You can enroll later: darkbloom enroll"); + } } } println!(); @@ -4767,11 +4880,11 @@ async fn cmd_status() -> Result<()> { println!(" SE signing: ✓ Ephemeral (per-launch)"); println!( - " MDM enrolled: {}", + " Local MDM: {}", if security::check_mdm_enrolled() { - "✓ Yes (hardware trust)" + "✓ Profile present" } else { - "✗ No — not routable without MDM enrollment" + "✗ No profile found" } ); println!(); @@ -4789,6 +4902,62 @@ async fn cmd_status() -> Result<()> { ); println!(); + // Coordinator trust is the network-side source of truth for routing. + println!(" Coordinator trust:"); + match get_serial_number() { + Ok(serial) => { + println!(" Serial: {serial}"); + match fetch_coordinator_provider_trust(DEFAULT_COORDINATOR_HTTP_URL, &serial).await { + Ok(records) if records.is_empty() => { + println!(" Provider: ✗ No coordinator record for this serial"); + println!(" Trust: ✗ Not verified"); + println!( + " Next: Start or restart the provider after installing the MDM profile" + ); + } + Ok(records) => { + let record = &records[0]; + println!( + " Provider: {} ({})", + record.short_provider_id(), + record.status + ); + println!(" Trust: {}", record.trust_level); + println!( + " MDM: {}", + if record.mdm_verified { + "✓ Verified" + } else { + "✗ Not verified" + } + ); + println!( + " MDA/ACME: {}/{}", + if record.mda_verified { "✓" } else { "✗" }, + if record.acme_verified { "✓" } else { "✗" } + ); + if record.is_hardware_verified() { + println!(" Trust gate: ✓ Passed"); + } else { + println!( + " Trust gate: ✗ Not routable until coordinator verifies hardware trust" + ); + println!( + " Next: darkbloom stop && darkbloom start, then darkbloom doctor" + ); + } + } + Err(e) => { + println!(" Trust: ? Could not check coordinator ({e})"); + } + } + } + Err(e) => { + println!(" Serial: ? Could not read serial ({e})"); + } + } + println!(); + // Models (catalog-filtered) let models = models::scan_models(&hw); let catalog = fetch_catalog(DEFAULT_COORDINATOR_HTTP_URL).await; @@ -5121,12 +5290,90 @@ async fn cmd_earnings(coordinator_url: String) -> Result<()> { Ok(()) } -async fn cmd_doctor(coordinator_url: String) -> Result<()> { +#[cfg(target_os = "macos")] +fn support_command_output(program: &str, args: &[&str]) -> String { + match std::process::Command::new(program).args(args).output() { + Ok(output) => { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let trimmed = combined.trim(); + if trimmed.is_empty() { + "".to_string() + } else { + trimmed.lines().take(12).collect::>().join("\n ") + } + } + Err(e) => format!(""), + } +} + +fn print_doctor_support_info( + coordinator_url: &str, + serial_number: Option<&str>, + local_mdm_profile: bool, + provider_records: &[CoordinatorProviderTrust], + coordinator_trust_error: Option<&str>, +) { + println!(); + println!("Support info"); + println!(" Coordinator: {}", coordinator_http_base(coordinator_url)); + println!(" Serial: {}", serial_number.unwrap_or("")); + println!( + " Local MDM profile: {}", + if local_mdm_profile { + "present" + } else { + "not detected" + } + ); + + if provider_records.is_empty() { + println!(" Coordinator provider records: none for this serial"); + } else { + println!(" Coordinator provider records:"); + for record in provider_records { + println!( + " {} status={} trust={} mdm={} mda={} acme={} se={} sip={} secure_boot={} root={}", + record.provider_id, + record.status, + record.trust_level, + record.mdm_verified, + record.mda_verified, + record.acme_verified, + record.secure_enclave, + record.sip_enabled, + record.secure_boot_enabled, + record.authenticated_root_enabled + ); + } + } + if let Some(error) = coordinator_trust_error { + println!(" Coordinator trust lookup error: {error}"); + } + + #[cfg(target_os = "macos")] + { + println!(" profiles status -type enrollment:"); + println!( + " {}", + support_command_output("profiles", &["status", "-type", "enrollment"]) + ); + } +} + +async fn cmd_doctor(coordinator_url: String, support: bool) -> Result<()> { println!("Darkbloom Doctor — System Diagnostics"); println!(); let mut issues: Vec = Vec::new(); let mut passed = 0; + let total_checks = 9; + let local_serial = get_serial_number().ok(); + let mut provider_records: Vec = Vec::new(); + let mut coordinator_trust_error: Option = None; // 1. Hardware print!("1. Hardware detection........... "); @@ -5191,16 +5438,17 @@ async fn cmd_doctor(coordinator_url: String) -> Result<()> { passed += 1; } - // 4. MDM enrollment - print!("4. MDM enrollment.............. "); - if security::check_mdm_enrolled() { - println!("✓ Enrolled"); + // 4. Local MDM profile + print!("4. Local MDM profile........... "); + let local_mdm_profile = security::check_mdm_enrolled(); + if local_mdm_profile { + println!("✓ Present"); passed += 1; } else { #[cfg(target_os = "macos")] { - println!("✗ Not enrolled"); - issues.push("Run: darkbloom enroll".to_string()); + println!("✗ Not detected"); + issues.push("Install the MDM profile: darkbloom enroll".to_string()); } #[cfg(not(target_os = "macos"))] { @@ -5317,9 +5565,65 @@ async fn cmd_doctor(coordinator_url: String) -> Result<()> { } } + // 9. Coordinator hardware trust + print!("9. Coordinator hardware trust.. "); + match local_serial.as_deref() { + Some(serial) => match fetch_coordinator_provider_trust(&coordinator_url, serial).await { + Ok(records) if records.is_empty() => { + println!("✗ No provider record for serial {serial}"); + issues.push( + "Coordinator has no provider record for this serial. Start or restart the provider after installing the MDM profile." + .to_string(), + ); + } + Ok(records) => { + provider_records = records; + let record = &provider_records[0]; + if record.is_hardware_verified() { + println!( + "✓ hardware ({}, provider {})", + record.status, + record.short_provider_id() + ); + passed += 1; + } else { + println!( + "✗ {} ({}, provider {})", + record.trust_level, + record.status, + record.short_provider_id() + ); + if local_mdm_profile { + issues.push( + "Local MDM profile is present, but the coordinator has not verified this serial through MDM. Run: darkbloom stop && darkbloom start, then retry darkbloom doctor." + .to_string(), + ); + } else { + issues.push( + "Coordinator trust is self-signed because no local MDM profile is detected. Run: darkbloom enroll." + .to_string(), + ); + } + } + } + Err(e) => { + println!("? Could not check: {e}"); + coordinator_trust_error = Some(e.to_string()); + issues.push(format!( + "Could not check coordinator hardware trust for serial {serial}" + )); + } + }, + None => { + println!("✗ Could not read local serial number"); + issues + .push("Could not read Mac serial number for coordinator trust lookup".to_string()); + } + } + // Summary println!(); - println!("Result: {passed}/8 checks passed"); + println!("Result: {passed}/{total_checks} checks passed"); if issues.is_empty() { println!(); println!("All good! Start serving with: darkbloom serve"); @@ -5329,6 +5633,18 @@ async fn cmd_doctor(coordinator_url: String) -> Result<()> { for (i, issue) in issues.iter().enumerate() { println!(" {}. {}", i + 1, issue); } + println!(); + println!("For support details, run: darkbloom doctor --support"); + } + + if support { + print_doctor_support_info( + &coordinator_url, + local_serial.as_deref(), + local_mdm_profile, + &provider_records, + coordinator_trust_error.as_deref(), + ); } Ok(()) @@ -6686,6 +7002,57 @@ mod tests { ); } + #[test] + fn test_provider_trust_sort_prefers_hardware_then_online() { + let mut records = vec![ + CoordinatorProviderTrust { + provider_id: "self-online".into(), + serial_number: "SERIAL".into(), + trust_level: "self_signed".into(), + status: "online".into(), + mdm_verified: false, + acme_verified: false, + mda_verified: false, + secure_enclave: true, + sip_enabled: true, + secure_boot_enabled: true, + authenticated_root_enabled: true, + }, + CoordinatorProviderTrust { + provider_id: "hardware-offline".into(), + serial_number: "SERIAL".into(), + trust_level: "hardware".into(), + status: "offline".into(), + mdm_verified: true, + acme_verified: false, + mda_verified: false, + secure_enclave: true, + sip_enabled: true, + secure_boot_enabled: true, + authenticated_root_enabled: true, + }, + CoordinatorProviderTrust { + provider_id: "hardware-online".into(), + serial_number: "SERIAL".into(), + trust_level: "hardware".into(), + status: "online".into(), + mdm_verified: true, + acme_verified: false, + mda_verified: false, + secure_enclave: true, + sip_enabled: true, + secure_boot_enabled: true, + authenticated_root_enabled: true, + }, + ]; + + records.sort_by(prefer_provider_record); + + assert_eq!(records[0].provider_id, "hardware-online"); + assert_eq!(records[1].provider_id, "hardware-offline"); + assert_eq!(records[2].provider_id, "self-online"); + } + #[test] fn test_runtime_smoke_test_reports_success() { let path = write_test_command("#!/bin/sh\nprintf 'vllm-mlx 0.2.7; mlx-lm 0.31.2\\n'\n"); From b56b240188873342a69200dd20703324a746d77d Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Fri, 1 May 2026 03:27:04 -0700 Subject: [PATCH 02/34] Add Swift provider runtime --- .gitmodules | 6 + coordinator/internal/api/provider.go | 150 +++- coordinator/internal/api/provider_test.go | 326 +++++++++ coordinator/internal/api/server.go | 120 +++- .../internal/e2e/testdata/gen-vectors/main.go | 91 +++ coordinator/internal/protocol/messages.go | 4 +- coordinator/internal/registry/registry.go | 23 +- .../internal/registry/registry_test.go | 17 + libs/mlx-swift | 1 + libs/mlx-swift-lm | 1 + provider-swift/Package.resolved | 276 +++++++ provider-swift/Package.swift | 53 ++ provider-swift/README.md | 115 +++ .../ProviderCore/Auth/DeviceAuth.swift | 244 +++++++ .../Batching/BatchQueuePlanner.swift | 442 ++++++++++++ .../Benchmark/ModelBenchmark.swift | 252 +++++++ .../ProviderCore/Config/ProviderConfig.swift | 317 ++++++++ .../Coordinator/CoordinatorClient.swift | 675 ++++++++++++++++++ .../Coordinator/CoordinatorClientCodec.swift | 123 ++++ .../Coordinator/ExponentialBackoff.swift | 21 + .../ProviderCore/Crypto/NodeKeyPair.swift | 218 ++++++ .../Crypto/X25519ChaChaPoly.swift | 148 ++++ .../Hardware/HardwareDetector.swift | 229 ++++++ .../ProviderCore/Hardware/SystemMetrics.swift | 72 ++ .../Inference/BatchScheduler.swift | 491 +++++++++++++ .../Inference/ChatPromptFormatting.swift | 114 +++ .../ProviderCore/Inference/ChatRequest.swift | 186 +++++ .../Inference/InferenceCancellation.swift | 60 ++ .../Inference/InferenceEngine.swift | 317 ++++++++ .../Inference/OpenAIFormatter.swift | 124 ++++ .../Inference/SSEChunkFormatting.swift | 93 +++ .../Inference/SingleRequestInference.swift | 115 +++ .../Inference/UsageAccounting.swift | 57 ++ .../LocalMLXModelFoundation.swift | 344 +++++++++ .../ProviderCore/Models/ModelScanner.swift | 377 ++++++++++ .../ProviderCore/Models/WeightHasher.swift | 135 ++++ .../Sources/ProviderCore/Protocol/Enums.swift | 30 + .../ProviderCore/Protocol/Messages.swift | 531 ++++++++++++++ .../ProviderCore/Protocol/ProtocolCodec.swift | 327 +++++++++ .../Sources/ProviderCore/Protocol/Types.swift | 482 +++++++++++++ .../Sources/ProviderCore/ProviderCore.swift | 9 + .../Sources/ProviderCore/ProviderLoop.swift | 638 +++++++++++++++++ .../ProviderCore/Scheduling/Schedule.swift | 359 ++++++++++ .../ProviderCore/Security/AntiDebug.swift | 80 +++ .../Security/AttestationBuilder.swift | 398 +++++++++++ .../ProviderCore/Security/BinaryHasher.swift | 113 +++ .../Security/EnvironmentScrubber.swift | 39 + .../Security/SecureEnclaveIdentity.swift | 149 ++++ .../Security/SecurityFoundation.swift | 488 +++++++++++++ .../Security/SecurityHardening.swift | 556 +++++++++++++++ .../ProviderCore/Server/ServerRoutes.swift | 90 +++ .../Server/StandaloneServer.swift | 253 +++++++ .../ProviderCore/Service/LaunchAgent.swift | 277 +++++++ .../Telemetry/TelemetryClient.swift | 466 ++++++++++++ .../Telemetry/TelemetryEvent.swift | 246 +++++++ .../Telemetry/TelemetryOverflowQueue.swift | 167 +++++ .../ProviderCore/Update/SelfUpdater.swift | 279 ++++++++ .../Sources/darkbloom/BenchmarkCommand.swift | 61 ++ .../Sources/darkbloom/Darkbloom.swift | 213 ++++++ .../Sources/darkbloom/DoctorCommand.swift | 158 ++++ .../Sources/darkbloom/LoginCommand.swift | 55 ++ .../Sources/darkbloom/LogoutCommand.swift | 19 + .../Sources/darkbloom/ModelsCommand.swift | 60 ++ .../Sources/darkbloom/StartCommand.swift | 196 +++++ .../Sources/darkbloom/StatusCommand.swift | 46 ++ .../Sources/darkbloom/StopCommand.swift | 27 + .../Sources/darkbloom/UpdateCommand.swift | 77 ++ .../ProviderCoreTests/BatchingTests.swift | 169 +++++ .../CoordinatorClientTests.swift | 202 ++++++ .../Tests/ProviderCoreTests/CryptoTests.swift | 223 ++++++ .../ProviderCoreTests/FoundationTests.swift | 122 ++++ .../ProviderCoreTests/InferenceTests.swift | 130 ++++ .../IntegrationPlanTests.swift | 131 ++++ .../ProviderCoreTests/ProtocolTests.swift | 209 ++++++ .../ProviderCoreTests/ProviderCoreTests.swift | 6 + .../ProviderCoreTests/SecurityTests.swift | 273 +++++++ .../StandaloneServerTests.swift | 94 +++ 77 files changed, 14737 insertions(+), 48 deletions(-) create mode 100644 .gitmodules create mode 100644 coordinator/internal/e2e/testdata/gen-vectors/main.go create mode 160000 libs/mlx-swift create mode 160000 libs/mlx-swift-lm create mode 100644 provider-swift/Package.resolved create mode 100644 provider-swift/Package.swift create mode 100644 provider-swift/README.md create mode 100644 provider-swift/Sources/ProviderCore/Auth/DeviceAuth.swift create mode 100644 provider-swift/Sources/ProviderCore/Batching/BatchQueuePlanner.swift create mode 100644 provider-swift/Sources/ProviderCore/Benchmark/ModelBenchmark.swift create mode 100644 provider-swift/Sources/ProviderCore/Config/ProviderConfig.swift create mode 100644 provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClient.swift create mode 100644 provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClientCodec.swift create mode 100644 provider-swift/Sources/ProviderCore/Coordinator/ExponentialBackoff.swift create mode 100644 provider-swift/Sources/ProviderCore/Crypto/NodeKeyPair.swift create mode 100644 provider-swift/Sources/ProviderCore/Crypto/X25519ChaChaPoly.swift create mode 100644 provider-swift/Sources/ProviderCore/Hardware/HardwareDetector.swift create mode 100644 provider-swift/Sources/ProviderCore/Hardware/SystemMetrics.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/ChatPromptFormatting.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/ChatRequest.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/InferenceCancellation.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/InferenceEngine.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/OpenAIFormatter.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/SSEChunkFormatting.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/SingleRequestInference.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/UsageAccounting.swift create mode 100644 provider-swift/Sources/ProviderCore/InferenceFoundation/LocalMLXModelFoundation.swift create mode 100644 provider-swift/Sources/ProviderCore/Models/ModelScanner.swift create mode 100644 provider-swift/Sources/ProviderCore/Models/WeightHasher.swift create mode 100644 provider-swift/Sources/ProviderCore/Protocol/Enums.swift create mode 100644 provider-swift/Sources/ProviderCore/Protocol/Messages.swift create mode 100644 provider-swift/Sources/ProviderCore/Protocol/ProtocolCodec.swift create mode 100644 provider-swift/Sources/ProviderCore/Protocol/Types.swift create mode 100644 provider-swift/Sources/ProviderCore/ProviderCore.swift create mode 100644 provider-swift/Sources/ProviderCore/ProviderLoop.swift create mode 100644 provider-swift/Sources/ProviderCore/Scheduling/Schedule.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/AntiDebug.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/BinaryHasher.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/EnvironmentScrubber.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/SecureEnclaveIdentity.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/SecurityFoundation.swift create mode 100644 provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift create mode 100644 provider-swift/Sources/ProviderCore/Server/ServerRoutes.swift create mode 100644 provider-swift/Sources/ProviderCore/Server/StandaloneServer.swift create mode 100644 provider-swift/Sources/ProviderCore/Service/LaunchAgent.swift create mode 100644 provider-swift/Sources/ProviderCore/Telemetry/TelemetryClient.swift create mode 100644 provider-swift/Sources/ProviderCore/Telemetry/TelemetryEvent.swift create mode 100644 provider-swift/Sources/ProviderCore/Telemetry/TelemetryOverflowQueue.swift create mode 100644 provider-swift/Sources/ProviderCore/Update/SelfUpdater.swift create mode 100644 provider-swift/Sources/darkbloom/BenchmarkCommand.swift create mode 100644 provider-swift/Sources/darkbloom/Darkbloom.swift create mode 100644 provider-swift/Sources/darkbloom/DoctorCommand.swift create mode 100644 provider-swift/Sources/darkbloom/LoginCommand.swift create mode 100644 provider-swift/Sources/darkbloom/LogoutCommand.swift create mode 100644 provider-swift/Sources/darkbloom/ModelsCommand.swift create mode 100644 provider-swift/Sources/darkbloom/StartCommand.swift create mode 100644 provider-swift/Sources/darkbloom/StatusCommand.swift create mode 100644 provider-swift/Sources/darkbloom/StopCommand.swift create mode 100644 provider-swift/Sources/darkbloom/UpdateCommand.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/BatchingTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/CoordinatorClientTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/CryptoTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/FoundationTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/InferenceTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/IntegrationPlanTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ProtocolTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ProviderCoreTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/SecurityTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/StandaloneServerTests.swift diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..fb84ecd1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "libs/mlx-swift"] + path = libs/mlx-swift + url = https://github.com/Gajesh2007/mlx-swift.git +[submodule "libs/mlx-swift-lm"] + path = libs/mlx-swift-lm + url = https://github.com/Gajesh2007/mlx-swift-lm.git diff --git a/coordinator/internal/api/provider.go b/coordinator/internal/api/provider.go index 4c0765cb..1e91a0c9 100644 --- a/coordinator/internal/api/provider.go +++ b/coordinator/internal/api/provider.go @@ -209,8 +209,16 @@ func (s *Server) providerReadLoop(ctx context.Context, conn *websocket.Conn, pro provider.Mu().Unlock() } - // Verify runtime integrity against the known-good manifest. - if s.knownRuntimeManifest != nil { + // Verify runtime integrity against the known-good manifest. The Swift + // provider has no Python/vllm runtime; it is covered by signed binary + // hash attestation during the challenge path instead. + if registry.BackendUsesSwiftRuntime(regMsg.Backend) { + provider.Mu().Lock() + provider.RuntimeVerified = true + provider.RuntimeManifestChecked = true + provider.TemplateHashes = registry.CloneStringMap(regMsg.TemplateHashes) + provider.Mu().Unlock() + } else if s.knownRuntimeManifest != nil { runtimeOK, mismatches := s.verifyRuntimeHashes( regMsg.PythonHash, regMsg.RuntimeHash, regMsg.TemplateHashes) provider.Mu().Lock() @@ -659,35 +667,58 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P return } - // Verify fresh RDMA status. RDMA over Thunderbolt 5 allows another Mac - // to directly read inference process memory, bypassing all software - // protections (PT_DENY_ATTACH, Hardened Runtime, SIP). This check is - // required — providers must report RDMA status (v0.2.0+). + // Verify fresh RDMA status. Reporting remains mandatory so routing and + // trust policy can distinguish single-node providers from RDMA-aware + // cluster runtimes. RDMA enablement is not itself a challenge failure: + // Apple Silicon Thunderbolt RDMA is IOMMU-scoped to registered buffers, + // so the security boundary is the signed runtime's buffer-registration + // discipline, not a hypervisor flag. if resp.RDMADisabled == nil { s.handleChallengeFailure(providerID, "RDMA status not reported — provider must update to v0.2.0+") return } if !*resp.RDMADisabled { - // RDMA is enabled — only acceptable if hypervisor memory isolation - // is active. The hypervisor's Stage 2 page tables make inference - // memory invisible to RDMA DMA transfers. - hvActive := resp.HypervisorActive != nil && *resp.HypervisorActive - if !hvActive { - s.logger.Error("provider RDMA enabled without hypervisor — remote memory access possible, marking untrusted", + s.logger.Info("provider RDMA enabled — accepting under registered-buffer RDMA policy", + "provider_id", providerID, + "backend", provider.Backend, + "hypervisor_active", resp.HypervisorActive, + ) + } + + // Verify fresh binary hash when a known-good policy is configured. A + // reported binary hash only counts when the response is signed by the + // provider key from a valid registration attestation. + policyConfigured, knownBinaryHashes := s.binaryHashPolicySnapshot() + if policyConfigured { + attestationResult := provider.AttestationResult + if attestationResult == nil || !attestationResult.Valid || attestationResult.PublicKey == "" { + s.logger.Error("provider cannot prove binary hash without valid attestation", "provider_id", providerID, ) s.registry.MarkUntrusted(providerID) - s.handleChallengeFailure(providerID, "RDMA enabled without hypervisor memory isolation") + s.handleChallengeFailure(providerID, "valid attestation required for binary hash policy") return } - s.logger.Info("provider RDMA enabled with hypervisor isolation — acceptable", - "provider_id", providerID, - ) - } - - // Verify fresh binary hash if reported and known hashes are configured. - if resp.BinaryHash != "" && len(s.knownBinaryHashes) > 0 { - if !s.knownBinaryHashes[resp.BinaryHash] { + if resp.BinaryHash == "" { + s.logger.Error("provider omitted binary hash while known-good policy is configured", + "provider_id", providerID, + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "binary hash missing") + return + } + attestedBinaryHash, err := normalizeSHA256Hex(attestationResult.BinaryHash, "attested binary_hash") + if err != nil { + s.logger.Error("provider attestation has no usable binary hash", + "provider_id", providerID, + "binary_hash", attestationResult.BinaryHash, + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "attested binary hash missing") + return + } + binaryHash, err := normalizeSHA256Hex(resp.BinaryHash, "binary_hash") + if err != nil || !knownBinaryHashes[binaryHash] { s.logger.Error("provider binary hash changed — no longer matches known-good list", "provider_id", providerID, "binary_hash", resp.BinaryHash, @@ -696,6 +727,16 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P s.handleChallengeFailure(providerID, "binary hash mismatch") return } + if binaryHash != attestedBinaryHash { + s.logger.Error("provider binary hash changed from registration attestation", + "provider_id", providerID, + "attested_binary_hash", registry.TruncHash(attestedBinaryHash), + "challenge_binary_hash", registry.TruncHash(binaryHash), + ) + s.registry.MarkUntrusted(providerID) + s.handleChallengeFailure(providerID, "binary hash changed from registration attestation") + return + } } // Verify active model hash if reported and catalog has expected hash. @@ -721,8 +762,18 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P } } - // Verify runtime integrity hashes from challenge response. - if s.knownRuntimeManifest != nil { + // Verify runtime integrity hashes from challenge response. Swift providers + // omit Python/vllm runtime hashes and rely on the signed binary hash check + // above plus the signed status payload. + if registry.BackendUsesSwiftRuntime(provider.Backend) { + provider.Mu().Lock() + provider.RuntimeVerified = true + provider.RuntimeManifestChecked = true + if len(resp.TemplateHashes) > 0 { + provider.TemplateHashes = registry.CloneStringMap(resp.TemplateHashes) + } + provider.Mu().Unlock() + } else if s.knownRuntimeManifest != nil { runtimeOK, mismatches := s.verifyRuntimeHashes( resp.PythonHash, resp.RuntimeHash, resp.TemplateHashes) provider.Mu().Lock() @@ -782,8 +833,8 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P // Override self-reported privacy capabilities with coordinator-verified // values from the challenge response. The coordinator independently checks - // SIP and hypervisor status during each attestation challenge — these - // override the provider's self-report at registration time. + // SIP during each attestation challenge. Hypervisor status is preserved as + // a reported capability only; it is not the RDMA safety proof. provider.Mu().Lock() if provider.PrivacyCapabilities != nil { if resp.SIPEnabled != nil { @@ -1129,9 +1180,21 @@ func (s *Server) handleInferenceError(providerID string, provider *registry.Prov // verifyProviderAttestation verifies a provider's Secure Enclave attestation // if one was included in the registration message. If the attestation is valid, // the provider is marked as attested. If missing or invalid, the provider is -// still accepted (Open Mode) but marked as not attested. +// accepted in Open Mode only when no binary hash policy is configured. func (s *Server) verifyProviderAttestation(providerID string, provider *registry.Provider, regMsg *protocol.RegisterMessage) { + policyConfigured, knownBinaryHashes := s.binaryHashPolicySnapshot() if len(regMsg.Attestation) == 0 { + if policyConfigured { + s.logger.Warn("provider registered without attestation while binary hash policy is configured", + "provider_id", providerID, + ) + provider.SetAttestationResult(&attestation.VerificationResult{ + Valid: false, + Error: "attestation missing", + }) + s.registry.MarkUntrusted(providerID) + return + } s.logger.Info("provider registered without attestation (Open Mode)", "provider_id", providerID, ) @@ -1144,6 +1207,13 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry "provider_id", providerID, "error", err, ) + if policyConfigured { + provider.SetAttestationResult(&attestation.VerificationResult{ + Valid: false, + Error: "attestation invalid", + }) + s.registry.MarkUntrusted(providerID) + } return } @@ -1154,6 +1224,9 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry "provider_id", providerID, "error", result.Error, ) + if policyConfigured { + s.registry.MarkUntrusted(providerID) + } return } @@ -1168,6 +1241,9 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry result.Valid = false result.Error = "attestation missing encryption public key" provider.SetAttestationResult(&result) + if policyConfigured { + s.registry.MarkUntrusted(providerID) + } return } if result.EncryptionPublicKey != regMsg.PublicKey { @@ -1179,13 +1255,28 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry result.Valid = false result.Error = "encryption key mismatch" provider.SetAttestationResult(&result) + if policyConfigured { + s.registry.MarkUntrusted(providerID) + } return } } - // Verify binary hash against known-good hashes. - if len(s.knownBinaryHashes) > 0 && result.BinaryHash != "" { - if !s.knownBinaryHashes[result.BinaryHash] { + // Verify binary hash against known-good hashes. Once a binary hash policy is + // configured, omission is a policy violation, not an Open Mode downgrade. + if policyConfigured { + if result.BinaryHash == "" { + s.logger.Warn("provider binary hash missing while known-good policy is configured", + "provider_id", providerID, + ) + result.Valid = false + result.Error = "binary hash missing" + provider.SetAttestationResult(&result) + s.registry.MarkUntrusted(providerID) + return + } + binaryHash, err := normalizeSHA256Hex(result.BinaryHash, "binary_hash") + if err != nil || !knownBinaryHashes[binaryHash] { s.logger.Warn("provider binary hash not in known-good list", "provider_id", providerID, "binary_hash", result.BinaryHash, @@ -1193,6 +1284,7 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry result.Valid = false result.Error = "binary hash not recognized" provider.SetAttestationResult(&result) + s.registry.MarkUntrusted(providerID) return } s.logger.Info("provider binary hash verified", diff --git a/coordinator/internal/api/provider_test.go b/coordinator/internal/api/provider_test.go index 4db3885f..9425f90c 100644 --- a/coordinator/internal/api/provider_test.go +++ b/coordinator/internal/api/provider_test.go @@ -28,6 +28,8 @@ import ( "nhooyr.io/websocket" ) +const knownGoodBinaryHashForTest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + func TestProviderWebSocketConnect(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) st := store.NewMemory("test-key") @@ -288,6 +290,10 @@ func rawP256PublicKeyB64ForTest(t *testing.T, pubKey *ecdsa.PublicKey) string { } func createTestAttestationJSON(t *testing.T, encryptionKey string) json.RawMessage { + return createTestAttestationJSONWithBinaryHash(t, encryptionKey, "") +} + +func createTestAttestationJSONWithBinaryHash(t *testing.T, encryptionKey, binaryHash string) json.RawMessage { t.Helper() privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -322,6 +328,9 @@ func createTestAttestationJSON(t *testing.T, encryptionKey string) json.RawMessa blobMap["encryptionPublicKey"] = encryptionKey registerTestChallengeSigner(encryptionKey, privKey) } + if binaryHash != "" { + blobMap["binaryHash"] = binaryHash + } blobJSON, err := json.Marshal(blobMap) if err != nil { @@ -504,6 +513,82 @@ func TestProviderRegistrationWithoutAttestation(t *testing.T) { } } +func TestProviderRegistrationRequiresBinaryHashWhenPolicyConfigured(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "missing-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSON(t, pubKey), + } + p := reg.Register("provider-1", nil, regMsg) + + srv.verifyProviderAttestation("provider-1", p, regMsg) + + if p.AttestationResult == nil { + t.Fatal("expected attestation result") + } + if p.AttestationResult.Valid { + t.Fatal("attestation should be invalid when binary hash policy is configured and hash is missing") + } + if p.AttestationResult.Error != "binary hash missing" { + t.Fatalf("attestation error = %q, want %q", p.AttestationResult.Error, "binary hash missing") + } + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } +} + +func TestProviderRegistrationAcceptsKnownBinaryHash(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "known-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), + } + p := reg.Register("provider-1", nil, regMsg) + + srv.verifyProviderAttestation("provider-1", p, regMsg) + + if p.AttestationResult == nil { + t.Fatal("expected attestation result") + } + if !p.AttestationResult.Valid { + t.Fatalf("attestation should be valid with a known binary hash, got %q", p.AttestationResult.Error) + } + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status == registry.StatusUntrusted { + t.Fatal("provider should not be marked untrusted with a known binary hash") + } + if p.TrustLevel != registry.TrustSelfSigned { + t.Fatalf("provider trust = %q, want %q", p.TrustLevel, registry.TrustSelfSigned) + } +} + // TestListModelsWithAttestationInfo verifies that /v1/models includes // attestation metadata. func TestListModelsWithAttestationInfo(t *testing.T) { @@ -774,6 +859,247 @@ func TestChallengeResponseSuccess(t *testing.T) { } } +func TestChallengeResponseAllowsRDMAEnabledWithoutHypervisor(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.challengeInterval = 200 * time.Millisecond + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/provider" + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket dial: %v", err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + pubKey := testPublicKeyB64() + regMsg := protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "rdma-enabled-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + } + regData, _ := json.Marshal(regMsg) + conn.Write(ctx, websocket.MessageText, regData) + time.Sleep(100 * time.Millisecond) + + challengeReceived := false + for range 20 { + readCtx, readCancel := context.WithTimeout(ctx, 500*time.Millisecond) + _, data, err := conn.Read(readCtx) + readCancel() + if err != nil { + continue + } + + var envelope struct { + Type string `json:"type"` + } + json.Unmarshal(data, &envelope) + if envelope.Type != protocol.TypeAttestationChallenge { + continue + } + challengeReceived = true + + var challenge protocol.AttestationChallengeMessage + json.Unmarshal(data, &challenge) + rdmaDisabled := false + hypervisorActive := false + sipEnabled := true + secureBootEnabled := true + response := protocol.AttestationResponseMessage{ + Type: protocol.TypeAttestationResponse, + Nonce: challenge.Nonce, + Signature: testChallengeSignature(challenge.Nonce, challenge.Timestamp, pubKey), + PublicKey: pubKey, + RDMADisabled: &rdmaDisabled, + HypervisorActive: &hypervisorActive, + SIPEnabled: &sipEnabled, + SecureBootEnabled: &secureBootEnabled, + } + respData, _ := json.Marshal(response) + conn.Write(ctx, websocket.MessageText, respData) + break + } + + if !challengeReceived { + t.Fatal("did not receive attestation challenge") + } + + time.Sleep(200 * time.Millisecond) + + p := findProviderByModel(reg, "rdma-enabled-model") + if p == nil { + t.Fatal("provider not found") + } + if p.Status == registry.StatusUntrusted { + t.Error("provider should not be marked untrusted when RDMA is enabled") + } + if p.GetLastChallengeVerified().IsZero() { + t.Fatal("provider should record challenge success when RDMA is enabled") + } +} + +func TestChallengeResponseRequiresBinaryHashWhenPolicyConfigured(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "missing-challenge-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), + } + p := reg.Register("provider-1", nil, regMsg) + srv.verifyProviderAttestation("provider-1", p, regMsg) + sipEnabled := true + secureBootEnabled := true + rdmaDisabled := true + challengeTimestamp := "2026-04-24T12:00:00Z" + + srv.verifyChallengeResponse("provider-1", p, &pendingChallenge{ + nonce: "nonce-1", + timestamp: challengeTimestamp, + }, &protocol.AttestationResponseMessage{ + Type: protocol.TypeAttestationResponse, + Nonce: "nonce-1", + Signature: testChallengeSignature("nonce-1", challengeTimestamp, pubKey), + PublicKey: pubKey, + SIPEnabled: &sipEnabled, + SecureBootEnabled: &secureBootEnabled, + RDMADisabled: &rdmaDisabled, + }) + + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } + if p.FailedChallenges != 1 { + t.Fatalf("failed challenges = %d, want 1", p.FailedChallenges) + } +} + +func TestChallengeResponseRejectsHashChangedFromRegistrationAttestation(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + otherKnownHash := strings.Repeat("f", 64) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest, otherKnownHash}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "changed-challenge-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), + } + p := reg.Register("provider-1", nil, regMsg) + srv.verifyProviderAttestation("provider-1", p, regMsg) + sipEnabled := true + secureBootEnabled := true + rdmaDisabled := true + challengeTimestamp := "2026-04-24T12:00:00Z" + + srv.verifyChallengeResponse("provider-1", p, &pendingChallenge{ + nonce: "nonce-1", + timestamp: challengeTimestamp, + }, &protocol.AttestationResponseMessage{ + Type: protocol.TypeAttestationResponse, + Nonce: "nonce-1", + Signature: testChallengeSignature("nonce-1", challengeTimestamp, pubKey), + PublicKey: pubKey, + SIPEnabled: &sipEnabled, + SecureBootEnabled: &secureBootEnabled, + RDMADisabled: &rdmaDisabled, + BinaryHash: otherKnownHash, + }) + + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } + if p.FailedChallenges != 1 { + t.Fatalf("failed challenges = %d, want 1", p.FailedChallenges) + } +} + +func TestChallengeResponseAcceptsKnownBinaryHash(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "known-challenge-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: registry.BackendMLXSwift, + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), + } + p := reg.Register("provider-1", nil, regMsg) + srv.verifyProviderAttestation("provider-1", p, regMsg) + sipEnabled := true + secureBootEnabled := true + rdmaDisabled := true + challengeTimestamp := "2026-04-24T12:00:00Z" + + srv.verifyChallengeResponse("provider-1", p, &pendingChallenge{ + nonce: "nonce-1", + timestamp: challengeTimestamp, + }, &protocol.AttestationResponseMessage{ + Type: protocol.TypeAttestationResponse, + Nonce: "nonce-1", + Signature: testChallengeSignature("nonce-1", challengeTimestamp, pubKey), + PublicKey: pubKey, + SIPEnabled: &sipEnabled, + SecureBootEnabled: &secureBootEnabled, + RDMADisabled: &rdmaDisabled, + BinaryHash: knownGoodBinaryHashForTest, + }) + + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status == registry.StatusUntrusted { + t.Fatal("provider should not be marked untrusted with a known binary hash") + } + if p.FailedChallenges != 0 { + t.Fatalf("failed challenges = %d, want 0", p.FailedChallenges) + } + if p.LastChallengeVerified.IsZero() { + t.Fatal("provider should record challenge success with a known binary hash") + } +} + func TestChallengeResponseRejectsMissingSIPStatus(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) st := store.NewMemory("test-key") diff --git a/coordinator/internal/api/server.go b/coordinator/internal/api/server.go index 303d71ce..822d4d90 100644 --- a/coordinator/internal/api/server.go +++ b/coordinator/internal/api/server.go @@ -17,9 +17,11 @@ import ( "bufio" "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "crypto/x509" _ "embed" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -113,9 +115,16 @@ type Server struct { stepCAIntermediateCert *x509.Certificate // step-ca intermediate CA // knownBinaryHashes is the set of accepted provider binary SHA-256 hashes. - // When non-empty, providers whose binary hash doesn't match are rejected. + // When binaryHashPolicyConfigured is true, providers whose binary hash is + // missing or doesn't match are rejected. // Auto-populated from active releases via SyncBinaryHashes(). - knownBinaryHashes map[string]bool + binaryHashPolicyMu sync.RWMutex + knownBinaryHashes map[string]bool + manualKnownBinaryHashes map[string]bool + releaseKnownBinaryHashes map[string]bool + manualBinaryHashPolicyConfigured bool + releaseBinaryHashPolicyConfigured bool + binaryHashPolicyConfigured bool // knownRuntimeManifest holds accepted runtime component hashes. // When set, providers whose runtime hashes don't match are marked as @@ -412,24 +421,68 @@ func (s *Server) SyncModelCatalog() { // SetKnownBinaryHashes configures the set of accepted provider binary hashes. // Providers whose binary SHA-256 doesn't match any known hash are rejected. func (s *Server) SetKnownBinaryHashes(hashes []string) { - s.knownBinaryHashes = make(map[string]bool, len(hashes)) + normalized := normalizeKnownBinaryHashes(hashes, s.logger) + + s.binaryHashPolicyMu.Lock() + defer s.binaryHashPolicyMu.Unlock() + + s.manualKnownBinaryHashes = normalized + s.manualBinaryHashPolicyConfigured = hasConfiguredHashInput(hashes) + s.rebuildBinaryHashPolicyLocked() +} + +func normalizeKnownBinaryHashes(hashes []string, logger *slog.Logger) map[string]bool { + normalizedHashes := make(map[string]bool, len(hashes)) for _, h := range hashes { - if h != "" { - s.knownBinaryHashes[h] = true + normalized, err := normalizeSHA256Hex(h, "known_binary_hashes") + if err != nil { + if strings.TrimSpace(h) != "" { + logger.Warn("invalid known binary hash ignored", "hash", h, "error", err) + } + continue } + normalizedHashes[normalized] = true } + return normalizedHashes } // AddKnownBinaryHashes adds hashes to the existing known set (for env var fallback). func (s *Server) AddKnownBinaryHashes(hashes []string) { - if s.knownBinaryHashes == nil { - s.knownBinaryHashes = make(map[string]bool) + normalized := normalizeKnownBinaryHashes(hashes, s.logger) + + s.binaryHashPolicyMu.Lock() + defer s.binaryHashPolicyMu.Unlock() + + if s.manualKnownBinaryHashes == nil { + s.manualKnownBinaryHashes = make(map[string]bool) } + if hasConfiguredHashInput(hashes) { + s.manualBinaryHashPolicyConfigured = true + } + for h := range normalized { + s.manualKnownBinaryHashes[h] = true + } + s.rebuildBinaryHashPolicyLocked() +} + +func hasConfiguredHashInput(hashes []string) bool { for _, h := range hashes { - if h != "" { - s.knownBinaryHashes[h] = true + if strings.TrimSpace(h) != "" { + return true } } + return false +} + +func normalizeSHA256Hex(value, field string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if len(value) != sha256.Size*2 { + return "", fmt.Errorf("%s must be a 64-character SHA-256 hex digest", field) + } + if _, err := hex.DecodeString(value); err != nil { + return "", fmt.Errorf("%s must be a valid SHA-256 hex digest", field) + } + return value, nil } // SetConsoleURL sets the frontend URL for device auth verification links. @@ -464,13 +517,52 @@ func (s *Server) CoordinatorKey() *e2e.CoordinatorKey { func (s *Server) SyncBinaryHashes() { releases := s.store.ListReleases() hashes := make(map[string]bool) + policyConfigured := false for _, r := range releases { - if r.Active && r.BinaryHash != "" { - hashes[r.BinaryHash] = true + if !r.Active { + continue + } + policyConfigured = true + normalized, err := normalizeSHA256Hex(r.BinaryHash, "release.binary_hash") + if err != nil { + s.logger.Warn("invalid release binary hash ignored", + "version", r.Version, + "platform", r.Platform, + "error", err, + ) + continue } + hashes[normalized] = true + } + + s.binaryHashPolicyMu.Lock() + s.releaseKnownBinaryHashes = hashes + s.releaseBinaryHashPolicyConfigured = policyConfigured + s.rebuildBinaryHashPolicyLocked() + knownHashCount := len(s.knownBinaryHashes) + effectivePolicyConfigured := s.binaryHashPolicyConfigured + s.binaryHashPolicyMu.Unlock() + + s.logger.Info("binary hashes synced from releases", "known_hashes", knownHashCount, "policy_configured", effectivePolicyConfigured) +} + +func (s *Server) rebuildBinaryHashPolicyLocked() { + hashes := make(map[string]bool, len(s.manualKnownBinaryHashes)+len(s.releaseKnownBinaryHashes)) + for h := range s.releaseKnownBinaryHashes { + hashes[h] = true + } + for h := range s.manualKnownBinaryHashes { + hashes[h] = true } s.knownBinaryHashes = hashes - s.logger.Info("binary hashes synced from releases", "known_hashes", len(hashes)) + s.binaryHashPolicyConfigured = s.manualBinaryHashPolicyConfigured || s.releaseBinaryHashPolicyConfigured +} + +func (s *Server) binaryHashPolicySnapshot() (bool, map[string]bool) { + s.binaryHashPolicyMu.RLock() + defer s.binaryHashPolicyMu.RUnlock() + + return s.binaryHashPolicyConfigured, s.knownBinaryHashes } // SyncRuntimeManifest builds the runtime manifest from active releases. @@ -547,7 +639,11 @@ func (s *Server) revalidateConnectedProvidersAgainstRuntimePolicy() { runtimeHash := provider.RuntimeHash templateHashes := registry.CloneStringMap(provider.TemplateHashes) version := provider.Version + backend := provider.Backend switch { + case registry.BackendUsesSwiftRuntime(backend): + provider.RuntimeVerified = true + provider.RuntimeManifestChecked = true case s.knownRuntimeManifest == nil: provider.RuntimeVerified = false provider.RuntimeManifestChecked = false diff --git a/coordinator/internal/e2e/testdata/gen-vectors/main.go b/coordinator/internal/e2e/testdata/gen-vectors/main.go new file mode 100644 index 00000000..53d388d9 --- /dev/null +++ b/coordinator/internal/e2e/testdata/gen-vectors/main.go @@ -0,0 +1,91 @@ +// Generates deterministic NaCl box test vectors for cross-language validation. +// +// Usage: cd coordinator && go run ./internal/e2e/testdata/gen-vectors +package main + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/nacl/box" +) + +func main() { + // Fixed provider key pair (recipient) + providerPrivBytes := [32]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + } + + // Fixed ephemeral key pair (sender / coordinator) + ephPrivBytes := [32]byte{ + 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, + 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, + 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, + 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, + } + + providerPub := derivePub(providerPrivBytes[:]) + ephPub := derivePub(ephPrivBytes[:]) + + var providerPubArr, ephPubArr [32]byte + copy(providerPubArr[:], providerPub) + copy(ephPubArr[:], ephPub) + + // Fixed nonce + nonce := [24]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + } + + fmt.Println("// Golden NaCl box test vectors") + fmt.Println("// Provider private key:", hex.EncodeToString(providerPrivBytes[:])) + fmt.Println("// Provider public key: ", hex.EncodeToString(providerPub)) + fmt.Println("// Ephemeral private key:", hex.EncodeToString(ephPrivBytes[:])) + fmt.Println("// Ephemeral public key: ", hex.EncodeToString(ephPub)) + fmt.Println("// Nonce: ", hex.EncodeToString(nonce[:])) + fmt.Println() + + testCases := []struct { + name string + plaintext string + }{ + {"hello", "hello from Go"}, + {"json", `{"model":"test","messages":[{"role":"user","content":"hi"}]}`}, + {"empty", ""}, + {"unicode", "こんにちは世界 🌍"}, + } + + for _, tc := range testCases { + plainBytes := []byte(tc.plaintext) + encrypted := box.Seal(nonce[:], plainBytes, &nonce, &providerPubArr, &ephPrivBytes) + + fmt.Printf("Vector: %s\n", tc.name) + fmt.Printf(" ephemeral_pub_b64: %s\n", base64.StdEncoding.EncodeToString(ephPub)) + fmt.Printf(" ciphertext_b64: %s\n", base64.StdEncoding.EncodeToString(encrypted)) + fmt.Printf(" provider_priv_b64: %s\n", base64.StdEncoding.EncodeToString(providerPrivBytes[:])) + fmt.Printf(" plaintext: %q\n", tc.plaintext) + fmt.Println() + } + + // Reverse: provider encrypts → coordinator decrypts + responseEncrypted := box.Seal(nonce[:], []byte("response from provider"), &nonce, &ephPubArr, &providerPrivBytes) + fmt.Println("Vector: reverse") + fmt.Printf(" sender_pub_b64: %s\n", base64.StdEncoding.EncodeToString(providerPub)) + fmt.Printf(" ciphertext_b64: %s\n", base64.StdEncoding.EncodeToString(responseEncrypted)) + fmt.Printf(" recipient_priv_b64: %s\n", base64.StdEncoding.EncodeToString(ephPrivBytes[:])) + fmt.Printf(" plaintext: %q\n", "response from provider") +} + +func derivePub(privBytes []byte) []byte { + key, err := ecdh.X25519().NewPrivateKey(privBytes) + if err != nil { + panic(err) + } + return key.PublicKey().Bytes() +} diff --git a/coordinator/internal/protocol/messages.go b/coordinator/internal/protocol/messages.go index 4237adf0..6edad4eb 100644 --- a/coordinator/internal/protocol/messages.go +++ b/coordinator/internal/protocol/messages.go @@ -276,8 +276,8 @@ type AttestationResponseMessage struct { Signature string `json:"signature"` // base64-encoded signature of nonce+timestamp StatusSignature string `json:"status_signature,omitempty"` // base64-encoded signature of canonical status JSON (see attestation.BuildStatusCanonical) PublicKey string `json:"public_key"` // base64-encoded public key - HypervisorActive *bool `json:"hypervisor_active,omitempty"` // hypervisor memory isolation active (Stage 2 page tables) - RDMADisabled *bool `json:"rdma_disabled,omitempty"` // fresh RDMA status (true = safe, false = remote memory access possible) + HypervisorActive *bool `json:"hypervisor_active,omitempty"` // reported hypervisor containment status, if any + RDMADisabled *bool `json:"rdma_disabled,omitempty"` // fresh RDMA status (true = disabled, false = enabled) SIPEnabled *bool `json:"sip_enabled,omitempty"` // fresh SIP status at challenge time SecureBootEnabled *bool `json:"secure_boot_enabled,omitempty"` // fresh Secure Boot status BinaryHash string `json:"binary_hash,omitempty"` // fresh SHA-256 of provider binary diff --git a/coordinator/internal/registry/registry.go b/coordinator/internal/registry/registry.go index 499de8f8..5cd4c013 100644 --- a/coordinator/internal/registry/registry.go +++ b/coordinator/internal/registry/registry.go @@ -53,6 +53,15 @@ const ( TrustHardware TrustLevel = "hardware" // MDM + MDA + SE key bound to Apple-verified hardware ) +const ( + BackendInprocessMLX = "inprocess-mlx" + BackendMLXSwift = "mlx-swift" +) + +func BackendUsesSwiftRuntime(backend string) bool { + return backend == BackendMLXSwift +} + // PendingRequest is a channel-based handle for an in-flight inference request. type PendingRequest struct { RequestID string @@ -139,9 +148,9 @@ type Provider struct { TemplateHashes map[string]string // Phase 7: Privacy invariant attestation. - // Self-reported by the provider at registration. Fields like SIPEnabled - // and HypervisorActive are overridden by the coordinator after each - // attestation challenge response with coordinator-verified values. + // Self-reported by the provider at registration. SIPEnabled is overridden + // by the coordinator after each attestation challenge response with a + // coordinator-verified value. HypervisorActive is informational. PrivacyCapabilities *protocol.PrivacyCapabilities `json:"privacy_capabilities,omitempty"` // Coordinator-verified SIP status from the most recent attestation challenge. @@ -158,10 +167,10 @@ type Provider struct { } func providerSupportsPrivateTextLocked(p *Provider) bool { - if p.PublicKey == "" || p.Backend != "inprocess-mlx" || !p.EncryptedResponseChunks { + if p.PublicKey == "" || !privateTextBackendSupported(p.Backend) || !p.EncryptedResponseChunks { return false } - if !p.RuntimeManifestChecked { + if !BackendUsesSwiftRuntime(p.Backend) && !p.RuntimeManifestChecked { return false } // Require coordinator-verified SIP (from attestation challenge) rather @@ -187,6 +196,10 @@ func providerSupportsPrivateTextLocked(p *Provider) bool { caps.EnvScrubbed } +func privateTextBackendSupported(backend string) bool { + return backend == BackendInprocessMLX || backend == BackendMLXSwift +} + // AddPending registers a pending request on this provider. func (p *Provider) AddPending(pr *PendingRequest) { p.mu.Lock() diff --git a/coordinator/internal/registry/registry_test.go b/coordinator/internal/registry/registry_test.go index f25aea1e..bc89ee9b 100644 --- a/coordinator/internal/registry/registry_test.go +++ b/coordinator/internal/registry/registry_test.go @@ -133,6 +133,23 @@ func TestProviderWithoutManifestCheckExcludedFromTextRouting(t *testing.T) { } } +func TestSwiftProviderDoesNotRequirePythonRuntimeManifest(t *testing.T) { + reg := New(testLogger()) + msg := testRegisterMessage() + msg.Backend = BackendMLXSwift + p := reg.Register("p-swift", nil, msg) + p.TrustLevel = TrustHardware + p.LastChallengeVerified = time.Now() + p.ChallengeVerifiedSIP = true + p.RuntimeVerified = true + p.RuntimeManifestChecked = false + + found := reg.FindProvider("mlx-community/Qwen3.5-9B-Instruct-4bit") + if found == nil { + t.Fatal("swift provider should be routable without Python/vllm runtime manifest") + } +} + func TestProviderWithoutChallengeVerifiedSIPExcluded(t *testing.T) { reg := New(testLogger()) msg := testRegisterMessage() diff --git a/libs/mlx-swift b/libs/mlx-swift new file mode 160000 index 00000000..49d73abc --- /dev/null +++ b/libs/mlx-swift @@ -0,0 +1 @@ +Subproject commit 49d73abcbe8f49f44c1e61911997e6b680484216 diff --git a/libs/mlx-swift-lm b/libs/mlx-swift-lm new file mode 160000 index 00000000..3ec4b8a2 --- /dev/null +++ b/libs/mlx-swift-lm @@ -0,0 +1 @@ +Subproject commit 3ec4b8a238dff788e93007aea416fb9f4191b0fe diff --git a/provider-swift/Package.resolved b/provider-swift/Package.resolved new file mode 100644 index 00000000..c9d3279d --- /dev/null +++ b/provider-swift/Package.resolved @@ -0,0 +1,276 @@ +{ + "originHash" : "3906467b4323a865d27fbc7b5eb877ca0a7e1b924f5858ce90b85c971199d039", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "a2ed0a0294de56e18ba55344eafc801a7a385a90", + "version" : "2.22.0" + } + }, + { + "identity" : "jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnmai-dev/Jinja", + "state" : { + "revision" : "5c0a87846dfd36ca6621795ad2f09fdaab82b739", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "e7e799cd1eaa4d0f6d3eab56832e7f4b377f4a4f", + "version" : "0.10.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "9de99a78f099e59caf2b2beec65a4c45d54b2081", + "version" : "603.0.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "f000aa7aec0e78acd0211685e4094e1fca84cd8b", + "version" : "0.1.24" + } + }, + { + "identity" : "tomlkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LebJe/TOMLKit.git", + "state" : { + "revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f", + "version" : "0.6.0" + } + } + ], + "version" : 3 +} diff --git a/provider-swift/Package.swift b/provider-swift/Package.swift new file mode 100644 index 00000000..710f3d34 --- /dev/null +++ b/provider-swift/Package.swift @@ -0,0 +1,53 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "DarkbloomProvider", + platforms: [.macOS(.v15)], + products: [ + .library(name: "ProviderCore", targets: ["ProviderCore"]), + .executable(name: "darkbloom", targets: ["darkbloom"]), + ], + dependencies: [ + .package(path: "../libs/mlx-swift"), + .package(path: "../libs/mlx-swift-lm"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), + .package(url: "https://github.com/huggingface/swift-transformers", from: "0.1.12"), + .package(url: "https://github.com/jedisct1/swift-sodium.git", from: "0.9.1"), + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", exact: "2.22.0"), + ], + targets: [ + .target( + name: "ProviderCore", + dependencies: [ + .product(name: "MLX", package: "mlx-swift"), + .product(name: "MLXNN", package: "mlx-swift"), + .product(name: "MLXLLM", package: "mlx-swift-lm"), + .product(name: "MLXLMCommon", package: "mlx-swift-lm"), + .product(name: "Transformers", package: "swift-transformers"), + .product(name: "Sodium", package: "swift-sodium"), + .product(name: "TOMLKit", package: "TOMLKit"), + .product(name: "Hummingbird", package: "hummingbird"), + ], + path: "Sources/ProviderCore" + ), + .executableTarget( + name: "darkbloom", + dependencies: [ + "ProviderCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Sources/darkbloom" + ), + .testTarget( + name: "ProviderCoreTests", + dependencies: [ + "ProviderCore", + .product(name: "HummingbirdTesting", package: "hummingbird"), + ], + path: "Tests/ProviderCoreTests" + ), + ] +) diff --git a/provider-swift/README.md b/provider-swift/README.md new file mode 100644 index 00000000..9771ca9f --- /dev/null +++ b/provider-swift/README.md @@ -0,0 +1,115 @@ +# Swift Provider Cutover Notes + +This package is the Swift-native provider candidate. `Package.swift` currently +defines the `ProviderCore` library and `darkbloom` executable, requires +Swift tools 6.1, targets macOS 15, and depends on local `../libs/mlx-swift` +and `../libs/mlx-swift-lm`. + +## Local Gates + +Run these before any release-script cutover: + +```bash +swift test --package-path provider-swift +swift build -c release --package-path provider-swift +provider-swift/.build/release/darkbloom --help +``` + +The CLI is not release-ready while `darkbloom serve` remains a stub. At the +time of this pass, `Sources/darkbloom/main.swift` prints `Backend: mlx-swift` +but exits failure for serving because coordinator event handling and inference +dispatch are not wired into the CLI yet. + +## Compatibility Decisions To Lock + +- Backend identifier: use `mlx-swift` for the Swift provider cutover. Current + repo state is split: the Swift CLI prints `mlx-swift`, existing Swift tests + still use `mlx_swift_lm`, and the coordinator private-text gate checks for + `inprocess-mlx` in `coordinator/internal/registry/registry.go`. +- Runtime hashes: Swift releases should omit `python_hash` and `runtime_hash`. + Keep `binary_hash`, `bundle_hash`, and any template/model hashes that are + still meaningful. +- Runtime verification: the coordinator must become backend-aware. Existing + `SyncRuntimeManifest` and `verifyRuntimeHashes` logic in + `coordinator/internal/api/server.go` applies Python/runtime hash requirements + globally, so a Swift provider with no Python hashes will fail whenever old + active releases still contribute Python/runtime hashes. If all old releases + are deactivated, the manifest becomes nil and providers lose private-text + eligibility because registration marks `RuntimeManifestChecked=false`. + +## Cutover Checklist + +1. Finish the Swift runtime path. + - `darkbloom serve` must connect to `/ws/provider`, register, handle + inference, cancellation, reconnects, and attestation challenges. + - Registration should send `backend: "mlx-swift"`, encrypted response + chunks, privacy capabilities, binary/model/template hashes as applicable, + and no `python_hash` or `runtime_hash`. + - Update Swift tests that still use `mlx_swift_lm` after the coordinator + accepts the canonical backend string. + +2. Update `scripts/build-bundle.sh`. + - Replace the Rust provider build with + `swift build -c release --package-path provider-swift`. + - Set the provider binary path to + `provider-swift/.build/release/darkbloom`. + - Remove portable Python setup, `vllm-mlx` installs, import checks, PyO3 + build environment, `install_name_tool` libpython rewrites, and Python + runtime hash generation. + - Bundle `bin/darkbloom`; keep `bin/eigeninference-enclave` only if Phase 5 + has not fully merged Secure Enclave support into the Swift provider. + - Keep signing with `scripts/entitlements.plist`, then compute + `binary_hash` and `bundle_hash` after signing/stapling. + - Update app bundle minimum OS from 14.0 to 15.0 if this script still builds + the app bundle. + +3. Update `.github/workflows/release.yml`. + - Remove Cargo/PyO3/PBS/uv/vllm-mlx setup and cache entries. + - Add SwiftPM cache coverage for `provider-swift/.build` and + `provider-swift/Package.resolved`. + - Build with `swift build -c release --package-path provider-swift`. + - Test with `swift test --package-path provider-swift`. + - Assemble the provider bundle from the Swift `darkbloom` binary without a + `python/` directory and without libpython rpath patching. + - Upload only the bundle and DMG unless another Swift-native artifact is + explicitly introduced. + - Register releases without `python_hash` and `runtime_hash`; keep + `binary_hash`, `bundle_hash`, `template_hashes`, `url`, `platform`, and + `changelog`. + - Update release notes so they no longer advertise a vllm-mlx runtime hash. + +4. Update `scripts/install.sh` and the embedded coordinator copy. + - Remove the Python runtime verification/download/install step. + - Remove `PYTHON_BIN`, PBS fallback, site-packages fallback, and vllm-mlx + import checks. + - Replace Python-based model catalog parsing with a Swift CLI helper or a + shell-only parser. Prefer moving selection/download logic into `darkbloom` + so a fresh macOS install still has no Python prerequisite. + - If Secure Enclave is merged into the main binary, replace direct + `eigeninference-enclave info` calls with the new `darkbloom` command. + - Reduce the displayed step count and summary to match the no-Python flow. + +5. Update coordinator release and runtime compatibility. + - In `coordinator/internal/api/release_handlers.go`, require + `bundle_hash` because `install.sh` verifies it. + - In `coordinator/internal/store/interface.go`, update `Release` comments + and consider adding a `backend` or `runtime_kind` field if Rust and Swift + releases will overlap. + - In `coordinator/internal/api/server.go`, make runtime verification + backend-aware: legacy providers should keep Python/runtime hash checks, + while `mlx-swift` providers should pass without those fields and rely on + signed binary hash, template hashes, model hashes, and attestation status. + - In `coordinator/internal/registry/registry.go`, update + `providerSupportsPrivateTextLocked` to accept the canonical Swift backend + and to stop depending on Python-specific capability wording once the + replacement runtime policy is in place. + - Update coordinator tests that assert `inprocess-mlx` or old runtime hash + semantics. + +6. Production validation. + - Publish a dev release first and install from the dev coordinator. + - Run a side-by-side Rust vs Swift provider comparison on the same model and + machine: registration, chat streaming, cancellation, reconnect, attested + binary hash, model weight hash, thermal behavior, and 48-hour soak. + - Keep Rust release artifacts active until Swift has passed soak and the + coordinator can route both backends intentionally. diff --git a/provider-swift/Sources/ProviderCore/Auth/DeviceAuth.swift b/provider-swift/Sources/ProviderCore/Auth/DeviceAuth.swift new file mode 100644 index 00000000..8f002331 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Auth/DeviceAuth.swift @@ -0,0 +1,244 @@ +/// DeviceAuth -- RFC 8628 device code flow for linking a provider to a Darkbloom account. +/// +/// The flow: +/// 1. Provider POSTs to `/v1/device/code` to get a device_code, user_code, and verification_uri. +/// 2. User opens the verification_uri in their browser and enters the user_code. +/// 3. Provider polls `/v1/device/token` until the user approves (or the code expires). +/// 4. On approval, the coordinator returns an auth token which is saved to `~/.darkbloom/auth_token`. +/// +/// Mirrors the Rust implementation in `provider/src/main.rs` (cmd_login / cmd_logout). + +import Foundation + +// MARK: - Token Storage + +public enum AuthTokenStore: Sendable { + + /// Path to the stored auth token: ~/.darkbloom/auth_token + public static func tokenPath() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".darkbloom") + .appendingPathComponent("auth_token") + } + + /// Load the saved auth token, if any. + public static func load() -> String? { + let path = tokenPath() + guard let content = try? String(contentsOf: path, encoding: .utf8) else { + return nil + } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Save an auth token to disk with restricted permissions (owner read/write only). + public static func save(_ token: String) throws { + let path = tokenPath() + let dir = path.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true + ) + try token.write(to: path, atomically: true, encoding: .utf8) + + // Restrict to owner read/write (0600). + let attributes: [FileAttributeKey: Any] = [ + .posixPermissions: 0o600 + ] + try FileManager.default.setAttributes(attributes, ofItemAtPath: path.path) + } + + /// Delete the auth token file. + public static func delete() throws { + let path = tokenPath() + if FileManager.default.fileExists(atPath: path.path) { + try FileManager.default.removeItem(at: path) + } + } +} + +// MARK: - Device Code Flow + +/// Response from POST /v1/device/code +private struct DeviceCodeResponse: Decodable, Sendable { + let device_code: String + let user_code: String + let verification_uri: String + let expires_in: Int + let interval: Int +} + +/// Response from POST /v1/device/token +private struct DeviceTokenResponse: Decodable, Sendable { + let status: String? + let token: String? + let error: TokenError? + + struct TokenError: Decodable, Sendable { + let message: String? + } +} + +public enum DeviceAuthError: Error, CustomStringConvertible, Sendable { + case alreadyLoggedIn(tokenPrefix: String) + case coordinatorUnreachable(String) + case deviceCodeRequestFailed(String) + case deviceCodeExpired + case authorizationFailed(String) + case invalidResponse(String) + + public var description: String { + switch self { + case .alreadyLoggedIn(let prefix): + return "Already logged in (token: \(prefix)...). Run 'darkbloom logout' first to unlink." + case .coordinatorUnreachable(let detail): + return "Failed to reach coordinator: \(detail)" + case .deviceCodeRequestFailed(let detail): + return "Failed to get device code: \(detail)" + case .deviceCodeExpired: + return "Device code expired. Run 'darkbloom login' again." + case .authorizationFailed(let detail): + return "Authorization failed: \(detail)" + case .invalidResponse(let detail): + return "Invalid response from coordinator: \(detail)" + } + } +} + +/// Convert a coordinator WebSocket URL to an HTTP base URL. +/// +/// Examples: +/// - `wss://api.darkbloom.dev/ws/provider` -> `https://api.darkbloom.dev` +/// - `ws://localhost:8080/ws/provider` -> `http://localhost:8080` +public func coordinatorHTTPBase(_ wsURL: String) -> String { + wsURL + .replacingOccurrences(of: "wss://", with: "https://") + .replacingOccurrences(of: "ws://", with: "http://") + .replacingOccurrences(of: "/ws/provider", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) +} + +/// Run the device code login flow. +/// +/// Posts to the coordinator to get a device code, displays the verification URL +/// and user code, then polls until the user authorizes or the code expires. +/// +/// - Parameters: +/// - coordinatorURL: The coordinator base HTTP URL (not the WebSocket URL). +/// - onDisplayCode: Callback to display the user code and verification URL. +/// Called once when the device code is received. The caller should print +/// these to the terminal. Parameters: (userCode, verificationURI, expiresInSeconds). +/// - onPollTick: Optional callback on each poll iteration (e.g., to print a dot). +/// - Returns: The auth token string on success. +/// - Throws: `DeviceAuthError` on failure. +@discardableResult +public func performDeviceCodeLogin( + coordinatorURL: String, + onDisplayCode: @Sendable (String, String, Int) -> Void, + onPollTick: (@Sendable () -> Void)? = nil +) async throws -> String { + // Check if already logged in. + if let existingToken = AuthTokenStore.load() { + let prefix = String(existingToken.prefix(min(20, existingToken.count))) + throw DeviceAuthError.alreadyLoggedIn(tokenPrefix: prefix) + } + + let baseURL = coordinatorHTTPBase(coordinatorURL) + + // Step 1: Request a device code. + let codeURL = URL(string: "\(baseURL)/v1/device/code")! + var codeRequest = URLRequest(url: codeURL) + codeRequest.httpMethod = "POST" + codeRequest.timeoutInterval = 10 + + let codeData: Data + let codeResponse: URLResponse + do { + (codeData, codeResponse) = try await URLSession.shared.data(for: codeRequest) + } catch { + throw DeviceAuthError.coordinatorUnreachable(error.localizedDescription) + } + + guard let httpResponse = codeResponse as? HTTPURLResponse else { + throw DeviceAuthError.invalidResponse("non-HTTP response") + } + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + let body = String(data: codeData, encoding: .utf8) ?? "" + throw DeviceAuthError.deviceCodeRequestFailed(body) + } + + let dc: DeviceCodeResponse + do { + dc = try JSONDecoder().decode(DeviceCodeResponse.self, from: codeData) + } catch { + throw DeviceAuthError.invalidResponse("could not decode device code response: \(error)") + } + + // Display the code to the user. + onDisplayCode(dc.user_code, dc.verification_uri, dc.expires_in) + + // Try to open the browser automatically. + let openProcess = Process() + openProcess.executableURL = URL(fileURLWithPath: "/usr/bin/open") + openProcess.arguments = [dc.verification_uri] + openProcess.standardOutput = FileHandle.nullDevice + openProcess.standardError = FileHandle.nullDevice + _ = try? openProcess.run() + + // Step 2: Poll for authorization. + let tokenURL = URL(string: "\(baseURL)/v1/device/token")! + let pollInterval = max(dc.interval, 1) // At least 1 second + let deadline = Date().addingTimeInterval(TimeInterval(dc.expires_in)) + + while Date() < deadline { + try await Task.sleep(nanoseconds: UInt64(pollInterval) * 1_000_000_000) + + var tokenRequest = URLRequest(url: tokenURL) + tokenRequest.httpMethod = "POST" + tokenRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + tokenRequest.timeoutInterval = 10 + + let body = try JSONSerialization.data( + withJSONObject: ["device_code": dc.device_code] + ) + tokenRequest.httpBody = body + + let tokenData: Data + do { + (tokenData, _) = try await URLSession.shared.data(for: tokenRequest) + } catch { + // Network error -- retry on next tick. + onPollTick?() + continue + } + + let tokenResp: DeviceTokenResponse + do { + tokenResp = try JSONDecoder().decode(DeviceTokenResponse.self, from: tokenData) + } catch { + // Malformed response -- retry. + onPollTick?() + continue + } + + switch tokenResp.status ?? "" { + case "authorization_pending": + onPollTick?() + continue + + case "authorized": + guard let token = tokenResp.token, !token.isEmpty else { + throw DeviceAuthError.invalidResponse("authorized but no token in response") + } + try AuthTokenStore.save(token) + return token + + default: + // expired or error + let message = tokenResp.error?.message ?? "Device code expired or invalid" + throw DeviceAuthError.authorizationFailed(message) + } + } + + throw DeviceAuthError.deviceCodeExpired +} diff --git a/provider-swift/Sources/ProviderCore/Batching/BatchQueuePlanner.swift b/provider-swift/Sources/ProviderCore/Batching/BatchQueuePlanner.swift new file mode 100644 index 00000000..09e6cb6b --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Batching/BatchQueuePlanner.swift @@ -0,0 +1,442 @@ +import Foundation + +public struct BatchSchedulingPolicy: Sendable, Equatable { + public let maxConcurrentRequests: Int + public let maxQueuedRequests: Int + public let maxActiveTokenBudget: Int + public let maxTokensPerBatch: Int + + public init( + maxConcurrentRequests: Int = 4, + maxQueuedRequests: Int = 128, + maxActiveTokenBudget: Int = 32_768, + maxTokensPerBatch: Int = 4_096 + ) { + self.maxConcurrentRequests = max(1, maxConcurrentRequests) + self.maxQueuedRequests = max(0, maxQueuedRequests) + self.maxActiveTokenBudget = max(1, maxActiveTokenBudget) + self.maxTokensPerBatch = max(1, maxTokensPerBatch) + } + + public static let `default` = BatchSchedulingPolicy() +} + +public struct BatchRequest: Sendable, Equatable, Identifiable { + public let id: String + public let promptTokenCount: Int + public let maxOutputTokens: Int + + public init(id: String, promptTokenCount: Int, maxOutputTokens: Int) { + self.id = id + self.promptTokenCount = promptTokenCount + self.maxOutputTokens = maxOutputTokens + } + + public var reservedTokenCount: Int { + promptTokenCount + maxOutputTokens + } +} + +public enum BatchRejectionReason: Sendable, Equatable { + case duplicateRequestID + case invalidTokenCount + case requestExceedsActiveTokenBudget + case requestExceedsBatchTokenBudget + case queueFull +} + +public enum BatchAdmissionResult: Sendable, Equatable { + case queued(requestID: String, position: Int) + case rejected(requestID: String, reason: BatchRejectionReason) +} + +public enum BatchRequestPhase: String, Sendable, Equatable { + case pendingPrefill + case prefilling + case decoding +} + +public enum BatchStepKind: String, Sendable, Equatable { + case prefill + case decode +} + +public struct BatchScheduledRequest: Sendable, Equatable, Identifiable { + public let id: String + public let sequence: UInt64 + public let kind: BatchStepKind + public let inputTokenCount: Int + public let promptTokenCount: Int + public let generatedTokenCount: Int + public let maxOutputTokens: Int + + public init( + id: String, + sequence: UInt64, + kind: BatchStepKind, + inputTokenCount: Int, + promptTokenCount: Int, + generatedTokenCount: Int, + maxOutputTokens: Int + ) { + self.id = id + self.sequence = sequence + self.kind = kind + self.inputTokenCount = inputTokenCount + self.promptTokenCount = promptTokenCount + self.generatedTokenCount = generatedTokenCount + self.maxOutputTokens = maxOutputTokens + } +} + +public struct ScheduledBatch: Sendable, Equatable { + public let sequence: UInt64 + public let prefill: BatchScheduledRequest? + public let decodes: [BatchScheduledRequest] + public let tokenCost: Int + public let activeTokenBudgetUsed: Int + + public init( + sequence: UInt64, + prefill: BatchScheduledRequest?, + decodes: [BatchScheduledRequest], + tokenCost: Int, + activeTokenBudgetUsed: Int + ) { + self.sequence = sequence + self.prefill = prefill + self.decodes = decodes + self.tokenCost = tokenCost + self.activeTokenBudgetUsed = activeTokenBudgetUsed + } + + public var orderedRequests: [BatchScheduledRequest] { + decodes + (prefill.map { [$0] } ?? []) + } +} + +public struct BatchRequestSnapshot: Sendable, Equatable, Identifiable { + public let id: String + public let sequence: UInt64 + public let phase: BatchRequestPhase + public let promptTokenCount: Int + public let generatedTokenCount: Int + public let maxOutputTokens: Int + public let reservedTokenCount: Int + + public init( + id: String, + sequence: UInt64, + phase: BatchRequestPhase, + promptTokenCount: Int, + generatedTokenCount: Int, + maxOutputTokens: Int, + reservedTokenCount: Int + ) { + self.id = id + self.sequence = sequence + self.phase = phase + self.promptTokenCount = promptTokenCount + self.generatedTokenCount = generatedTokenCount + self.maxOutputTokens = maxOutputTokens + self.reservedTokenCount = reservedTokenCount + } +} + +public struct BatchSchedulerSnapshot: Sendable, Equatable { + public let pendingRequests: [BatchRequestSnapshot] + public let activeRequests: [BatchRequestSnapshot] + public let activeTokenBudgetUsed: Int + public let queuedTokenBudget: Int + public let policy: BatchSchedulingPolicy + + public init( + pendingRequests: [BatchRequestSnapshot], + activeRequests: [BatchRequestSnapshot], + activeTokenBudgetUsed: Int, + queuedTokenBudget: Int, + policy: BatchSchedulingPolicy + ) { + self.pendingRequests = pendingRequests + self.activeRequests = activeRequests + self.activeTokenBudgetUsed = activeTokenBudgetUsed + self.queuedTokenBudget = queuedTokenBudget + self.policy = policy + } + + public var pendingRequestIDs: [String] { + pendingRequests.map(\.id) + } + + public var activeRequestIDs: [String] { + activeRequests.map(\.id) + } +} + +public enum DecodeStepOutcome: Sendable, Equatable { + case generated(remainingTokens: Int) + case completed + case notFound +} + +public actor BatchQueuePlanner { + private struct QueuedRequest: Sendable { + let request: BatchRequest + let sequence: UInt64 + } + + private struct ActiveRequest: Sendable { + let request: BatchRequest + let sequence: UInt64 + var phase: BatchRequestPhase + var generatedTokenCount: Int + } + + public let policy: BatchSchedulingPolicy + + private var nextRequestSequence: UInt64 = 0 + private var nextBatchSequence: UInt64 = 0 + private var pending: [QueuedRequest] = [] + private var active: [String: ActiveRequest] = [:] + private var activeOrder: [String] = [] + + public init(policy: BatchSchedulingPolicy = .default) { + self.policy = policy + } + + @discardableResult + public func admit(_ request: BatchRequest) -> BatchAdmissionResult { + guard request.promptTokenCount > 0, request.maxOutputTokens > 0 else { + return .rejected(requestID: request.id, reason: .invalidTokenCount) + } + + guard request.reservedTokenCount <= policy.maxActiveTokenBudget else { + return .rejected(requestID: request.id, reason: .requestExceedsActiveTokenBudget) + } + + guard request.promptTokenCount <= policy.maxTokensPerBatch else { + return .rejected(requestID: request.id, reason: .requestExceedsBatchTokenBudget) + } + + guard !contains(requestID: request.id) else { + return .rejected(requestID: request.id, reason: .duplicateRequestID) + } + + guard pending.count < policy.maxQueuedRequests else { + return .rejected(requestID: request.id, reason: .queueFull) + } + + nextRequestSequence += 1 + pending.append(QueuedRequest(request: request, sequence: nextRequestSequence)) + return .queued(requestID: request.id, position: pending.count) + } + + @discardableResult + public func admit( + id: String, + promptTokenCount: Int, + maxOutputTokens: Int + ) -> BatchAdmissionResult { + admit(BatchRequest( + id: id, + promptTokenCount: promptTokenCount, + maxOutputTokens: maxOutputTokens + )) + } + + public func nextBatch() -> ScheduledBatch? { + var remainingTokenBudget = policy.maxTokensPerBatch + let decodes = decodeRequests(fittingIn: &remainingTokenBudget) + let prefill = prefillRequest(fittingIn: remainingTokenBudget) + + guard prefill != nil || !decodes.isEmpty else { + return nil + } + + let tokenCost = decodes.reduce(prefill?.inputTokenCount ?? 0) { + $0 + $1.inputTokenCount + } + + nextBatchSequence += 1 + return ScheduledBatch( + sequence: nextBatchSequence, + prefill: prefill, + decodes: decodes, + tokenCost: tokenCost, + activeTokenBudgetUsed: activeTokenBudgetUsed + ) + } + + @discardableResult + public func markPrefillComplete(requestID: String) -> Bool { + guard var request = active[requestID], request.phase == .prefilling else { + return false + } + + request.phase = .decoding + active[requestID] = request + return true + } + + @discardableResult + public func recordDecodeStep( + requestID: String, + generatedTokens: Int = 1 + ) -> DecodeStepOutcome { + guard var request = active[requestID], request.phase == .decoding else { + return .notFound + } + + request.generatedTokenCount += max(0, generatedTokens) + if request.generatedTokenCount >= request.request.maxOutputTokens { + removeActive(requestID: requestID) + return .completed + } + + active[requestID] = request + return .generated( + remainingTokens: request.request.maxOutputTokens - request.generatedTokenCount + ) + } + + @discardableResult + public func complete(requestID: String) -> Bool { + guard active[requestID] != nil else { + return false + } + + removeActive(requestID: requestID) + return true + } + + @discardableResult + public func cancel(requestID: String) -> Bool { + if let index = pending.firstIndex(where: { $0.request.id == requestID }) { + pending.remove(at: index) + return true + } + + guard active[requestID] != nil else { + return false + } + + removeActive(requestID: requestID) + return true + } + + public func snapshot() -> BatchSchedulerSnapshot { + let pendingSnapshots = pending.map { queued in + snapshot(for: queued) + } + let activeSnapshots = activeOrder.compactMap { requestID in + active[requestID].map { snapshot(for: $0) } + } + + return BatchSchedulerSnapshot( + pendingRequests: pendingSnapshots, + activeRequests: activeSnapshots, + activeTokenBudgetUsed: activeTokenBudgetUsed, + queuedTokenBudget: pending.reduce(0) { $0 + $1.request.reservedTokenCount }, + policy: policy + ) + } + + private func decodeRequests(fittingIn remainingTokenBudget: inout Int) -> [BatchScheduledRequest] { + var scheduled: [BatchScheduledRequest] = [] + + for requestID in activeOrder { + guard remainingTokenBudget > 0 else { + break + } + guard let request = active[requestID], request.phase == .decoding else { + continue + } + + scheduled.append(scheduledRequest(for: request, kind: .decode)) + remainingTokenBudget -= 1 + } + + return scheduled + } + + private func prefillRequest(fittingIn remainingTokenBudget: Int) -> BatchScheduledRequest? { + guard let next = pending.first else { + return nil + } + guard active.count < policy.maxConcurrentRequests else { + return nil + } + guard next.request.promptTokenCount <= remainingTokenBudget else { + return nil + } + guard activeTokenBudgetUsed + next.request.reservedTokenCount <= policy.maxActiveTokenBudget + else { + return nil + } + + pending.removeFirst() + + let activeRequest = ActiveRequest( + request: next.request, + sequence: next.sequence, + phase: .prefilling, + generatedTokenCount: 0 + ) + active[next.request.id] = activeRequest + activeOrder.append(next.request.id) + + return scheduledRequest(for: activeRequest, kind: .prefill) + } + + private func contains(requestID: String) -> Bool { + active[requestID] != nil || pending.contains { $0.request.id == requestID } + } + + private var activeTokenBudgetUsed: Int { + active.values.reduce(0) { $0 + $1.request.reservedTokenCount } + } + + private func removeActive(requestID: String) { + active.removeValue(forKey: requestID) + activeOrder.removeAll { $0 == requestID } + } + + private func scheduledRequest( + for activeRequest: ActiveRequest, + kind: BatchStepKind + ) -> BatchScheduledRequest { + BatchScheduledRequest( + id: activeRequest.request.id, + sequence: activeRequest.sequence, + kind: kind, + inputTokenCount: kind == .prefill ? activeRequest.request.promptTokenCount : 1, + promptTokenCount: activeRequest.request.promptTokenCount, + generatedTokenCount: activeRequest.generatedTokenCount, + maxOutputTokens: activeRequest.request.maxOutputTokens + ) + } + + private func snapshot(for queued: QueuedRequest) -> BatchRequestSnapshot { + BatchRequestSnapshot( + id: queued.request.id, + sequence: queued.sequence, + phase: .pendingPrefill, + promptTokenCount: queued.request.promptTokenCount, + generatedTokenCount: 0, + maxOutputTokens: queued.request.maxOutputTokens, + reservedTokenCount: queued.request.reservedTokenCount + ) + } + + private func snapshot(for activeRequest: ActiveRequest) -> BatchRequestSnapshot { + BatchRequestSnapshot( + id: activeRequest.request.id, + sequence: activeRequest.sequence, + phase: activeRequest.phase, + promptTokenCount: activeRequest.request.promptTokenCount, + generatedTokenCount: activeRequest.generatedTokenCount, + maxOutputTokens: activeRequest.request.maxOutputTokens, + reservedTokenCount: activeRequest.request.reservedTokenCount + ) + } +} diff --git a/provider-swift/Sources/ProviderCore/Benchmark/ModelBenchmark.swift b/provider-swift/Sources/ProviderCore/Benchmark/ModelBenchmark.swift new file mode 100644 index 00000000..207590d1 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Benchmark/ModelBenchmark.swift @@ -0,0 +1,252 @@ +import Foundation +import MLX +import MLXLLM +import MLXLMCommon + +/// Result of a single benchmark iteration. +public struct BenchmarkIterationResult: Sendable { + public let iteration: Int + public let promptTokens: Int + public let completionTokens: Int + public let prefillLatencyMs: Double + public let decodeTokensPerSecond: Double + public let totalTimeMs: Double +} + +/// Aggregated benchmark results across all iterations. +public struct BenchmarkReport: Sendable { + public let modelID: String + public let modelPath: String + public let prompt: String + public let iterations: [BenchmarkIterationResult] + public let hardwareDescription: String + + public var avgPrefillLatencyMs: Double { + guard !iterations.isEmpty else { return 0 } + return iterations.map(\.prefillLatencyMs).reduce(0, +) / Double(iterations.count) + } + + public var avgDecodeTokensPerSecond: Double { + guard !iterations.isEmpty else { return 0 } + return iterations.map(\.decodeTokensPerSecond).reduce(0, +) / Double(iterations.count) + } + + public var avgTotalTimeMs: Double { + guard !iterations.isEmpty else { return 0 } + return iterations.map(\.totalTimeMs).reduce(0, +) / Double(iterations.count) + } + + public var avgPromptTokens: Int { + guard !iterations.isEmpty else { return 0 } + return iterations.map(\.promptTokens).reduce(0, +) / iterations.count + } + + public var avgCompletionTokens: Int { + guard !iterations.isEmpty else { return 0 } + return iterations.map(\.completionTokens).reduce(0, +) / iterations.count + } + + public func printTable() { + print("") + print("Benchmark: \(modelID)") + print("Path: \(modelPath)") + print("Hardware: \(hardwareDescription)") + print("Prompt: \"\(prompt)\"") + print("Iterations: \(iterations.count)") + print("") + + let headers = ["ITER", "PREFILL", "DECODE TPS", "TOTAL", "PROMPT TOK", "COMP TOK"] + let rows = iterations.map { iter in + [ + "\(iter.iteration)", + String(format: "%.1f ms", iter.prefillLatencyMs), + String(format: "%.2f tok/s", iter.decodeTokensPerSecond), + String(format: "%.0f ms", iter.totalTimeMs), + "\(iter.promptTokens)", + "\(iter.completionTokens)", + ] + } + + let widths = headers.enumerated().map { index, header in + rows.reduce(header.count) { max($0, $1[index].count) } + } + + func line(_ columns: [String]) -> String { + columns.enumerated().map { index, value in + value.padding(toLength: widths[index], withPad: " ", startingAt: 0) + }.joined(separator: " ") + } + + print(line(headers)) + print(line(widths.map { String(repeating: "-", count: $0) })) + for row in rows { + print(line(row)) + } + + print("") + print("Average:") + print(" Prefill latency: \(String(format: "%.1f ms", avgPrefillLatencyMs))") + print(" Decode throughput: \(String(format: "%.2f tok/s", avgDecodeTokensPerSecond))") + print(" Total time: \(String(format: "%.0f ms", avgTotalTimeMs))") + print(" Prompt tokens: \(avgPromptTokens)") + print(" Completion tokens: \(avgCompletionTokens)") + } +} + +/// Runs standardized inference benchmarks against a local MLX model. +public struct ModelBenchmark: Sendable { + + public static let defaultPrompt = "Write a short story about a robot learning to paint." + public static let defaultIterations = 3 + public static let defaultMaxTokens = 256 + + /// Select the best model to benchmark from available models. + /// + /// Picks the largest model that fits in available memory, matching the + /// provider's model selection logic. + public static func selectModel( + models: [ModelInfo], + preferredModel: String? + ) -> ModelInfo? { + if let preferredModel { + return models.first { $0.id == preferredModel } + } + // Pick the largest model (models are sorted by estimated memory ascending) + return models.last + } + + /// Run the benchmark against a model. + public static func run( + modelID: String, + modelDirectory: URL, + prompt: String = defaultPrompt, + iterations: Int = defaultIterations, + maxTokens: Int = defaultMaxTokens, + hardware: HardwareInfo + ) async throws -> BenchmarkReport { + let hardwareDesc = "\(hardware.chipName), \(hardware.memoryGb) GB RAM, \(hardware.gpuCores) GPU cores, \(hardware.memoryBandwidthGbs) GB/s" + + print("Loading model: \(modelID)") + print("Path: \(modelDirectory.path)") + + let container = try await LLMModelFactory.shared.loadContainer( + from: modelDirectory, + using: LocalTokenizerLoader() + ) + + print("Model loaded. Running \(iterations) iteration(s)...") + print("") + + var results: [BenchmarkIterationResult] = [] + + for i in 1...iterations { + print("Iteration \(i)/\(iterations)...") + + let result = try await runIteration( + container: container, + modelID: modelID, + prompt: prompt, + maxTokens: maxTokens, + iteration: i + ) + results.append(result) + } + + return BenchmarkReport( + modelID: modelID, + modelPath: modelDirectory.path, + prompt: prompt, + iterations: results, + hardwareDescription: hardwareDesc + ) + } + + private static func runIteration( + container: ModelContainer, + modelID: String, + prompt: String, + maxTokens: Int, + iteration: Int + ) async throws -> BenchmarkIterationResult { + let messages: [ChatMessage] = [ + ChatMessage(role: "user", content: prompt) + ] + + let request = ChatCompletionRequest( + model: modelID, + messages: messages, + temperature: 0.6, + max_tokens: maxTokens, + stream: false + ) + + let rawMessages = try messages.map { msg -> MLXLMCommon.Message in + ["role": msg.role, "content": msg.content] as MLXLMCommon.Message + } + + let iterationStart = ContinuousClock.now + + let generationStream: AsyncStream = try await container.perform { + context in + let input = try await context.processor.prepare( + input: UserInput(messages: rawMessages)) + let params = GenerateParameters( + maxTokens: request.max_tokens, + temperature: request.temperature ?? 0.6, + topP: request.top_p ?? 1.0, + topK: request.top_k ?? 0 + ) + return try MLXLMCommon.generate( + input: input, parameters: params, context: context) + } + + var promptTokens = 0 + var completionTokens = 0 + var prefillLatencyMs: Double = 0 + var firstTokenTime: ContinuousClock.Instant? + + for await generation in generationStream { + switch generation { + case .chunk: + if firstTokenTime == nil { + firstTokenTime = .now + let elapsed = firstTokenTime! - iterationStart + prefillLatencyMs = Double(elapsed.components.attoseconds) / 1e15 + } + + case .info(let info): + promptTokens = info.promptTokenCount + completionTokens = info.generationTokenCount + // Use the info's own timing if we didn't capture first token + if prefillLatencyMs == 0 { + prefillLatencyMs = info.promptTime * 1000 + } + + case .toolCall: + break + } + } + + let totalElapsed = ContinuousClock.now - iterationStart + let totalTimeMs = Double(totalElapsed.components.attoseconds) / 1e15 + + // Calculate decode TPS from the generation info's timing when available, + // otherwise approximate from wall-clock + let decodeTimeMs = totalTimeMs - prefillLatencyMs + let decodeTokensPerSecond: Double + if completionTokens > 0 && decodeTimeMs > 0 { + decodeTokensPerSecond = Double(completionTokens) / (decodeTimeMs / 1000) + } else { + decodeTokensPerSecond = 0 + } + + return BenchmarkIterationResult( + iteration: iteration, + promptTokens: promptTokens, + completionTokens: completionTokens, + prefillLatencyMs: prefillLatencyMs, + decodeTokensPerSecond: decodeTokensPerSecond, + totalTimeMs: totalTimeMs + ) + } +} diff --git a/provider-swift/Sources/ProviderCore/Config/ProviderConfig.swift b/provider-swift/Sources/ProviderCore/Config/ProviderConfig.swift new file mode 100644 index 00000000..421ab762 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Config/ProviderConfig.swift @@ -0,0 +1,317 @@ +/// Provider configuration management. +/// +/// Configuration is stored in TOML format at `~/.config/eigeninference/provider.toml` +/// (legacy path, kept for backward compatibility with existing installations). +/// The same path the Rust CLI uses, via `dirs::config_dir()`. The config includes: +/// - Provider identity (name, memory reserve) +/// - Backend settings (port, model, continuous batching, idle timeout) +/// - Coordinator connection settings (URL, heartbeat interval) +/// - Scheduling windows +/// +/// A default config is generated based on detected hardware when the provider +/// is first initialized. CLI flags can override config values at runtime. + +import Foundation +import TOMLKit + +// MARK: - Config structs + +public struct ProviderSettings: Sendable, Equatable, Codable { + public var name: String + public var memoryReserveGB: UInt64 + public var autoUpdate: Bool + + public init(name: String, memoryReserveGB: UInt64 = 4, autoUpdate: Bool = true) { + self.name = name + self.memoryReserveGB = memoryReserveGB + self.autoUpdate = autoUpdate + } + + enum CodingKeys: String, CodingKey { + case name + case memoryReserveGB = "memory_reserve_gb" + case autoUpdate = "auto_update" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "darkbloom" + self.memoryReserveGB = try container.decodeIfPresent(UInt64.self, forKey: .memoryReserveGB) ?? 4 + self.autoUpdate = try container.decodeIfPresent(Bool.self, forKey: .autoUpdate) ?? true + } +} + +public struct BackendSettings: Sendable, Equatable, Codable { + public var port: UInt16 + public var model: String? + public var continuousBatching: Bool + /// Which models to advertise to the network. If empty, all downloaded models + /// are advertised. If set, only these models are offered. + public var enabledModels: [String] + /// Minutes of inactivity before the backend is shut down to free GPU memory. + /// 0 = never shut down. Default: 60 (1 hour). + public var idleTimeoutMins: UInt64 + + public init( + port: UInt16 = 8100, + model: String? = nil, + continuousBatching: Bool = true, + enabledModels: [String] = [], + idleTimeoutMins: UInt64 = 60 + ) { + self.port = port + self.model = model + self.continuousBatching = continuousBatching + self.enabledModels = enabledModels + self.idleTimeoutMins = idleTimeoutMins + } + + enum CodingKeys: String, CodingKey { + case port + case model + case continuousBatching = "continuous_batching" + case enabledModels = "enabled_models" + case idleTimeoutMins = "idle_timeout_mins" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.port = try container.decodeIfPresent(UInt16.self, forKey: .port) ?? 8100 + self.model = try container.decodeIfPresent(String.self, forKey: .model) + self.continuousBatching = try container.decodeIfPresent(Bool.self, forKey: .continuousBatching) ?? true + self.enabledModels = try container.decodeIfPresent([String].self, forKey: .enabledModels) ?? [] + self.idleTimeoutMins = try container.decodeIfPresent(UInt64.self, forKey: .idleTimeoutMins) ?? 60 + } +} + +public struct CoordinatorSettings: Sendable, Equatable, Codable { + public var url: String + public var heartbeatIntervalSecs: UInt64 + + public init(url: String = "ws://localhost:8080/ws/provider", heartbeatIntervalSecs: UInt64 = 5) { + self.url = url + self.heartbeatIntervalSecs = heartbeatIntervalSecs + } + + enum CodingKeys: String, CodingKey { + case url + case heartbeatIntervalSecs = "heartbeat_interval_secs" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.url = try container.decodeIfPresent(String.self, forKey: .url) ?? "ws://localhost:8080/ws/provider" + self.heartbeatIntervalSecs = try container.decodeIfPresent(UInt64.self, forKey: .heartbeatIntervalSecs) ?? 5 + } +} + +public struct ProviderConfig: Sendable, Equatable, Codable { + public var provider: ProviderSettings + public var backend: BackendSettings + public var coordinator: CoordinatorSettings + public var schedule: ScheduleConfig? + + public init( + provider: ProviderSettings, + backend: BackendSettings = BackendSettings(), + coordinator: CoordinatorSettings = CoordinatorSettings(), + schedule: ScheduleConfig? = nil + ) { + self.provider = provider + self.backend = backend + self.coordinator = coordinator + self.schedule = schedule + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.provider = try container.decodeIfPresent(ProviderSettings.self, forKey: .provider) ?? ProviderSettings(name: "darkbloom") + self.backend = try container.decodeIfPresent(BackendSettings.self, forKey: .backend) ?? BackendSettings() + self.coordinator = try container.decodeIfPresent(CoordinatorSettings.self, forKey: .coordinator) ?? CoordinatorSettings() + self.schedule = try container.decodeIfPresent(ScheduleConfig.self, forKey: .schedule) + } + + /// Generate a default config based on detected hardware. + /// + /// The provider name is derived from the machine model identifier + /// (e.g. "Mac16,1" -> "darkbloom-mac16-1"). + public static func defaultForHardware(_ hw: HardwareInfo) -> ProviderConfig { + let name = "darkbloom-" + hw.machineModel + .replacingOccurrences(of: ",", with: "-") + .lowercased() + + return ProviderConfig( + provider: ProviderSettings( + name: name, + memoryReserveGB: 4, + autoUpdate: true + ), + backend: BackendSettings( + port: 8100, + model: nil, + continuousBatching: true, + enabledModels: [], + idleTimeoutMins: 60 + ), + coordinator: CoordinatorSettings( + url: "ws://localhost:8080/ws/provider", + heartbeatIntervalSecs: 5 + ), + schedule: nil + ) + } +} + +// MARK: - File I/O + +public enum ConfigError: Error, CustomStringConvertible { + case cannotDetermineConfigDirectory + case readFailed(path: String, underlying: Error) + case writeFailed(path: String, underlying: Error) + case parseFailed(detail: String) + + public var description: String { + switch self { + case .cannotDetermineConfigDirectory: + return "could not determine config directory" + case .readFailed(let path, let err): + return "failed to read config from \(path): \(err)" + case .writeFailed(let path, let err): + return "failed to write config to \(path): \(err)" + case .parseFailed(let detail): + return "failed to parse config: \(detail)" + } + } +} + +public enum ConfigManager: Sendable { + + /// Default config file path: `~/.config/eigeninference/provider.toml` + /// + /// The `~/.config/eigeninference/` path is a legacy path kept for backward + /// compatibility with existing Rust CLI installations. This matches the Rust + /// `dirs::config_dir()` behavior on macOS, which maps to + /// `~/Library/Application Support/`. We check both XDG-style and App Support paths. + public static func defaultConfigPath() throws -> URL { + // The Rust `dirs` crate maps config_dir() to ~/Library/Application Support/ + // on macOS, but the Darkbloom provider historically uses ~/.config/ via + // dirs::config_dir() which follows XDG on macOS when $XDG_CONFIG_HOME is set. + // Check both locations, preferring the one that exists. + // Note: ~/.config/eigeninference/ is a legacy path kept for backward compatibility. + let home = FileManager.default.homeDirectoryForCurrentUser + + // Primary: ~/.config/eigeninference/provider.toml (legacy path, matches Rust CLI) + let xdgPath = home + .appendingPathComponent(".config") + .appendingPathComponent("eigeninference") + .appendingPathComponent("provider.toml") + + // Secondary: ~/Library/Application Support/eigeninference/provider.toml (legacy path) + if let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first { + let appSupportPath = appSupport + .appendingPathComponent("eigeninference") + .appendingPathComponent("provider.toml") + + // Also check legacy "darkbloom" directory + let legacyPath = appSupport + .appendingPathComponent("darkbloom") + .appendingPathComponent("provider.toml") + + // Return whichever exists, preferring xdg > appSupport > legacy > xdg (default) + if FileManager.default.fileExists(atPath: xdgPath.path) { + return xdgPath + } + if FileManager.default.fileExists(atPath: appSupportPath.path) { + return appSupportPath + } + if FileManager.default.fileExists(atPath: legacyPath.path) { + return legacyPath + } + } + + // Default to XDG path for new installs + return xdgPath + } + + /// Load config from a file path. + public static func load(from path: URL) throws -> ProviderConfig { + let content: String + do { + content = try String(contentsOf: path, encoding: .utf8) + } catch { + throw ConfigError.readFailed(path: path.path, underlying: error) + } + return parse(content) + } + + /// Load config from the default path. Returns default config if file doesn't exist. + public static func loadDefault() -> ProviderConfig { + guard let path = try? defaultConfigPath(), + FileManager.default.fileExists(atPath: path.path), + let config = try? load(from: path) + else { + // Return a minimal default if we can't load + return ProviderConfig( + provider: ProviderSettings(name: "darkbloom"), + backend: BackendSettings(), + coordinator: CoordinatorSettings() + ) + } + return config + } + + /// Save config to a file path, creating parent directories as needed. + public static func save(_ config: ProviderConfig, to path: URL) throws { + let dir = path.deletingLastPathComponent() + do { + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true + ) + } catch { + throw ConfigError.writeFailed(path: dir.path, underlying: error) + } + + let toml = serialize(config) + do { + try toml.write(to: path, atomically: true, encoding: .utf8) + } catch { + throw ConfigError.writeFailed(path: path.path, underlying: error) + } + } + + /// Read-modify-write: load config, apply a transform, save it back. + public static func update(at path: URL, _ transform: (inout ProviderConfig) -> Void) throws { + var config = try load(from: path) + transform(&config) + try save(config, to: path) + } + + // MARK: - TOML parsing + + /// Parse a TOML string into a ProviderConfig. + public static func parse(_ content: String) -> ProviderConfig { + do { + return try TOMLDecoder().decode(ProviderConfig.self, from: content) + } catch { + // Fall back to defaults on malformed TOML (matches previous behavior) + return ProviderConfig( + provider: ProviderSettings(name: "darkbloom"), + backend: BackendSettings(), + coordinator: CoordinatorSettings() + ) + } + } + + /// Serialize a ProviderConfig to TOML matching the Rust CLI format. + public static func serialize(_ config: ProviderConfig) -> String { + do { + return try TOMLEncoder().encode(config) + } catch { + // Should never happen with our well-defined types, but return empty + // string rather than crashing (matches previous graceful behavior). + return "" + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClient.swift b/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClient.swift new file mode 100644 index 00000000..53433d8e --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClient.swift @@ -0,0 +1,675 @@ +import Foundation +#if canImport(os) +import os +#endif + +// MARK: - Event Types + +public enum CoordinatorEvent: Sendable { + case connected + case disconnected + case inferenceRequest(requestId: String, body: Data, responsePublicKey: [UInt8]?) + case cancel(requestId: String) + case attestationChallenge(nonce: String, timestamp: String) + case runtimeOutdated(mismatches: [RuntimeMismatch]) +} + +// MARK: - Shared State + +public final class AtomicProviderStats: Sendable { + private let _requestsServed = ManagedAtomic(0) + private let _tokensGenerated = ManagedAtomic(0) + + public init() {} + + public var requestsServed: UInt64 { + get { _requestsServed.load() } + set { _requestsServed.store(newValue) } + } + + public var tokensGenerated: UInt64 { + get { _tokensGenerated.load() } + set { _tokensGenerated.store(newValue) } + } + + public func incrementRequestsServed() { + _requestsServed.add(1) + } + + public func addTokensGenerated(_ count: UInt64) { + _tokensGenerated.add(count) + } +} + +/// Lock-free atomic wrapper using os_unfair_lock for shared mutable state +/// accessed from both the heartbeat tick and the main event loop. +public final class ProviderState: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock() + private var _inferenceActive: Bool = false + private var _currentModel: String? = nil + private var _warmModels: [String] = [] + private var _currentModelHash: String? = nil + private var _backendCapacity: BackendCapacity? = nil + + public init() {} + + public var inferenceActive: Bool { + get { lock.withLock { _inferenceActive } } + set { lock.withLock { _inferenceActive = newValue } } + } + + public var currentModel: String? { + get { lock.withLock { _currentModel } } + set { lock.withLock { _currentModel = newValue } } + } + + public var warmModels: [String] { + get { lock.withLock { _warmModels } } + set { lock.withLock { _warmModels = newValue } } + } + + public var currentModelHash: String? { + get { lock.withLock { _currentModelHash } } + set { lock.withLock { _currentModelHash = newValue } } + } + + public var backendCapacity: BackendCapacity? { + get { lock.withLock { _backendCapacity } } + set { lock.withLock { _backendCapacity = newValue } } + } +} + +// MARK: - os_unfair_lock wrapper (Sendable-safe) + +private final class OSAllocatedUnfairLock: @unchecked Sendable { + private let _lock: UnsafeMutablePointer + + init() { + _lock = .allocate(capacity: 1) + _lock.initialize(to: os_unfair_lock()) + } + + deinit { + _lock.deinitialize(count: 1) + _lock.deallocate() + } + + func withLock(_ body: () -> T) -> T { + os_unfair_lock_lock(_lock) + defer { os_unfair_lock_unlock(_lock) } + return body() + } +} + +// MARK: - PongTracker (thread-safe timestamp for ping/pong timeout) + +/// Tracks the last pong time. Updated from URLSessionWebSocketTask's sendPing +/// completion handler (runs on an arbitrary queue) and read from the ping +/// task on the cooperative thread pool. +private final class PongTracker: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock() + private var lastPong = CFAbsoluteTimeGetCurrent() + + func recordPong() { + lock.withLock { lastPong = CFAbsoluteTimeGetCurrent() } + } + + func elapsed() -> TimeInterval { + lock.withLock { CFAbsoluteTimeGetCurrent() - lastPong } + } +} + +// MARK: - ManagedAtomic + +private final class ManagedAtomic: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock() + private var value: Value + + init(_ initial: Value) { + self.value = initial + } + + func load() -> Value { + lock.withLock { value } + } + + func store(_ value: Value) { + lock.withLock { self.value = value } + } + + func add(_ delta: Value) { + lock.withLock { value &+= delta } + } +} + +// MARK: - Configuration + +public struct CoordinatorClientConfig: Sendable { + public let url: String + public let hardware: HardwareInfo + public let models: [ModelInfo] + public let backendName: String + public let heartbeatInterval: TimeInterval + public let publicKey: String? + public let walletAddress: String? + public let attestation: RawJSON? + public let authToken: String? + public let runtimeHashes: RuntimeHashes? + public let modelHashes: [String: String] + public let privacyCapabilities: PrivacyCapabilities? + + public init( + url: String, + hardware: HardwareInfo, + models: [ModelInfo], + backendName: String, + heartbeatInterval: TimeInterval = 30.0, + publicKey: String? = nil, + walletAddress: String? = nil, + attestation: RawJSON? = nil, + authToken: String? = nil, + runtimeHashes: RuntimeHashes? = nil, + modelHashes: [String: String] = [:], + privacyCapabilities: PrivacyCapabilities? = nil + ) { + self.url = url + self.hardware = hardware + self.models = models + self.backendName = backendName + self.heartbeatInterval = heartbeatInterval + self.publicKey = publicKey + self.walletAddress = walletAddress + self.attestation = attestation + self.authToken = authToken + self.runtimeHashes = runtimeHashes + self.modelHashes = modelHashes + self.privacyCapabilities = privacyCapabilities + } +} + +public struct RuntimeHashes: Sendable { + public let pythonHash: String? + public let runtimeHash: String? + public let templateHashes: [String: String] + + public init( + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:] + ) { + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + } +} + +// MARK: - Outbound message type (provider -> coordinator) + +public enum OutboundMessage: Sendable { + case inferenceAccepted(requestId: String) + case inferenceChunk(requestId: String, data: String, encryptedData: EncryptedPayload?) + case inferenceComplete(requestId: String, usage: UsageInfo, seSignature: String?, responseHash: String?) + case inferenceError(requestId: String, error: String, statusCode: UInt16) + case attestationResponse(AttestationResponsePayload) +} + +public struct AttestationResponsePayload: Sendable { + public let nonce: String + public let signature: String + public let statusSignature: String? + public let publicKey: String + public let hypervisorActive: Bool? + public let rdmaDisabled: Bool? + public let sipEnabled: Bool? + public let secureBootEnabled: Bool? + public let binaryHash: String? + public let activeModelHash: String? + public let pythonHash: String? + public let runtimeHash: String? + public let templateHashes: [String: String] + public let modelHashes: [String: String] + + public init( + nonce: String, + signature: String, + statusSignature: String? = nil, + publicKey: String, + hypervisorActive: Bool? = nil, + rdmaDisabled: Bool? = nil, + sipEnabled: Bool? = nil, + secureBootEnabled: Bool? = nil, + binaryHash: String? = nil, + activeModelHash: String? = nil, + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:], + modelHashes: [String: String] = [:] + ) { + self.nonce = nonce + self.signature = signature + self.statusSignature = statusSignature + self.publicKey = publicKey + self.hypervisorActive = hypervisorActive + self.rdmaDisabled = rdmaDisabled + self.sipEnabled = sipEnabled + self.secureBootEnabled = secureBootEnabled + self.binaryHash = binaryHash + self.activeModelHash = activeModelHash + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + self.modelHashes = modelHashes + } +} + +// MARK: - Coordinator Client Actor + +public actor CoordinatorClient { + private let config: CoordinatorClientConfig + private let stats: AtomicProviderStats + private let state: ProviderState + + private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "coordinator") + + private var eventContinuation: AsyncStream.Continuation? + private var outboundContinuation: AsyncStream.Continuation? + + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession? + + private var shutdownRequested = false + + public init( + config: CoordinatorClientConfig, + stats: AtomicProviderStats, + state: ProviderState + ) { + self.config = config + self.stats = stats + self.state = state + } + + /// Start the connection loop. Returns an AsyncStream of events for the caller + /// to consume, and provides a way to send outbound messages. + public func start() -> (events: AsyncStream, send: @Sendable (OutboundMessage) -> Void) { + let (eventStream, eventCont) = AsyncStream.makeStream() + self.eventContinuation = eventCont + + let (outboundStream, outboundCont) = AsyncStream.makeStream() + self.outboundContinuation = outboundCont + + let sendFn: @Sendable (OutboundMessage) -> Void = { msg in + outboundCont.yield(msg) + } + + Task { [weak self] in + guard let self else { return } + await self.runLoop(outboundStream: outboundStream) + } + + return (eventStream, sendFn) + } + + public func shutdown() { + shutdownRequested = true + webSocketTask?.cancel(with: .goingAway, reason: nil) + eventContinuation?.finish() + outboundContinuation?.finish() + } + + // MARK: - Connection Loop + + private func runLoop(outboundStream: AsyncStream) async { + var backoff = ExponentialBackoff(base: 1.0, max: 30.0) + var reconnectCount: UInt64 = 0 + + while !shutdownRequested { + logger.info("Connecting to coordinator: \(self.config.url)") + + do { + try await connectAndRun(outboundStream: outboundStream) + logger.info("Coordinator connection closed, reconnecting...") + backoff.reset() + continue + } catch { + if shutdownRequested { break } + + eventContinuation?.yield(.disconnected) + let delay = backoff.nextDelay() + logger.warning("Coordinator connection error: \(error.localizedDescription). Reconnecting in \(delay)s") + + reconnectCount += 1 + if shouldEmitReconnectTelemetry(count: reconnectCount) { + emitReconnectTelemetry(count: reconnectCount, error: error) + } + + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + // Task cancelled = shutdown + break + } + } + } + + logger.info("Coordinator client shut down") + eventContinuation?.finish() + } + + // MARK: - Single Connection Session + + private func connectAndRun(outboundStream: AsyncStream) async throws { + guard let url = URL(string: config.url) else { + throw CoordinatorError.invalidURL(config.url) + } + + let session = URLSession(configuration: .default) + self.urlSession = session + let ws = session.webSocketTask(with: url) + self.webSocketTask = ws + ws.resume() + + try await sendRegistration(ws: ws) + logger.info("Sent registration to coordinator") + + eventContinuation?.yield(.connected) + + try await sessionLoop(ws: ws, outboundStream: outboundStream) + } + + private func sessionLoop( + ws: URLSessionWebSocketTask, + outboundStream: AsyncStream + ) async throws { + let pingInterval: TimeInterval = 10.0 + let pongTimeout: TimeInterval = 30.0 + + // Thread-safe pong timestamp: updated from sendPing's callback (arbitrary queue), + // read from the ping task. Using an actor would force structured concurrency + // overhead on every ping; an unfair lock is cheaper for a single Instant. + let pongTracker = PongTracker() + + try await withThrowingTaskGroup(of: Void.self) { group in + // Task 1: Receive messages from coordinator + group.addTask { [weak self] in + guard let self else { return } + try await self.receiveLoop(ws: ws) + } + + // Task 2: Forward outbound messages to coordinator + group.addTask { [weak self] in + guard let self else { return } + for await msg in outboundStream { + let shutting = await self.shutdownRequested + if shutting { break } + let json = await self.encodeOutbound(msg) + try await ws.send(.string(json)) + } + } + + // Task 3: Heartbeat timer + group.addTask { [weak self] in + guard let self else { return } + let interval = await self.config.heartbeatInterval + + try await Task.sleep(for: .seconds(interval)) + + while true { + let shutting = await self.shutdownRequested + if shutting { break } + let json = await self.buildHeartbeatJSON() + try await ws.send(.string(json)) + try await Task.sleep(for: .seconds(interval)) + } + } + + // Task 4: Ping timer with pong timeout detection + group.addTask { + while true { + try await Task.sleep(for: .seconds(pingInterval)) + + if pongTracker.elapsed() > pongTimeout { + throw CoordinatorError.pongTimeout + } + + ws.sendPing { error in + if error == nil { + pongTracker.recordPong() + } + } + } + } + + do { + try await group.next() + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Receive Loop + + private func receiveLoop(ws: URLSessionWebSocketTask) async throws { + while !shutdownRequested { + let message: URLSessionWebSocketTask.Message + do { + message = try await ws.receive() + } catch { + throw CoordinatorError.connectionClosed(error) + } + + switch message { + case .string(let text): + await handleIncomingText(text, ws: ws) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + await handleIncomingText(text, ws: ws) + } + @unknown default: + break + } + } + } + + private func handleIncomingText(_ text: String, ws: URLSessionWebSocketTask) async { + guard let data = text.data(using: .utf8) else { return } + + let parsed: CoordinatorMessage + do { + parsed = try CoordinatorClientCodec.decodeIncomingMessage(from: data) + } catch { + logger.warning("Failed to parse coordinator message: \(error.localizedDescription)") + return + } + + switch parsed { + case .inferenceRequest(let request): + let requestId = request.requestId + logger.info("Received inference request: \(requestId)") + + guard let encrypted = request.encryptedBody else { + logger.error("Rejecting plaintext inference request: \(requestId)") + let errorResponse = encodeInferenceError( + requestId: requestId, + error: "coordinator text request missing encrypted body", + statusCode: 400 + ) + try? await ws.send(.string(errorResponse)) + return + } + + eventContinuation?.yield(.inferenceRequest( + requestId: requestId, + body: Data(encrypted.ciphertext.utf8), + responsePublicKey: decodeEphemeralKey(encrypted.ephemeralPublicKey) + )) + + case .cancel(let cancel): + let requestId = cancel.requestId + logger.info("Received cancel for: \(requestId)") + eventContinuation?.yield(.cancel(requestId: requestId)) + + case .attestationChallenge(let challenge): + logger.info("Received attestation challenge") + eventContinuation?.yield(.attestationChallenge( + nonce: challenge.nonce, + timestamp: challenge.timestamp + )) + + case .runtimeStatus(let status): + if status.verified { + logger.info("Runtime integrity verified by coordinator") + } else { + logger.warning("Runtime integrity check FAILED -- \(status.mismatches.count) mismatch(es)") + for m in status.mismatches { + logger.warning(" \(m.component): expected=\(m.expected), got=\(m.got)") + } + eventContinuation?.yield(.runtimeOutdated(mismatches: status.mismatches)) + } + } + } + + // MARK: - Registration + + private func sendRegistration(ws: URLSessionWebSocketTask) async throws { + let privacyCapabilities = config.privacyCapabilities ?? PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: SecurityChecks.isSIPEnabled(), + antiDebugEnabled: true, + coreDumpsDisabled: true, + envScrubbed: true, + hypervisorActive: SecurityChecks.isHypervisorActive() + ) + let jsonData = try CoordinatorClientCodec.encodeRegistration( + from: config, + privacyCapabilities: privacyCapabilities + ) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw CoordinatorError.encodingFailed + } + try await ws.send(.string(jsonString)) + } + + // MARK: - Heartbeat + + private func buildHeartbeatJSON() -> String { + let isActive = state.inferenceActive + let activeModel = state.currentModel + let warmModels = state.warmModels + let capacity = state.backendCapacity + let metrics = SystemMetricsCollector.collect(cpuCores: config.hardware.cpuCores.total) + + let message = CoordinatorClientCodec.heartbeatMessage( + status: isActive ? .serving : .idle, + activeModel: activeModel, + warmModels: warmModels, + stats: ProviderStats( + requestsServed: stats.requestsServed, + tokensGenerated: stats.tokensGenerated + ), + systemMetrics: metrics, + backendCapacity: capacity + ) + + guard let data = try? ProviderProtocolCodec.encodeProviderMessage(message), + let json = String(data: data, encoding: .utf8) else { + return "{\"type\":\"heartbeat\",\"status\":\"idle\",\"stats\":{\"requests_served\":0,\"tokens_generated\":0},\"system_metrics\":{\"memory_pressure\":0,\"cpu_usage\":0,\"thermal_state\":\"nominal\"}}" + } + return json + } + + // MARK: - Outbound Encoding + + private func encodeOutbound(_ msg: OutboundMessage) -> String { + (try? CoordinatorClientCodec.encodeOutboundMessageString(msg)) ?? "{}" + } + + private func encodeInferenceError(requestId: String, error: String, statusCode: UInt16) -> String { + let message = ProviderMessage.inferenceError(ProviderMessage.InferenceError( + requestId: requestId, + error: error, + statusCode: statusCode + )) + guard let data = try? ProviderProtocolCodec.encodeProviderMessage(message), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json + } + + // MARK: - Telemetry + + /// Matches the Rust telemetry gate: emit at counts 3, 10, then every 30. + private func shouldEmitReconnectTelemetry(count: UInt64) -> Bool { + count == 3 || count == 10 || count % 30 == 0 + } + + private func emitReconnectTelemetry(count: UInt64, error: Error) { + // Telemetry integration point -- when the Telemetry module is ported, + // this will emit a TelemetryEvent with source=provider, severity=warn, + // kind=connectivity, and the reconnect_count/last_error/ws_state fields. + logger.warning("Reconnect telemetry: count=\(count) error=\(error.localizedDescription)") + } + + // MARK: - Helpers + + private func decodeEphemeralKey(_ base64Key: String) -> [UInt8]? { + guard let data = Data(base64Encoded: base64Key), data.count == 32 else { + return nil + } + return [UInt8](data) + } +} + +// MARK: - Errors + +public enum CoordinatorError: Error, CustomStringConvertible { + case invalidURL(String) + case encodingFailed + case pongTimeout + case connectionClosed(Error) + + public var description: String { + switch self { + case .invalidURL(let url): return "Invalid coordinator URL: \(url)" + case .encodingFailed: return "Failed to encode message" + case .pongTimeout: return "WebSocket pong timeout (no response in 30s)" + case .connectionClosed(let err): return "WebSocket connection closed: \(err.localizedDescription)" + } + } +} + +// MARK: - Security Checks Namespace + +/// Stub namespace for security checks. The Security module will provide +/// real implementations; these stubs ensure the coordinator client compiles +/// and runs independently. +enum SecurityChecks { + static func isSIPEnabled() -> Bool { + SIPStatusChecker().isFullyEnabled() + } + + static func isHypervisorActive() -> Bool { + false + } +} + +// MARK: - Logger (os.Logger on macOS, stderr fallback) + +#if canImport(os) +private typealias Logger = os.Logger +#else +private struct Logger { + let subsystem: String + let category: String + + func info(_ msg: String) { print("[\(category)] INFO: \(msg)") } + func warning(_ msg: String) { print("[\(category)] WARN: \(msg)") } + func error(_ msg: String) { print("[\(category)] ERROR: \(msg)") } +} +#endif diff --git a/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClientCodec.swift b/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClientCodec.swift new file mode 100644 index 00000000..f2bd6d59 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Coordinator/CoordinatorClientCodec.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Testable message construction for the URLSessionWebSocketTask coordinator +/// client. The client owns transport/reconnect concerns; this type owns the +/// wire messages it sends and receives. +public enum CoordinatorClientCodec { + public static func registrationMessage( + from config: CoordinatorClientConfig, + version: String = ProviderCore.version, + privacyCapabilities: PrivacyCapabilities? = nil + ) -> ProviderMessage { + .register(ProviderMessage.Register( + hardware: config.hardware, + models: config.models, + backend: config.backendName, + version: version, + publicKey: config.publicKey, + encryptedResponseChunks: true, + walletAddress: config.walletAddress, + attestation: config.attestation, + authToken: config.authToken, + pythonHash: config.runtimeHashes?.pythonHash, + runtimeHash: config.runtimeHashes?.runtimeHash, + templateHashes: config.runtimeHashes?.templateHashes ?? [:], + privacyCapabilities: privacyCapabilities + )) + } + + public static func encodeRegistration( + from config: CoordinatorClientConfig, + version: String = ProviderCore.version, + privacyCapabilities: PrivacyCapabilities? = nil + ) throws -> Data { + try ProviderProtocolCodec.encodeProviderMessage( + registrationMessage( + from: config, + version: version, + privacyCapabilities: privacyCapabilities + ) + ) + } + + public static func heartbeatMessage( + status: ProviderStatus, + activeModel: String?, + warmModels: [String], + stats: ProviderStats, + systemMetrics: SystemMetrics, + backendCapacity: BackendCapacity? + ) -> ProviderMessage { + .heartbeat(ProviderMessage.Heartbeat( + status: status, + activeModel: activeModel, + warmModels: warmModels, + stats: stats, + systemMetrics: systemMetrics, + backendCapacity: backendCapacity + )) + } + + public static func providerMessage(for outbound: OutboundMessage) -> ProviderMessage { + switch outbound { + case .inferenceAccepted(let requestId): + return .inferenceAccepted(ProviderMessage.InferenceAccepted(requestId: requestId)) + + case .inferenceChunk(let requestId, let data, let encryptedData): + return .inferenceResponseChunk(ProviderMessage.InferenceResponseChunk( + requestId: requestId, + data: data, + encryptedData: encryptedData + )) + + case .inferenceComplete(let requestId, let usage, let seSignature, let responseHash): + return .inferenceComplete(ProviderMessage.InferenceComplete( + requestId: requestId, + usage: usage, + seSignature: seSignature, + responseHash: responseHash + )) + + case .inferenceError(let requestId, let error, let statusCode): + return .inferenceError(ProviderMessage.InferenceError( + requestId: requestId, + error: error, + statusCode: statusCode + )) + + case .attestationResponse(let payload): + return .attestationResponse(ProviderMessage.AttestationResponse( + nonce: payload.nonce, + signature: payload.signature, + statusSignature: payload.statusSignature, + publicKey: payload.publicKey, + hypervisorActive: payload.hypervisorActive, + rdmaDisabled: payload.rdmaDisabled, + sipEnabled: payload.sipEnabled, + secureBootEnabled: payload.secureBootEnabled, + binaryHash: payload.binaryHash, + activeModelHash: payload.activeModelHash, + pythonHash: payload.pythonHash, + runtimeHash: payload.runtimeHash, + templateHashes: payload.templateHashes, + modelHashes: payload.modelHashes + )) + } + } + + public static func encodeOutboundMessage(_ outbound: OutboundMessage) throws -> Data { + try ProviderProtocolCodec.encodeProviderMessage(providerMessage(for: outbound)) + } + + public static func encodeOutboundMessageString(_ outbound: OutboundMessage) throws -> String { + try ProviderProtocolCodec.encodeProviderMessageString(providerMessage(for: outbound)) + } + + public static func decodeIncomingMessage(from data: Data) throws -> CoordinatorMessage { + try ProviderProtocolCodec.decodeCoordinatorMessage(from: data) + } + + public static func decodeIncomingMessage(from string: String) throws -> CoordinatorMessage { + try ProviderProtocolCodec.decodeCoordinatorMessage(from: string) + } +} diff --git a/provider-swift/Sources/ProviderCore/Coordinator/ExponentialBackoff.swift b/provider-swift/Sources/ProviderCore/Coordinator/ExponentialBackoff.swift new file mode 100644 index 00000000..86e0b167 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Coordinator/ExponentialBackoff.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct ExponentialBackoff: Sendable { + private var current: TimeInterval + private let max: TimeInterval + + public init(base: TimeInterval = 1.0, max: TimeInterval = 30.0) { + self.current = base + self.max = max + } + + public mutating func nextDelay() -> TimeInterval { + let delay = current + current = Swift.min(current * 2, max) + return delay + } + + public mutating func reset() { + current = 1.0 + } +} diff --git a/provider-swift/Sources/ProviderCore/Crypto/NodeKeyPair.swift b/provider-swift/Sources/ProviderCore/Crypto/NodeKeyPair.swift new file mode 100644 index 00000000..6bd93359 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Crypto/NodeKeyPair.swift @@ -0,0 +1,218 @@ +import CryptoKit +import Foundation +import Sodium + +// MARK: - Errors + +public enum CryptoError: Error, CustomStringConvertible, Sendable { + case ciphertextTooShort(got: Int, minimum: Int) + case decryptionFailed + case encryptionFailed + case invalidPublicKeyLength(got: Int) + case keyFileCorrupted(path: String, got: Int) + + public var description: String { + switch self { + case .ciphertextTooShort(let got, let minimum): + "ciphertext too short: expected at least \(minimum) bytes for nonce, got \(got)" + case .decryptionFailed: + "decryption failed: authentication tag verification failed" + case .encryptionFailed: + "encryption failed" + case .invalidPublicKeyLength(let got): + "invalid public key length: expected 32, got \(got)" + case .keyFileCorrupted(let path, let got): + "key file corrupted at \(path): expected 32 bytes, got \(got)" + } + } +} + +// MARK: - NodeKeyPair + +/// Shared libsodium instance. Sodium is a value type whose methods are all +/// stateless wrappers around C libsodium functions (thread-safe by design). +private nonisolated(unsafe) let sodium = Sodium() + +/// NaCl nonce size (24 bytes for XSalsa20-Poly1305). +private let naclNonceSize = 24 + +/// Ephemeral X25519 key pair for E2E encryption. +/// +/// The secret exists only in this process's memory, protected by Hardened +/// Runtime + SIP. The attestation blob binds the public key to the Secure +/// Enclave signing identity. +/// +/// Wire format (NaCl `crypto_box`): +/// ciphertext = nonce (24 bytes) || Poly1305 tag (16 bytes) || encrypted_data +/// +/// The underlying primitive is XSalsa20-Poly1305 authenticated encryption +/// with an X25519 Diffie-Hellman shared secret, matching Go's +/// `golang.org/x/crypto/nacl/box` and the Rust `crypto_box` crate (v0.9). +/// Implemented via libsodium (swift-sodium). +public struct NodeKeyPair: Sendable { + /// Raw 32-byte X25519 secret key. + private let secretKeyBytes: Data + /// Raw 32-byte X25519 public key. + private let publicKeyData: Data + + // MARK: - Initialization + + /// Generate a fresh ephemeral key pair using libsodium's CSPRNG. + public static func generate() -> NodeKeyPair { + let kp = sodium.box.keyPair()! + return NodeKeyPair( + secretKeyBytes: Data(kp.secretKey), + publicKeyData: Data(kp.publicKey) + ) + } + + /// Restore from a raw 32-byte secret key (e.g. loaded from disk). + /// + /// Derives the public key from the secret key using CryptoKit's + /// Curve25519 (same X25519 math as libsodium). + public init(rawSecret: Data) throws { + guard rawSecret.count == 32 else { + throw CryptoError.keyFileCorrupted(path: "", got: rawSecret.count) + } + let privKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: rawSecret) + self.secretKeyBytes = rawSecret + self.publicKeyData = Data(privKey.publicKey.rawRepresentation) + } + + private init(secretKeyBytes: Data, publicKeyData: Data) { + self.secretKeyBytes = secretKeyBytes + self.publicKeyData = publicKeyData + } + + // MARK: - Public key accessors + + /// Base64-encoded public key (standard encoding, no padding stripping). + public var publicKeyBase64: String { + publicKeyData.base64EncodedString() + } + + /// Raw 32-byte public key. + public var publicKeyBytes: Data { + publicKeyData + } + + // MARK: - Decrypt + + /// Decrypt a NaCl box message. + /// + /// - Parameters: + /// - senderPublicKey: The sender's 32-byte X25519 public key. + /// - ciphertext: `nonce (24 bytes) || authenticated_ciphertext`. The authenticated + /// ciphertext includes the 16-byte Poly1305 authentication tag prepended by XSalsa20-Poly1305. + /// - Returns: The decrypted plaintext. + public func decrypt(senderPublicKey: Data, ciphertext: Data) throws -> Data { + guard ciphertext.count >= naclNonceSize else { + throw CryptoError.ciphertextTooShort(got: ciphertext.count, minimum: naclNonceSize) + } + guard senderPublicKey.count == 32 else { + throw CryptoError.invalidPublicKeyLength(got: senderPublicKey.count) + } + + // swift-sodium's open(nonceAndAuthenticatedCipherText:...) expects + // the combined format: nonce (24) || tag (16) || encrypted_data, + // which is exactly the wire format we receive. + guard let plaintext = sodium.box.open( + nonceAndAuthenticatedCipherText: Bytes(ciphertext), + senderPublicKey: Bytes(senderPublicKey), + recipientSecretKey: Bytes(secretKeyBytes) + ) else { + throw CryptoError.decryptionFailed + } + + return Data(plaintext) + } + + // MARK: - Encrypt + + /// Encrypt a plaintext using NaCl box. + /// + /// - Parameters: + /// - recipientPublicKey: The recipient's 32-byte X25519 public key. + /// - plaintext: The data to encrypt. + /// - Returns: `nonce (24 bytes) || authenticated_ciphertext` where the + /// authenticated ciphertext includes the 16-byte Poly1305 tag. + public func encrypt(recipientPublicKey: Data, plaintext: Data) throws -> Data { + guard recipientPublicKey.count == 32 else { + throw CryptoError.invalidPublicKeyLength(got: recipientPublicKey.count) + } + + // swift-sodium's seal() generates a random nonce and prepends it + // to the authenticated ciphertext, producing the combined format: + // nonce (24) || tag (16) || encrypted_data. + // Use the Bytes? overload (combined format), not the tuple overload. + guard let sealed: Bytes = sodium.box.seal( + message: Bytes(plaintext), + recipientPublicKey: Bytes(recipientPublicKey), + senderSecretKey: Bytes(secretKeyBytes) + ) else { + throw CryptoError.encryptionFailed + } + + return Data(sealed) + } + + // MARK: - EncryptedPayload helpers + + /// Decrypt an `EncryptedPayload` (the wire type used in WebSocket messages). + public func decryptPayload(_ payload: EncryptedPayload) throws -> Data { + guard let ephemeralKeyData = Data(base64Encoded: payload.ephemeralPublicKey), + ephemeralKeyData.count == 32 else { + throw CryptoError.invalidPublicKeyLength( + got: Data(base64Encoded: payload.ephemeralPublicKey)?.count ?? 0 + ) + } + guard let ciphertextData = Data(base64Encoded: payload.ciphertext) else { + throw CryptoError.ciphertextTooShort(got: 0, minimum: naclNonceSize) + } + return try decrypt(senderPublicKey: ephemeralKeyData, ciphertext: ciphertextData) + } + + /// Encrypt data into an `EncryptedPayload` for sending over the wire. + /// Uses this key pair's public key as the ephemeral key in the payload. + public func encryptPayload(recipientPublicKey: Data, plaintext: Data) throws -> EncryptedPayload { + let ciphertext = try encrypt(recipientPublicKey: recipientPublicKey, plaintext: plaintext) + return EncryptedPayload( + ephemeralPublicKey: publicKeyBase64, + ciphertext: ciphertext.base64EncodedString() + ) + } + + // MARK: - Legacy Cleanup + + private static let legacyDirNames = [".darkbloom", ".dginf", ".eigeninference"] + + /// Paths where legacy `node_key` files may exist. + public static var legacyNodeKeyPaths: [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + return legacyDirNames.map { home.appendingPathComponent($0).appendingPathComponent("node_key") } + } + + /// Paths where legacy `enclave_e2e_ka.data` files may exist. + public static var legacyEnclaveKeyPaths: [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + return legacyDirNames.map { + home.appendingPathComponent($0).appendingPathComponent("enclave_e2e_ka.data") + } + } + + /// Remove legacy E2E secret files from all known directories. + public static func purgeLegacyFiles() { + let paths = legacyNodeKeyPaths + legacyEnclaveKeyPaths + for path in paths where FileManager.default.fileExists(atPath: path.path) { + try? FileManager.default.removeItem(at: path) + } + } +} + +// MARK: - CustomDebugStringConvertible + +extension NodeKeyPair: CustomDebugStringConvertible { + public var debugDescription: String { + "NodeKeyPair(public: \(publicKeyBase64), secret: [REDACTED])" + } +} diff --git a/provider-swift/Sources/ProviderCore/Crypto/X25519ChaChaPoly.swift b/provider-swift/Sources/ProviderCore/Crypto/X25519ChaChaPoly.swift new file mode 100644 index 00000000..96fcaca6 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Crypto/X25519ChaChaPoly.swift @@ -0,0 +1,148 @@ +import CryptoKit +import Foundation + +public enum X25519ChaChaPolyError: Error, Equatable, Sendable { + case invalidPrivateKeyLength(Int) + case invalidPublicKeyLength(Int) + case invalidNonceLength(Int) +} + +public struct X25519KeyAgreementKeyPair: Sendable { + private let privateKey: Curve25519.KeyAgreement.PrivateKey + + public init() { + self.privateKey = Curve25519.KeyAgreement.PrivateKey() + } + + public init(rawPrivateKey: Data) throws { + guard rawPrivateKey.count == 32 else { + throw X25519ChaChaPolyError.invalidPrivateKeyLength(rawPrivateKey.count) + } + self.privateKey = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: rawPrivateKey) + } + + public var publicKey: Data { + Data(privateKey.publicKey.rawRepresentation) + } + + public var rawPrivateKey: Data { + Data(privateKey.rawRepresentation) + } + + public func symmetricKey( + peerPublicKey: Data, + salt: Data = Data(), + sharedInfo: Data = Data("darkbloom-provider-x25519-chachapoly-v1".utf8) + ) throws -> SymmetricKey { + guard peerPublicKey.count == 32 else { + throw X25519ChaChaPolyError.invalidPublicKeyLength(peerPublicKey.count) + } + + let peer = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublicKey) + let secret = try privateKey.sharedSecretFromKeyAgreement(with: peer) + return secret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: salt, + sharedInfo: sharedInfo, + outputByteCount: 32 + ) + } +} + +public struct X25519ChaChaPolySealedMessage: Equatable, Sendable { + public static let nonceSize = 12 + + public let senderPublicKey: Data + public let nonce: Data + public let ciphertext: Data + public let tag: Data + + public init(senderPublicKey: Data, nonce: Data, ciphertext: Data, tag: Data) throws { + guard senderPublicKey.count == 32 else { + throw X25519ChaChaPolyError.invalidPublicKeyLength(senderPublicKey.count) + } + guard nonce.count == Self.nonceSize else { + throw X25519ChaChaPolyError.invalidNonceLength(nonce.count) + } + self.senderPublicKey = senderPublicKey + self.nonce = nonce + self.ciphertext = ciphertext + self.tag = tag + } + + public var combinedCiphertext: Data { + var data = Data(capacity: nonce.count + ciphertext.count + tag.count) + data.append(nonce) + data.append(ciphertext) + data.append(tag) + return data + } +} + +public struct X25519ChaChaPoly: Sendable { + public let salt: Data + public let sharedInfo: Data + + public init( + salt: Data = Data(), + sharedInfo: Data = Data("darkbloom-provider-x25519-chachapoly-v1".utf8) + ) { + self.salt = salt + self.sharedInfo = sharedInfo + } + + public func seal( + plaintext: Data, + recipientPublicKey: Data, + senderKeyPair: X25519KeyAgreementKeyPair = X25519KeyAgreementKeyPair(), + nonce: Data? = nil, + authenticatedData: Data = Data() + ) throws -> X25519ChaChaPolySealedMessage { + let key = try senderKeyPair.symmetricKey( + peerPublicKey: recipientPublicKey, + salt: salt, + sharedInfo: sharedInfo + ) + let cryptoNonce = try makeNonce(nonce) + let sealed = try ChaChaPoly.seal( + plaintext, + using: key, + nonce: cryptoNonce, + authenticating: authenticatedData + ) + return try X25519ChaChaPolySealedMessage( + senderPublicKey: senderKeyPair.publicKey, + nonce: Data(sealed.nonce), + ciphertext: sealed.ciphertext, + tag: sealed.tag + ) + } + + public func open( + _ sealed: X25519ChaChaPolySealedMessage, + recipientKeyPair: X25519KeyAgreementKeyPair, + authenticatedData: Data = Data() + ) throws -> Data { + let key = try recipientKeyPair.symmetricKey( + peerPublicKey: sealed.senderPublicKey, + salt: salt, + sharedInfo: sharedInfo + ) + let box = try ChaChaPoly.SealedBox( + nonce: ChaChaPoly.Nonce(data: sealed.nonce), + ciphertext: sealed.ciphertext, + tag: sealed.tag + ) + return try ChaChaPoly.open(box, using: key, authenticating: authenticatedData) + } + + private func makeNonce(_ data: Data?) throws -> ChaChaPoly.Nonce { + guard let data else { + return ChaChaPoly.Nonce() + } + guard data.count == X25519ChaChaPolySealedMessage.nonceSize else { + throw X25519ChaChaPolyError.invalidNonceLength(data.count) + } + return try ChaChaPoly.Nonce(data: data) + } +} diff --git a/provider-swift/Sources/ProviderCore/Hardware/HardwareDetector.swift b/provider-swift/Sources/ProviderCore/Hardware/HardwareDetector.swift new file mode 100644 index 00000000..88d72716 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Hardware/HardwareDetector.swift @@ -0,0 +1,229 @@ +import Foundation +import Darwin + +extension HardwareInfo: CustomStringConvertible { + public var description: String { + """ + Hardware Info: + Machine: \(machineModel) + Chip: \(chipName) + Family: \(chipFamily.rawValue) \(chipTier.rawValue) + Memory: \(memoryGb) GB total + Available: \(memoryAvailableGb) GB (for inference) + CPU: \(cpuCores.total) cores (\(cpuCores.performance) P + \(cpuCores.efficiency) E) + GPU: \(gpuCores) cores + Bandwidth: \(memoryBandwidthGbs) GB/s + """ + } +} + +// MARK: - Detection + +private let osMemoryReserveGB: UInt64 = 4 + +public enum HardwareDetector: Sendable { + + public static func detect() throws -> HardwareInfo { + let machineModel = try sysctlString("hw.model") + let memoryBytes = try sysctlUInt64("hw.memsize") + let memoryGb = memoryBytes / (1024 * 1024 * 1024) + + let cpuTotal = try sysctlUInt32("hw.ncpu") + let cpuPerf = sysctlUInt32Optional("hw.perflevel0.logicalcpu") ?? cpuTotal + let cpuEff = sysctlUInt32Optional("hw.perflevel1.logicalcpu") ?? 0 + + let (chipName, gpuCores) = try detectGPUInfo() + let (chipFamily, chipTier) = parseChipIdentity(chipName) + let memoryBandwidthGbs = lookupBandwidth( + family: chipFamily, tier: chipTier, gpuCores: gpuCores + ) + let memoryAvailableGb = memoryGb > osMemoryReserveGB + ? memoryGb - osMemoryReserveGB + : 0 + + return HardwareInfo( + machineModel: machineModel, + chipName: chipName, + chipFamily: chipFamily, + chipTier: chipTier, + memoryGb: memoryGb, + memoryAvailableGb: memoryAvailableGb, + cpuCores: CpuCores( + total: cpuTotal, + performance: cpuPerf, + efficiency: cpuEff + ), + gpuCores: gpuCores, + memoryBandwidthGbs: memoryBandwidthGbs + ) + } + + public static func totalMemoryGB() -> UInt64 { + (try? sysctlUInt64("hw.memsize")).map { $0 / (1024 * 1024 * 1024) } ?? 16 + } +} + +// MARK: - sysctl Helpers + +private func sysctlString(_ key: String) throws -> String { + var size: Int = 0 + guard sysctlbyname(key, nil, &size, nil, 0) == 0, size > 0 else { + throw HardwareError.sysctlFailed(key) + } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(key, &buffer, &size, nil, 0) == 0 else { + throw HardwareError.sysctlFailed(key) + } + return String(cString: buffer) +} + +private func sysctlUInt64(_ key: String) throws -> UInt64 { + var value: UInt64 = 0 + var size = MemoryLayout.size + guard sysctlbyname(key, &value, &size, nil, 0) == 0 else { + throw HardwareError.sysctlFailed(key) + } + return value +} + +private func sysctlUInt32(_ key: String) throws -> UInt32 { + var value: UInt32 = 0 + var size = MemoryLayout.size + guard sysctlbyname(key, &value, &size, nil, 0) == 0 else { + throw HardwareError.sysctlFailed(key) + } + return value +} + +private func sysctlUInt32Optional(_ key: String) -> UInt32? { + var value: UInt32 = 0 + var size = MemoryLayout.size + guard sysctlbyname(key, &value, &size, nil, 0) == 0 else { return nil } + return value +} + +// MARK: - GPU Detection + +private func detectGPUInfo() throws -> (chipName: String, gpuCores: UInt32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler") + process.arguments = ["SPDisplaysDataType", "-json"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return (fallbackChipName(), 0) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let displays = json["SPDisplaysDataType"] as? [[String: Any]] + else { + return (fallbackChipName(), 0) + } + + for display in displays { + guard let chipName = display["sppci_model"] as? String, !chipName.isEmpty else { + continue + } + + let gpuCores: UInt32 = + (display["sppci_cores"] as? String).flatMap { UInt32($0) } + ?? (display["sppci_gpu_core_count"] as? String).flatMap { UInt32($0) } + ?? 0 + + return (chipName, gpuCores) + } + + return (fallbackChipName(), 0) +} + +private func fallbackChipName() -> String { + (try? sysctlString("machdep.cpu.brand_string")) ?? "Unknown Apple Silicon" +} + +// MARK: - Chip Identity Parsing + +internal func parseChipIdentity(_ chipName: String) -> (ChipFamily, ChipTier) { + let name = chipName.lowercased() + + let family: ChipFamily + if name.contains("m5") { + family = .m5 + } else if name.contains("m4") { + family = .m4 + } else if name.contains("m3") { + family = .m3 + } else if name.contains("m2") { + family = .m2 + } else if name.contains("m1") { + family = .m1 + } else { + family = .unknown + } + + let tier: ChipTier + if name.contains("ultra") { + tier = .ultra + } else if name.contains("max") { + tier = .max + } else if name.contains("pro") { + tier = .pro + } else if family != .unknown { + tier = .base + } else { + tier = .unknown + } + + return (family, tier) +} + +// MARK: - Bandwidth Lookup + +internal func lookupBandwidth(family: ChipFamily, tier: ChipTier, gpuCores: UInt32) -> UInt32 { + switch (family, tier) { + case (.m1, .base): return 68 + case (.m1, .pro): return 200 + case (.m1, .max): return 400 + case (.m1, .ultra): return 800 + + case (.m2, .base): return 100 + case (.m2, .pro): return 200 + case (.m2, .max): return 400 + case (.m2, .ultra): return 800 + + case (.m3, .base): return 100 + case (.m3, .pro): return 150 + case (.m3, .max): return gpuCores >= 40 ? 400 : 300 + case (.m3, .ultra): return 819 + + case (.m4, .base): return 120 + case (.m4, .pro): return 273 + case (.m4, .max): return gpuCores >= 40 ? 546 : 410 + + case (.m5, .base): return 153 + case (.m5, .pro): return 307 + case (.m5, .max): return gpuCores >= 40 ? 614 : 460 + + default: return 100 + } +} + +// MARK: - Errors + +public enum HardwareError: Error, CustomStringConvertible { + case sysctlFailed(String) + + public var description: String { + switch self { + case .sysctlFailed(let key): return "sysctl query failed for '\(key)'" + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Hardware/SystemMetrics.swift b/provider-swift/Sources/ProviderCore/Hardware/SystemMetrics.swift new file mode 100644 index 00000000..49d1d9ad --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Hardware/SystemMetrics.swift @@ -0,0 +1,72 @@ +import Foundation +import Darwin + +public enum SystemMetricsCollector: Sendable { + + public static func collect(cpuCores: UInt32) -> SystemMetrics { + SystemMetrics( + memoryPressure: collectMemoryPressure() ?? 0.0, + cpuUsage: collectCPUUsage(cpuCores: cpuCores) ?? 0.0, + thermalState: mapThermalState(ProcessInfo.processInfo.thermalState) + ) + } +} + +// MARK: - Thermal State Mapping + +private func mapThermalState(_ state: ProcessInfo.ThermalState) -> ThermalState { + switch state { + case .nominal: return .nominal + case .fair: return .fair + case .serious: return .serious + case .critical: return .critical + @unknown default: return .nominal + } +} + +// MARK: - Memory Pressure + +// pressure = (active + wired + compressed) / (active + wired + compressed + inactive + speculative + free) +private func collectMemoryPressure() -> Double? { + var stats = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size + ) + + let result = withUnsafeMutablePointer(to: &stats) { ptr in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in + host_statistics64( + mach_host_self(), + HOST_VM_INFO64, + intPtr, + &count + ) + } + } + + guard result == KERN_SUCCESS else { return nil } + + let active = UInt64(stats.active_count) + let wired = UInt64(stats.wire_count) + let compressed = UInt64(stats.compressor_page_count) + let inactive = UInt64(stats.inactive_count) + let speculative = UInt64(stats.speculative_count) + let free = UInt64(stats.free_count) + + let used = active + wired + compressed + let total = used + inactive + speculative + free + + guard total > 0 else { return 0.0 } + return min(max(Double(used) / Double(total), 0.0), 1.0) +} + +// MARK: - CPU Usage + +// 1-minute load average normalized by core count. +private func collectCPUUsage(cpuCores: UInt32) -> Double? { + var loadavg = [Double](repeating: 0.0, count: 3) + guard getloadavg(&loadavg, 3) != -1 else { return nil } + + let cores = cpuCores > 0 ? Double(cpuCores) : 1.0 + return min(max(loadavg[0] / cores, 0.0), 1.0) +} diff --git a/provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift b/provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift new file mode 100644 index 00000000..d86ca477 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift @@ -0,0 +1,491 @@ +import Foundation +import MLX +import MLXLLM +import MLXLMCommon + +// MARK: - Public Types + +/// Events emitted by the scheduler for a single inference request. +public enum GenerationEvent: Sendable { + /// A decoded text chunk (one or more tokens worth of text). + case chunk(String) + /// Final usage and performance statistics for the completed request. + case info(promptTokens: Int, completionTokens: Int, tokensPerSecond: Double) + /// An unrecoverable error that terminated this request. + case error(String) +} + +/// Snapshot of the scheduler's capacity, reported to the coordinator in heartbeats. +public struct SchedulerCapacity: Sendable { + /// Model currently loaded (empty string if none). + public let model: String + /// Number of requests actively generating tokens. + public let activeRequests: Int + /// Number of requests queued waiting for a slot. + public let pendingRequests: Int + /// Maximum concurrent requests the scheduler will admit. + public let maxConcurrent: Int + /// GPU active memory in bytes. + public let gpuMemoryActiveBytes: Int + /// GPU peak memory observed in bytes. + public let gpuMemoryPeakBytes: Int + /// GPU cache memory in bytes. + public let gpuMemoryCacheBytes: Int + /// Total unified memory available in bytes. + public let totalMemoryBytes: UInt64 +} + +// MARK: - Internal State + +/// Represents a single in-flight inference request inside the scheduler. +private struct ActiveRequest: Sendable { + let id: String + let task: Task + let submittedAt: ContinuousClock.Instant +} + +/// Holds everything needed to start generating for a queued request. +/// The continuation is used to yield events into the caller's AsyncStream. +private struct PendingRequest: Sendable { + let id: String + let request: ChatCompletionRequest + let continuation: AsyncStream.Continuation + let submittedAt: ContinuousClock.Instant +} + +// MARK: - BatchScheduler + +/// Continuous batching scheduler for concurrent inference on a single ModelContainer. +/// +/// The ModelContainer serializes access via an internal actor, processing one +/// call to `perform` / `generate` at a time. The key insight from mlx-swift-lm +/// is that during decode the model weights are read-only and each request has +/// its own KVCache. The `ModelContainer.generate` method only holds the serial +/// lock during prefill; after that the returned AsyncStream runs concurrently. +/// +/// The scheduler exploits this by: +/// 1. Admitting at most `maxConcurrentRequests` requests at once. +/// 2. Serializing prefill through the ModelContainer's own lock (one at a time). +/// 3. Letting decode streams run concurrently once prefill completes. +/// 4. Using WiredMemoryPolicy for admission control when the GPU is near capacity. +/// 5. Providing cancel(requestId:) for immediate request termination. +/// +/// This design matches the standard vLLM approach: at most one prefill runs at +/// a time while all active decodes overlap on the GPU. +public actor BatchScheduler { + + // MARK: - Configuration + + /// Maximum number of requests generating concurrently. + /// Each active request holds a KV cache in GPU memory. + private let maxConcurrentRequests: Int + + /// Maximum time a request may sit in the pending queue before being rejected. + private let pendingTimeout: Duration + + /// Maximum tokens any single request can generate. + private let defaultMaxTokens: Int + + // MARK: - Model State + + /// The loaded model container. Nil until a model is loaded. + private var modelContainer: ModelContainer? + + /// The model identifier string (e.g. "mlx-community/Qwen2.5-7B-4bit"). + private var modelId: String = "" + + /// Weight bytes measured at load time, used for memory budgeting. + private var modelWeightBytes: Int = 0 + + // MARK: - Request Tracking + + /// Requests currently generating tokens. Keyed by request ID. + private var activeRequests: [String: ActiveRequest] = [:] + + /// Requests waiting for a slot. Oldest first. + private var pendingQueue: [PendingRequest] = [] + + /// Monotonic counter for generating unique request IDs when the caller + /// does not provide one. + private var requestCounter: UInt64 = 0 + + // MARK: - Lifecycle + + /// Creates a new scheduler. + /// + /// - Parameters: + /// - maxConcurrentRequests: Maximum number of requests that can generate at + /// the same time. Each request holds its own KVCache in GPU memory, so this + /// directly controls peak memory usage. Defaults to 4. + /// - pendingTimeout: How long a request can wait in the queue before being + /// rejected with an error event. Defaults to 120 seconds (matching the + /// coordinator's queue timeout). + /// - defaultMaxTokens: The maximum number of tokens to generate when the + /// request does not specify one. Defaults to 4096. + public init( + maxConcurrentRequests: Int = 4, + pendingTimeout: Duration = .seconds(120), + defaultMaxTokens: Int = 4096 + ) { + self.maxConcurrentRequests = max(1, maxConcurrentRequests) + self.pendingTimeout = pendingTimeout + self.defaultMaxTokens = defaultMaxTokens + } + + // MARK: - Model Management + + /// Load a model into the scheduler. Replaces any previously loaded model. + /// + /// All active and pending requests are cancelled before the swap. + /// + /// - Parameters: + /// - container: The ModelContainer to use for inference. + /// - modelId: Human-readable identifier for the model. + public func loadModel(container: ModelContainer, modelId: String) async { + // Cancel everything in-flight before swapping the model. + cancelAll() + self.modelContainer = container + self.modelId = modelId + + // Measure weight bytes for memory budgeting. + let bytes: Int = await container.perform { context in + context.model.parameters().flattened().reduce(0) { $0 + $1.1.nbytes } + } + self.modelWeightBytes = bytes + } + + /// Unload the current model and cancel all requests. + public func unloadModel() { + cancelAll() + modelContainer = nil + modelId = "" + modelWeightBytes = 0 + } + + // MARK: - Request Submission + + /// Submit a chat completion request for inference. + /// + /// Returns an `AsyncStream` that the caller consumes to + /// receive text chunks, and eventually a `.info` or `.error` event. + /// + /// If the scheduler is at capacity the request is queued. If it remains + /// queued past `pendingTimeout` it receives an `.error` event. + /// + /// - Parameters: + /// - request: The chat completion request. + /// - requestId: Optional caller-supplied ID. One is generated if nil. + /// - Returns: Stream of generation events for this request. + public func submit( + request: ChatCompletionRequest, + requestId: String? = nil + ) -> AsyncStream { + let id = requestId ?? nextRequestId() + + let (stream, continuation) = AsyncStream.makeStream() + + // If no model is loaded, fail immediately. + guard modelContainer != nil else { + continuation.yield(.error("No model loaded")) + continuation.finish() + return stream + } + + let pending = PendingRequest( + id: id, + request: request, + continuation: continuation, + submittedAt: .now + ) + + if activeRequests.count < maxConcurrentRequests { + // Slot available -- start immediately. + startRequest(pending) + } else { + // Queue and wait. + pendingQueue.append(pending) + } + + // When the consumer drops the stream, cancel the request. + continuation.onTermination = { @Sendable [weak self] termination in + if case .cancelled = termination { + Task { [weak self] in + await self?.cancel(requestId: id) + } + } + } + + return stream + } + + /// Cancel a specific request by ID. + /// + /// If the request is active, its generation task is cancelled and the + /// stream is finished. If it is pending, it is removed from the queue. + public func cancel(requestId: String) { + // Check active requests. + if let active = activeRequests.removeValue(forKey: requestId) { + active.task.cancel() + drainPendingQueue() + return + } + + // Check pending queue. + if let idx = pendingQueue.firstIndex(where: { $0.id == requestId }) { + let pending = pendingQueue.remove(at: idx) + pending.continuation.yield(.error("Request cancelled")) + pending.continuation.finish() + } + } + + /// Cancel all active and pending requests. Used during model swap and shutdown. + public func cancelAll() { + // Cancel all active tasks. + for (_, active) in activeRequests { + active.task.cancel() + } + activeRequests.removeAll() + + // Reject all pending requests. + for pending in pendingQueue { + pending.continuation.yield(.error("Scheduler shutting down")) + pending.continuation.finish() + } + pendingQueue.removeAll() + } + + // MARK: - Capacity Reporting + + /// Returns a snapshot of the scheduler's current capacity for heartbeat reporting. + public func capacity() -> SchedulerCapacity { + SchedulerCapacity( + model: modelId, + activeRequests: activeRequests.count, + pendingRequests: pendingQueue.count, + maxConcurrent: maxConcurrentRequests, + gpuMemoryActiveBytes: Memory.activeMemory, + gpuMemoryPeakBytes: Memory.peakMemory, + gpuMemoryCacheBytes: Memory.cacheMemory, + totalMemoryBytes: ProcessInfo.processInfo.physicalMemory + ) + } + + /// Convert the capacity snapshot to the protocol's BackendCapacity type + /// for wire transmission. + public func backendCapacity() -> BackendCapacity { + let cap = capacity() + let gbDivisor = 1024.0 * 1024.0 * 1024.0 + let slot = BackendSlotCapacity( + model: cap.model, + state: cap.activeRequests > 0 ? "running" : "idle", + numRunning: UInt32(cap.activeRequests), + numWaiting: UInt32(cap.pendingRequests), + activeTokens: 0, + maxTokensPotential: Int64(defaultMaxTokens * maxConcurrentRequests) + ) + return BackendCapacity( + slots: [slot], + gpuMemoryActiveGb: Double(cap.gpuMemoryActiveBytes) / gbDivisor, + gpuMemoryPeakGb: Double(cap.gpuMemoryPeakBytes) / gbDivisor, + gpuMemoryCacheGb: Double(cap.gpuMemoryCacheBytes) / gbDivisor, + totalMemoryGb: Double(cap.totalMemoryBytes) / gbDivisor + ) + } + + // MARK: - Internals + + /// Generate a unique request ID. + private func nextRequestId() -> String { + requestCounter += 1 + return "req-\(requestCounter)" + } + + /// Promote the oldest pending request into an active slot, if capacity allows. + private func drainPendingQueue() { + while activeRequests.count < maxConcurrentRequests, !pendingQueue.isEmpty { + let pending = pendingQueue.removeFirst() + + // Check for timeout while queued. + let elapsed = ContinuousClock.now - pending.submittedAt + if elapsed > pendingTimeout { + pending.continuation.yield( + .error("Request timed out after \(elapsed) in queue")) + pending.continuation.finish() + continue + } + + startRequest(pending) + } + } + + /// Start generating for a pending request. Moves it into the active set + /// and spawns the generation task. + private func startRequest(_ pending: PendingRequest) { + guard let container = modelContainer else { + pending.continuation.yield(.error("No model loaded")) + pending.continuation.finish() + return + } + + let requestId = pending.id + let request = pending.request + let continuation = pending.continuation + let maxTokens = request.max_tokens ?? defaultMaxTokens + + let task = Task { [weak self] in + await Self.runGeneration( + container: container, + request: request, + requestId: requestId, + maxTokens: maxTokens, + continuation: continuation + ) + + // When generation completes (success, error, or cancellation), + // remove from active set and promote next pending request. + await self?.requestCompleted(requestId: requestId) + } + + activeRequests[requestId] = ActiveRequest( + id: requestId, + task: task, + submittedAt: pending.submittedAt + ) + } + + /// Called when a generation task finishes. Removes it from the active set + /// and drains the pending queue. + private func requestCompleted(requestId: String) { + activeRequests.removeValue(forKey: requestId) + drainPendingQueue() + } + + /// The actual generation loop. This is a static method to avoid capturing + /// `self` (the actor) for the duration of generation -- the only callback + /// into the actor is `requestCompleted` at the end. + private static func runGeneration( + container: ModelContainer, + request: ChatCompletionRequest, + requestId: String, + maxTokens: Int, + continuation: AsyncStream.Continuation + ) async { + let generationStart = ContinuousClock.now + + do { + // 1. Build messages array for chat template. + let messages: [[String: any Sendable]] = request.messages.map { msg in + ["role": msg.role, "content": msg.content] + } + + // 2. Build GenerateParameters from the request. + let parameters = GenerateParameters( + maxTokens: maxTokens, + temperature: request.temperature ?? 0.6, + topP: request.top_p ?? 1.0, + topK: request.top_k ?? 0, + repetitionPenalty: request.repetition_penalty, + presencePenalty: request.presence_penalty, + frequencyPenalty: request.frequency_penalty + ) + + // 3. Build a UserInput from the messages and prepare it through + // the model's processor. This tokenizes the prompt using the + // chat template and handles any model-specific preprocessing. + let userInput = UserInput(messages: messages) + let prepared = try await container.prepare(input: userInput) + + // 4. Start generation via ModelContainer.generate(). + // This method acquires the serial lock ONLY during prefill + // (populating the KV cache). Once prefill completes, the lock + // is released and the returned AsyncStream runs the decode loop + // concurrently. This is the key to concurrent batching: multiple + // decode streams share the read-only model weights while each + // maintains its own KV cache. + let stream = try await container.generate( + input: prepared, + parameters: parameters + ) + + // 5. Consume the generation stream. + // The decode loop runs concurrently with other requests' decode + // loops since the model weights are read-only during decode. + var completionTokens = 0 + var promptTokens = 0 + + for await event in stream { + // Respect cancellation. + if Task.isCancelled { + break + } + + switch event { + case .chunk(let text): + continuation.yield(.chunk(text)) + + case .info(let info): + promptTokens = info.promptTokenCount + completionTokens = info.generationTokenCount + + case .toolCall: + // Tool calls are not supported yet in the provider. + break + } + } + + // 6. Compute performance metrics. + let elapsed = ContinuousClock.now - generationStart + let elapsedSeconds = Double(elapsed.components.seconds) + + Double(elapsed.components.attoseconds) / 1e18 + let tokensPerSecond = elapsedSeconds > 0 + ? Double(completionTokens) / elapsedSeconds + : 0 + + // 7. Emit final info event. + if !Task.isCancelled { + continuation.yield(.info( + promptTokens: promptTokens, + completionTokens: completionTokens, + tokensPerSecond: tokensPerSecond + )) + } + + } catch { + if !Task.isCancelled { + continuation.yield(.error("Generation failed: \(error.localizedDescription)")) + } + } + + continuation.finish() + } +} + +// MARK: - Memory Helpers + +/// Memory access helpers. These wrap MLX GPU memory queries that are available +/// on macOS 15+. +private enum Memory { + static var activeMemory: Int { + #if canImport(Metal) + MLX.GPU.activeMemory + #else + 0 + #endif + } + + static var peakMemory: Int { + #if canImport(Metal) + MLX.GPU.peakMemory + #else + 0 + #endif + } + + static var cacheMemory: Int { + #if canImport(Metal) + MLX.GPU.cacheMemory + #else + 0 + #endif + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/ChatPromptFormatting.swift b/provider-swift/Sources/ProviderCore/Inference/ChatPromptFormatting.swift new file mode 100644 index 00000000..063d0cf6 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/ChatPromptFormatting.swift @@ -0,0 +1,114 @@ +import Foundation + +public enum ChatPromptFormattingError: Error, Equatable, Sendable { + case emptyMessages + case unsupportedRole(String) + case emptyContent(role: String) +} + +public enum ChatPromptRole: String, Codable, CaseIterable, Sendable { + case system + case user + case assistant + case tool +} + +public struct FormattedChatMessage: Equatable, Sendable { + public let role: ChatPromptRole + public let content: String + + public init(role: ChatPromptRole, content: String) { + self.role = role + self.content = content + } + + public var rawMessage: [String: any Sendable] { + ["role": role.rawValue, "content": content] + } +} + +public struct ChatSamplingParameters: Equatable, Sendable { + public let maxTokens: Int? + public let temperature: Float? + public let topP: Float? + public let topK: Int? + public let repetitionPenalty: Float? + public let presencePenalty: Float? + public let frequencyPenalty: Float? + + public init( + maxTokens: Int? = nil, + temperature: Float? = nil, + topP: Float? = nil, + topK: Int? = nil, + repetitionPenalty: Float? = nil, + presencePenalty: Float? = nil, + frequencyPenalty: Float? = nil + ) { + self.maxTokens = maxTokens + self.temperature = temperature + self.topP = topP + self.topK = topK + self.repetitionPenalty = repetitionPenalty + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + } +} + +public struct FormattedChatPrompt: Equatable, Sendable { + public let model: String + public let messages: [FormattedChatMessage] + public let sampling: ChatSamplingParameters + public let stream: Bool + + public init( + model: String, + messages: [FormattedChatMessage], + sampling: ChatSamplingParameters, + stream: Bool + ) { + self.model = model + self.messages = messages + self.sampling = sampling + self.stream = stream + } + + public var rawMessages: [[String: any Sendable]] { + messages.map(\.rawMessage) + } +} + +public struct ChatPromptFormatter: Sendable { + public init() {} + + public func format(_ request: ChatCompletionRequest) throws -> FormattedChatPrompt { + guard !request.messages.isEmpty else { + throw ChatPromptFormattingError.emptyMessages + } + + let messages = try request.messages.map { message in + guard let role = ChatPromptRole(rawValue: message.role) else { + throw ChatPromptFormattingError.unsupportedRole(message.role) + } + guard !message.content.isEmpty else { + throw ChatPromptFormattingError.emptyContent(role: message.role) + } + return FormattedChatMessage(role: role, content: message.content) + } + + return FormattedChatPrompt( + model: request.model, + messages: messages, + sampling: ChatSamplingParameters( + maxTokens: request.max_tokens, + temperature: request.temperature, + topP: request.top_p, + topK: request.top_k, + repetitionPenalty: request.repetition_penalty, + presencePenalty: request.presence_penalty, + frequencyPenalty: request.frequency_penalty + ), + stream: request.stream ?? true + ) + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/ChatRequest.swift b/provider-swift/Sources/ProviderCore/Inference/ChatRequest.swift new file mode 100644 index 00000000..a30233e2 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/ChatRequest.swift @@ -0,0 +1,186 @@ +import Foundation + +// MARK: - Request Types + +public struct ChatCompletionRequest: Codable, Sendable { + public let model: String + public let messages: [ChatMessage] + public let temperature: Float? + public let top_p: Float? + public let top_k: Int? + public let max_tokens: Int? + public let repetition_penalty: Float? + public let presence_penalty: Float? + public let frequency_penalty: Float? + public let stream: Bool? + + public init( + model: String, + messages: [ChatMessage], + temperature: Float? = nil, + top_p: Float? = nil, + top_k: Int? = nil, + max_tokens: Int? = nil, + repetition_penalty: Float? = nil, + presence_penalty: Float? = nil, + frequency_penalty: Float? = nil, + stream: Bool? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.top_p = top_p + self.top_k = top_k + self.max_tokens = max_tokens + self.repetition_penalty = repetition_penalty + self.presence_penalty = presence_penalty + self.frequency_penalty = frequency_penalty + self.stream = stream + } +} + +public struct ChatMessage: Codable, Sendable { + public let role: String + public let content: String + + public init(role: String, content: String) { + self.role = role + self.content = content + } +} + +// MARK: - Response Types (Streaming) + +public struct ChatCompletionChunk: Codable, Sendable { + public let id: String + public let object: String + public let created: Int + public let model: String + public let choices: [ChunkChoice] + public let usage: ChunkUsage? + + public init( + id: String, + object: String = "chat.completion.chunk", + created: Int, + model: String, + choices: [ChunkChoice], + usage: ChunkUsage? = nil + ) { + self.id = id + self.object = object + self.created = created + self.model = model + self.choices = choices + self.usage = usage + } +} + +public struct ChunkChoice: Codable, Sendable { + public let index: Int + public let delta: ChunkDelta + public let finish_reason: String? + + public init(index: Int, delta: ChunkDelta, finish_reason: String? = nil) { + self.index = index + self.delta = delta + self.finish_reason = finish_reason + } +} + +public struct ChunkDelta: Codable, Sendable { + public let role: String? + public let content: String? + + public init(role: String? = nil, content: String? = nil) { + self.role = role + self.content = content + } +} + +public struct ChunkUsage: Codable, Sendable { + public let prompt_tokens: Int + public let completion_tokens: Int + public let total_tokens: Int + + public init(prompt_tokens: Int, completion_tokens: Int) { + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + self.total_tokens = prompt_tokens + completion_tokens + } +} + +// MARK: - Response Types (Non-Streaming) + +public struct ChatCompletionResponse: Codable, Sendable { + public let id: String + public let object: String + public let created: Int + public let model: String + public let choices: [ResponseChoice] + public let usage: ChunkUsage + + public init( + id: String, + object: String = "chat.completion", + created: Int, + model: String, + choices: [ResponseChoice], + usage: ChunkUsage + ) { + self.id = id + self.object = object + self.created = created + self.model = model + self.choices = choices + self.usage = usage + } +} + +public struct ResponseChoice: Codable, Sendable { + public let index: Int + public let message: ResponseMessage + public let finish_reason: String + + public init(index: Int, message: ResponseMessage, finish_reason: String) { + self.index = index + self.message = message + self.finish_reason = finish_reason + } +} + +public struct ResponseMessage: Codable, Sendable { + public let role: String + public let content: String + + public init(role: String = "assistant", content: String) { + self.role = role + self.content = content + } +} + +// MARK: - SSE Chunk Wrapper + +public struct SSEChunk: Sendable { + public let data: String + + public init(data: String) { + self.data = data + } + + public var formatted: String { + "data: \(data)\n\n" + } + + public static let done = SSEChunk(data: "[DONE]") +} + +// MARK: - Errors + +public enum InferenceError: Error, Sendable { + case noModelLoaded + case modelLoadFailed(String) + case generationFailed(String) + case invalidModelDirectory(String) + case unsupportedRole(String) +} diff --git a/provider-swift/Sources/ProviderCore/Inference/InferenceCancellation.swift b/provider-swift/Sources/ProviderCore/Inference/InferenceCancellation.swift new file mode 100644 index 00000000..1a0d9cb5 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/InferenceCancellation.swift @@ -0,0 +1,60 @@ +import Foundation + +public final class InferenceCancellationToken: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + public init() {} + + public var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled || Task.isCancelled + } + + public func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } + + public func checkCancellation() throws { + if isCancelled { + throw CancellationError() + } + } +} + +public actor InferenceCancellationRegistry { + private var tokens: [String: InferenceCancellationToken] = [:] + + public init() {} + + @discardableResult + public func register(requestId: String) -> InferenceCancellationToken { + let token = InferenceCancellationToken() + tokens[requestId] = token + return token + } + + public func token(for requestId: String) -> InferenceCancellationToken? { + tokens[requestId] + } + + @discardableResult + public func cancel(requestId: String) -> Bool { + guard let token = tokens.removeValue(forKey: requestId) else { + return false + } + token.cancel() + return true + } + + public func finish(requestId: String) { + tokens.removeValue(forKey: requestId) + } + + public var activeRequestIds: [String] { + tokens.keys.sorted() + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/InferenceEngine.swift b/provider-swift/Sources/ProviderCore/Inference/InferenceEngine.swift new file mode 100644 index 00000000..fe759b76 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/InferenceEngine.swift @@ -0,0 +1,317 @@ +import Foundation +import MLX +import MLXLLM +import MLXLMCommon +import Tokenizers + +public actor InferenceEngine { + private var container: ModelContainer? + private var loadedModelName: String? + private var idleTask: Task? + private let idleTimeout: Duration + private let formatter = OpenAIFormatter() + + // Tracks the last time a generate call completed so the idle timer + // knows when to unload. Updated inside the actor so no lock needed. + private var lastActivity: ContinuousClock.Instant + + public init(idleTimeout: Duration = .seconds(3600)) { + self.idleTimeout = idleTimeout + self.lastActivity = .now + } + + // MARK: - Model Lifecycle + + public func loadModel(from directory: URL, name: String) async throws { + if loadedModelName == name, container != nil { + touchActivity() + return + } + + unloadModelSync() + + let loaded = try await LLMModelFactory.shared.loadContainer( + from: directory, + using: LocalTokenizerLoader() + ) + + self.container = loaded + self.loadedModelName = name + touchActivity() + scheduleIdleUnload() + } + + public func unloadModel() { + unloadModelSync() + } + + public var isModelLoaded: Bool { + container != nil + } + + public var currentModelName: String? { + loadedModelName + } + + // MARK: - Streaming Generation + + public func generate( + request: ChatCompletionRequest + ) throws -> AsyncStream { + guard let container else { + throw InferenceError.noModelLoaded + } + + let rawMessages = try Self.buildRawMessages(request.messages) + let params = buildParameters(from: request) + let modelName = request.model + let completionID = formatter.makeCompletionID() + let created = Int(Date().timeIntervalSince1970) + let fmt = formatter + + let (stream, continuation) = AsyncStream.makeStream() + + let generationTask = Task.detached { + defer { continuation.finish() } + + do { + let generationStream: AsyncStream = try await container.perform { + context in + let input = try await context.processor.prepare( + input: UserInput(messages: rawMessages)) + return try MLXLMCommon.generate( + input: input, parameters: params, context: context) + } + + continuation.yield( + fmt.roleChunk(id: completionID, model: modelName, created: created) + ) + + var completionTokens = 0 + var promptTokens = 0 + var stopReason: GenerateStopReason = .stop + + for await generation in generationStream { + if Task.isCancelled { break } + + switch generation { + case .chunk(let text): + completionTokens += 1 + continuation.yield( + fmt.contentChunk( + id: completionID, + model: modelName, + created: created, + text: text + ) + ) + + case .info(let info): + promptTokens = info.promptTokenCount + completionTokens = info.generationTokenCount + stopReason = info.stopReason + + case .toolCall: + break + } + } + + let finishReason = fmt.finishReasonString(stopReason) + let usage = ChunkUsage( + prompt_tokens: promptTokens, + completion_tokens: completionTokens + ) + + continuation.yield( + fmt.stopChunk( + id: completionID, + model: modelName, + created: created, + finishReason: finishReason, + usage: usage + ) + ) + continuation.yield(.done) + + } catch { + continuation.yield(.done) + } + } + + continuation.onTermination = { @Sendable _ in + generationTask.cancel() + } + + touchActivity() + return stream + } + + // MARK: - Non-Streaming Generation + + public func generateFull( + request: ChatCompletionRequest + ) async throws -> ChatCompletionResponse { + guard let container else { + throw InferenceError.noModelLoaded + } + + let rawMessages = try Self.buildRawMessages(request.messages) + let params = buildParameters(from: request) + let completionID = formatter.makeCompletionID() + let created = Int(Date().timeIntervalSince1970) + + let generationStream: AsyncStream = try await container.perform { context in + let input = try await context.processor.prepare( + input: UserInput(messages: rawMessages)) + return try MLXLMCommon.generate(input: input, parameters: params, context: context) + } + + var fullContent = "" + var promptTokens = 0 + var completionTokens = 0 + var stopReason: GenerateStopReason = .stop + + for await generation in generationStream { + switch generation { + case .chunk(let text): + fullContent += text + + case .info(let info): + promptTokens = info.promptTokenCount + completionTokens = info.generationTokenCount + stopReason = info.stopReason + + case .toolCall: + break + } + } + + touchActivity() + + let finishReason = formatter.finishReasonString(stopReason) + let usage = ChunkUsage( + prompt_tokens: promptTokens, + completion_tokens: completionTokens + ) + + return formatter.nonStreamingResponse( + id: completionID, + model: request.model, + created: created, + content: fullContent, + finishReason: finishReason, + usage: usage + ) + } + + // MARK: - Idle Management + + private func touchActivity() { + lastActivity = .now + scheduleIdleUnload() + } + + private func scheduleIdleUnload() { + idleTask?.cancel() + idleTask = Task { [idleTimeout] in + while !Task.isCancelled { + let elapsed = ContinuousClock.now - self.lastActivity + let remaining = idleTimeout - elapsed + if remaining <= .zero { + self.unloadModelSync() + return + } + try? await Task.sleep(for: remaining) + } + } + } + + // MARK: - Internals + + private func unloadModelSync() { + idleTask?.cancel() + idleTask = nil + container = nil + loadedModelName = nil + } + + private func buildParameters(from request: ChatCompletionRequest) -> GenerateParameters { + GenerateParameters( + maxTokens: request.max_tokens, + temperature: request.temperature ?? 0.6, + topP: request.top_p ?? 1.0, + topK: request.top_k ?? 0, + repetitionPenalty: request.repetition_penalty, + presencePenalty: request.presence_penalty, + frequencyPenalty: request.frequency_penalty + ) + } + + /// Converts OpenAI ChatMessage array to raw Message dictionaries ([String: any Sendable]). + /// This format is Sendable and goes through the tokenizer's applyChatTemplate. + private static func buildRawMessages( + _ messages: [ChatMessage] + ) throws -> [MLXLMCommon.Message] { + let validRoles: Set = ["system", "user", "assistant", "tool"] + return try messages.map { msg in + guard validRoles.contains(msg.role) else { + throw InferenceError.unsupportedRole(msg.role) + } + return ["role": msg.role, "content": msg.content] as MLXLMCommon.Message + } + } +} + +// MARK: - Tokenizer Loading + +/// Bridges swift-transformers' AutoTokenizer to MLXLMCommon.Tokenizer. +/// This mirrors the exact bridge that mlx-swift-lm's #adaptHuggingFaceTokenizer +/// macro expands to, but done manually since we load from local directories +/// without the HuggingFace Hub client. +struct LocalTokenizerLoader: TokenizerLoader, Sendable { + func load(from directory: URL) async throws -> any MLXLMCommon.Tokenizer { + let upstream = try await AutoTokenizer.from(modelFolder: directory) + return TokenizerBridge(upstream) + } +} + +private struct TokenizerBridge: @unchecked Sendable, MLXLMCommon.Tokenizer { + private let upstream: any Tokenizers.Tokenizer + + init(_ upstream: any Tokenizers.Tokenizer) { + self.upstream = upstream + } + + func encode(text: String, addSpecialTokens: Bool) -> [Int] { + upstream.encode(text: text, addSpecialTokens: addSpecialTokens) + } + + func decode(tokenIds: [Int], skipSpecialTokens: Bool) -> String { + upstream.decode(tokens: tokenIds, skipSpecialTokens: skipSpecialTokens) + } + + func convertTokenToId(_ token: String) -> Int? { + upstream.convertTokenToId(token) + } + + func convertIdToToken(_ id: Int) -> String? { + upstream.convertIdToToken(id) + } + + var bosToken: String? { upstream.bosToken } + var eosToken: String? { upstream.eosToken } + var unknownToken: String? { upstream.unknownToken } + + func applyChatTemplate( + messages: [[String: any Sendable]], + tools: [[String: any Sendable]]?, + additionalContext: [String: any Sendable]? + ) throws -> [Int] { + do { + return try upstream.applyChatTemplate( + messages: messages, tools: tools, additionalContext: additionalContext) + } catch Tokenizers.TokenizerError.missingChatTemplate { + throw MLXLMCommon.TokenizerError.missingChatTemplate + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/OpenAIFormatter.swift b/provider-swift/Sources/ProviderCore/Inference/OpenAIFormatter.swift new file mode 100644 index 00000000..50fe0dad --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/OpenAIFormatter.swift @@ -0,0 +1,124 @@ +import Foundation +import MLXLMCommon + +public struct OpenAIFormatter: Sendable { + private let encoder: JSONEncoder + + public init() { + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + self.encoder = enc + } + + public func makeCompletionID() -> String { + "chatcmpl-\(UUID().uuidString.prefix(12).lowercased())" + } + + public func roleChunk( + id: String, + model: String, + created: Int + ) -> SSEChunk { + let chunk = ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(role: "assistant"), + finish_reason: nil + ) + ] + ) + return encodeChunk(chunk) + } + + public func contentChunk( + id: String, + model: String, + created: Int, + text: String + ) -> SSEChunk { + let chunk = ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(content: text), + finish_reason: nil + ) + ] + ) + return encodeChunk(chunk) + } + + public func stopChunk( + id: String, + model: String, + created: Int, + finishReason: String, + usage: ChunkUsage? + ) -> SSEChunk { + let chunk = ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(), + finish_reason: finishReason + ) + ], + usage: usage + ) + return encodeChunk(chunk) + } + + public func nonStreamingResponse( + id: String, + model: String, + created: Int, + content: String, + finishReason: String, + usage: ChunkUsage + ) -> ChatCompletionResponse { + ChatCompletionResponse( + id: id, + created: created, + model: model, + choices: [ + ResponseChoice( + index: 0, + message: ResponseMessage(content: content), + finish_reason: finishReason + ) + ], + usage: usage + ) + } + + public func encodeResponse(_ response: ChatCompletionResponse) -> Data? { + try? encoder.encode(response) + } + + func finishReasonString(_ reason: GenerateStopReason) -> String { + switch reason { + case .stop: "stop" + case .length: "length" + case .cancelled: "stop" + } + } + + private func encodeChunk(_ chunk: ChatCompletionChunk) -> SSEChunk { + guard let data = try? encoder.encode(chunk), + let json = String(data: data, encoding: .utf8) + else { + return SSEChunk(data: "{}") + } + return SSEChunk(data: json) + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/SSEChunkFormatting.swift b/provider-swift/Sources/ProviderCore/Inference/SSEChunkFormatting.swift new file mode 100644 index 00000000..27c77772 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/SSEChunkFormatting.swift @@ -0,0 +1,93 @@ +import Foundation + +public enum ChatStreamFinishReason: String, Codable, Equatable, Sendable { + case stop + case length + case cancelled + + public var openAIValue: String { + switch self { + case .stop: + "stop" + case .length: + "length" + case .cancelled: + "stop" + } + } +} + +public struct ChatSSEFormatter: Sendable { + private let outputFormatting: JSONEncoder.OutputFormatting + + public init(sortedKeys: Bool = true) { + self.outputFormatting = sortedKeys ? [.sortedKeys] : [] + } + + public func roleChunk(id: String, model: String, created: Int) throws -> SSEChunk { + try encode(ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(role: "assistant"), + finish_reason: nil + ) + ] + )) + } + + public func contentChunk( + id: String, + model: String, + created: Int, + text: String + ) throws -> SSEChunk { + try encode(ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(content: text), + finish_reason: nil + ) + ] + )) + } + + public func finishChunk( + id: String, + model: String, + created: Int, + reason: ChatStreamFinishReason, + usage: InferenceUsage + ) throws -> SSEChunk { + try encode(ChatCompletionChunk( + id: id, + created: created, + model: model, + choices: [ + ChunkChoice( + index: 0, + delta: ChunkDelta(), + finish_reason: reason.openAIValue + ) + ], + usage: usage.openAIChunkUsage + )) + } + + public func encode(_ value: T) throws -> SSEChunk { + let encoder = JSONEncoder() + encoder.outputFormatting = outputFormatting + let data = try encoder.encode(value) + guard let json = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return SSEChunk(data: json) + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/SingleRequestInference.swift b/provider-swift/Sources/ProviderCore/Inference/SingleRequestInference.swift new file mode 100644 index 00000000..413c4bfc --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/SingleRequestInference.swift @@ -0,0 +1,115 @@ +import Foundation + +public enum SingleRequestGenerationEvent: Equatable, Sendable { + case text(String, tokenCount: Int = 1) + case usage(InferenceUsage) + case finished(ChatStreamFinishReason) +} + +public enum SingleRequestInferenceOutput: Equatable, Sendable { + case sse(SSEChunk) + case complete(InferenceUsage) + + public static func == ( + lhs: SingleRequestInferenceOutput, + rhs: SingleRequestInferenceOutput + ) -> Bool { + switch (lhs, rhs) { + case (.sse(let left), .sse(let right)): + return left.data == right.data + case (.complete(let left), .complete(let right)): + return left == right + default: + return false + } + } +} + +public protocol SingleRequestChatEngine: Sendable { + func generate( + prompt: FormattedChatPrompt, + cancellation: InferenceCancellationToken + ) -> AsyncThrowingStream +} + +public struct SingleRequestInferenceDriver: Sendable { + private let engine: Engine + private let promptFormatter: ChatPromptFormatter + private let sseFormatter: ChatSSEFormatter + + public init( + engine: Engine, + promptFormatter: ChatPromptFormatter = ChatPromptFormatter(), + sseFormatter: ChatSSEFormatter = ChatSSEFormatter() + ) { + self.engine = engine + self.promptFormatter = promptFormatter + self.sseFormatter = sseFormatter + } + + public func stream( + requestId: String, + request: ChatCompletionRequest, + created: Int, + cancellation: InferenceCancellationToken = InferenceCancellationToken() + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let prompt = try promptFormatter.format(request) + try cancellation.checkCancellation() + + continuation.yield(.sse(try sseFormatter.roleChunk( + id: requestId, + model: prompt.model, + created: created + ))) + + var usage = UsageAccumulator() + var finishReason = ChatStreamFinishReason.stop + + for try await event in engine.generate(prompt: prompt, cancellation: cancellation) { + try cancellation.checkCancellation() + + switch event { + case .text(let text, let tokenCount): + usage.recordCompletionChunk(tokenCount: tokenCount) + continuation.yield(.sse(try sseFormatter.contentChunk( + id: requestId, + model: prompt.model, + created: created, + text: text + ))) + + case .usage(let finalUsage): + usage.merge(finalUsage) + + case .finished(let reason): + finishReason = reason + } + } + + try cancellation.checkCancellation() + let finalUsage = usage.snapshot + continuation.yield(.sse(try sseFormatter.finishChunk( + id: requestId, + model: prompt.model, + created: created, + reason: finishReason, + usage: finalUsage + ))) + continuation.yield(.sse(.done)) + continuation.yield(.complete(finalUsage)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + cancellation.cancel() + task.cancel() + } + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Inference/UsageAccounting.swift b/provider-swift/Sources/ProviderCore/Inference/UsageAccounting.swift new file mode 100644 index 00000000..f6ca9e3f --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Inference/UsageAccounting.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct InferenceUsage: Codable, Equatable, Sendable { + public let promptTokens: Int + public let completionTokens: Int + + public init(promptTokens: Int, completionTokens: Int) { + self.promptTokens = max(0, promptTokens) + self.completionTokens = max(0, completionTokens) + } + + public var totalTokens: Int { + promptTokens + completionTokens + } + + public var openAIChunkUsage: ChunkUsage { + ChunkUsage(prompt_tokens: promptTokens, completion_tokens: completionTokens) + } + + public var protocolUsageInfo: UsageInfo { + UsageInfo( + promptTokens: UInt64(promptTokens), + completionTokens: UInt64(completionTokens) + ) + } +} + +public struct UsageAccumulator: Sendable { + private var promptTokens: Int + private var completionTokens: Int + + public init(promptTokens: Int = 0, completionTokens: Int = 0) { + self.promptTokens = max(0, promptTokens) + self.completionTokens = max(0, completionTokens) + } + + public mutating func setPromptTokens(_ count: Int) { + promptTokens = max(0, count) + } + + public mutating func setCompletionTokens(_ count: Int) { + completionTokens = max(0, count) + } + + public mutating func recordCompletionChunk(tokenCount: Int = 1) { + completionTokens += max(0, tokenCount) + } + + public mutating func merge(_ usage: InferenceUsage) { + promptTokens = usage.promptTokens + completionTokens = usage.completionTokens + } + + public var snapshot: InferenceUsage { + InferenceUsage(promptTokens: promptTokens, completionTokens: completionTokens) + } +} diff --git a/provider-swift/Sources/ProviderCore/InferenceFoundation/LocalMLXModelFoundation.swift b/provider-swift/Sources/ProviderCore/InferenceFoundation/LocalMLXModelFoundation.swift new file mode 100644 index 00000000..b0daa337 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/InferenceFoundation/LocalMLXModelFoundation.swift @@ -0,0 +1,344 @@ +import Foundation +import MLXLLM +import MLXLMCommon + +public struct LocalMLXModelConfiguration: Equatable, Sendable { + public let modelID: String + public let modelDirectory: URL + public let tokenizerDirectory: URL + public let defaultPrompt: String + public let extraEOSTokens: Set + public let eosTokenIds: Set + + public init( + modelID: String? = nil, + modelDirectory: URL, + tokenizerDirectory: URL? = nil, + defaultPrompt: String = "", + extraEOSTokens: Set = [], + eosTokenIds: Set = [] + ) { + let normalizedModelDirectory = modelDirectory.standardizedFileURL + self.modelID = modelID ?? Self.defaultModelID(for: normalizedModelDirectory) + self.modelDirectory = normalizedModelDirectory + self.tokenizerDirectory = (tokenizerDirectory ?? modelDirectory).standardizedFileURL + self.defaultPrompt = defaultPrompt + self.extraEOSTokens = extraEOSTokens + self.eosTokenIds = eosTokenIds + } + + public var modelConfiguration: ModelConfiguration { + let tokenizerSource: TokenizerSource? = + tokenizerDirectory == modelDirectory ? nil : .directory(tokenizerDirectory) + + return ModelConfiguration( + directory: modelDirectory, + tokenizerSource: tokenizerSource, + defaultPrompt: defaultPrompt, + extraEOSTokens: extraEOSTokens, + eosTokenIds: eosTokenIds + ) + } + + private static func defaultModelID(for directory: URL) -> String { + let parent = directory.deletingLastPathComponent().lastPathComponent + let name = directory.lastPathComponent + return parent.isEmpty ? name : "\(parent)/\(name)" + } +} + +public struct LocalMLXModelFile: Equatable, Sendable { + public let url: URL + public let byteCount: UInt64 + + public init(url: URL, byteCount: UInt64) { + self.url = url.standardizedFileURL + self.byteCount = byteCount + } +} + +public struct LocalMLXModelReadinessIssue: Equatable, Sendable { + public enum Kind: String, Sendable { + case modelDirectoryMissing + case modelDirectoryUnreadable + case configJSONMissing + case tokenizerDirectoryMissing + case tokenizerDirectoryUnreadable + case tokenizerFilesMissing + case weightFilesMissing + } + + public let kind: Kind + public let path: URL + public let detail: String? + + public init(kind: Kind, path: URL, detail: String? = nil) { + self.kind = kind + self.path = path.standardizedFileURL + self.detail = detail + } + + public var isBlocking: Bool { + true + } +} + +public struct LocalMLXModelReadiness: Equatable, Sendable { + public static let tokenizerFileNames: Set = [ + "tokenizer.json", + "tokenizer.model", + "vocab.json", + ] + + public static let weightFileExtensions: Set = [ + "safetensors", + "npz", + "bin", + ] + + public let configuration: LocalMLXModelConfiguration + public let configJSON: URL? + public let tokenizerFiles: [LocalMLXModelFile] + public let weightFiles: [LocalMLXModelFile] + public let issues: [LocalMLXModelReadinessIssue] + + public var canAttemptLoad: Bool { + issues.allSatisfy { !$0.isBlocking } + } + + public var totalWeightBytes: UInt64 { + weightFiles.reduce(0) { $0 + $1.byteCount } + } + + public init( + configuration: LocalMLXModelConfiguration, + configJSON: URL?, + tokenizerFiles: [LocalMLXModelFile], + weightFiles: [LocalMLXModelFile], + issues: [LocalMLXModelReadinessIssue] + ) { + self.configuration = configuration + self.configJSON = configJSON?.standardizedFileURL + self.tokenizerFiles = tokenizerFiles + self.weightFiles = weightFiles + self.issues = issues + } + + public static func inspect( + _ configuration: LocalMLXModelConfiguration, + fileManager: FileManager = .default + ) -> LocalMLXModelReadiness { + var issues: [LocalMLXModelReadinessIssue] = [] + + let modelDirectory = configuration.modelDirectory + let tokenizerDirectory = configuration.tokenizerDirectory + + guard directoryExists(modelDirectory, fileManager: fileManager) else { + return LocalMLXModelReadiness( + configuration: configuration, + configJSON: nil, + tokenizerFiles: [], + weightFiles: [], + issues: [ + LocalMLXModelReadinessIssue( + kind: .modelDirectoryMissing, + path: modelDirectory + ) + ] + ) + } + + let configURL = modelDirectory.appendingPathComponent("config.json") + let configJSON: URL? = + fileManager.fileExists(atPath: configURL.path) ? configURL.standardizedFileURL : nil + if configJSON == nil { + issues.append( + LocalMLXModelReadinessIssue(kind: .configJSONMissing, path: configURL) + ) + } + + let weightFiles = collectFiles( + in: modelDirectory, + matchingExtensions: weightFileExtensions, + fileManager: fileManager + ) { error in + issues.append( + LocalMLXModelReadinessIssue( + kind: .modelDirectoryUnreadable, + path: modelDirectory, + detail: error.localizedDescription + ) + ) + } + if weightFiles.isEmpty { + issues.append( + LocalMLXModelReadinessIssue(kind: .weightFilesMissing, path: modelDirectory) + ) + } + + let tokenizerFiles: [LocalMLXModelFile] + if directoryExists(tokenizerDirectory, fileManager: fileManager) { + tokenizerFiles = collectNamedFiles( + in: tokenizerDirectory, + names: tokenizerFileNames, + fileManager: fileManager + ) { error in + issues.append( + LocalMLXModelReadinessIssue( + kind: .tokenizerDirectoryUnreadable, + path: tokenizerDirectory, + detail: error.localizedDescription + ) + ) + } + if tokenizerFiles.isEmpty { + issues.append( + LocalMLXModelReadinessIssue( + kind: .tokenizerFilesMissing, + path: tokenizerDirectory + ) + ) + } + } else { + tokenizerFiles = [] + issues.append( + LocalMLXModelReadinessIssue( + kind: .tokenizerDirectoryMissing, + path: tokenizerDirectory + ) + ) + } + + return LocalMLXModelReadiness( + configuration: configuration, + configJSON: configJSON, + tokenizerFiles: tokenizerFiles, + weightFiles: weightFiles, + issues: issues + ) + } + + private static func directoryExists(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory = ObjCBool(false) + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + + private static func collectNamedFiles( + in directory: URL, + names: Set, + fileManager: FileManager, + onReadError: (Error) -> Void + ) -> [LocalMLXModelFile] { + do { + return try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + .filter { names.contains($0.lastPathComponent) } + .compactMap { fileInfo(for: $0) } + .sorted { $0.url.path < $1.url.path } + } catch { + onReadError(error) + return [] + } + } + + private static func collectFiles( + in directory: URL, + matchingExtensions extensions: Set, + fileManager: FileManager, + onReadError: (Error) -> Void + ) -> [LocalMLXModelFile] { + var readError: Error? + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles], + errorHandler: { _, error in + readError = error + return false + } + ) else { + return [] + } + + var files: [LocalMLXModelFile] = [] + for case let url as URL in enumerator { + let ext = url.pathExtension.lowercased() + guard extensions.contains(ext), let file = fileInfo(for: url) else { + continue + } + files.append(file) + } + + if let error = readError { + onReadError(error) + } + + return files.sorted { $0.url.path < $1.url.path } + } + + private static func fileInfo(for url: URL) -> LocalMLXModelFile? { + guard + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true + else { + return nil + } + + return LocalMLXModelFile(url: url, byteCount: UInt64(values.fileSize ?? 0)) + } +} + +public enum LocalMLXModelLoadError: Error, Equatable, LocalizedError, Sendable { + case notReady([LocalMLXModelReadinessIssue]) + + public var errorDescription: String? { + switch self { + case .notReady(let issues): + let kinds = issues.map(\.kind.rawValue).joined(separator: ", ") + return "Local MLX model is not ready to load: \(kinds)" + } + } +} + +public struct LocalMLXModelLoader: Sendable { + private let tokenizerLoader: any TokenizerLoader + + public init(tokenizerLoader: any TokenizerLoader) { + self.tokenizerLoader = tokenizerLoader + } + + public static func live() -> LocalMLXModelLoader { + LocalMLXModelLoader(tokenizerLoader: LocalTokenizerLoader()) + } + + public func readiness( + for configuration: LocalMLXModelConfiguration, + fileManager: FileManager = .default + ) -> LocalMLXModelReadiness { + LocalMLXModelReadiness.inspect(configuration, fileManager: fileManager) + } + + public func loadContainer( + for configuration: LocalMLXModelConfiguration, + fileManager: FileManager = .default + ) async throws -> ModelContainer { + let readiness = readiness(for: configuration, fileManager: fileManager) + guard readiness.canAttemptLoad else { + throw LocalMLXModelLoadError.notReady(readiness.issues) + } + + let resolved = configuration.modelConfiguration.resolved( + modelDirectory: configuration.modelDirectory, + tokenizerDirectory: configuration.tokenizerDirectory + ) + let context = try await LLMModelFactory.shared._load( + configuration: resolved, + tokenizerLoader: tokenizerLoader + ) + return ModelContainer(context: context) + } +} diff --git a/provider-swift/Sources/ProviderCore/Models/ModelScanner.swift b/provider-swift/Sources/ProviderCore/Models/ModelScanner.swift new file mode 100644 index 00000000..3fa506b5 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Models/ModelScanner.swift @@ -0,0 +1,377 @@ +import Foundation +import os + +// MARK: - Model Scanner + +/// Scans the local HuggingFace cache for downloaded MLX models. +/// +/// The HuggingFace cache layout is: +/// ~/.cache/huggingface/hub/models--{org}--{name}/snapshots/{hash}/ +/// +/// A valid MLX model has config.json and at least one .safetensors weight file. +/// Memory estimation uses a 1.2x overhead factor for KV cache and runtime buffers. +/// +/// This performs fast discovery only (no weight hashing). Call +/// `WeightHasher.computeHash(for:)` separately for models that need attestation. +public struct ModelScanner: Sendable { + + private static let logger = Logger( + subsystem: "dev.darkbloom.provider", + category: "ModelScanner" + ) + + /// Memory overhead multiplier for KV cache, activation buffers, etc. + private static let memoryOverheadFactor: Double = 1.2 + + /// Weight file extensions that count toward model size. + static let weightExtensions: Set = [".safetensors", ".npz", ".bin"] + + /// Files included in integrity hashing (weights + config/tokenizer/template). + static let integrityFileNames: Set = [ + "config.json", + "tokenizer.json", + "tokenizer_config.json", + "tokenizer.model", + "generation_config.json", + "chat_template.jinja", + "quantize_config.json", + ] + + // MARK: - Public API + + /// Returns the default HuggingFace cache directory. + public static func defaultCacheDirectory() -> URL? { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cache/huggingface/hub", isDirectory: true) + } + + /// Scan for locally cached MLX models, filtering to those that fit in available memory. + public static func scanModels(hardwareInfo: HardwareInfo) -> [ModelInfo] { + guard let cacheDir = defaultCacheDirectory(), + FileManager.default.fileExists(atPath: cacheDir.path) else { + logger.debug("HuggingFace cache directory not found") + return [] + } + return scanModels(in: cacheDir, availableMemoryGB: hardwareInfo.memoryAvailableGb) + } + + /// Scan for models in a specific cache directory, filtering by available memory. + public static func scanModels(in cacheDir: URL, availableMemoryGB: UInt64) -> [ModelInfo] { + let fm = FileManager.default + let entries: [URL] + do { + entries = try fm.contentsOfDirectory( + at: cacheDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + } catch { + logger.warning("Failed to read cache directory \(cacheDir.path): \(error.localizedDescription)") + return [] + } + + var models: [ModelInfo] = [] + + for entry in entries { + let dirName = entry.lastPathComponent + + // HuggingFace stores models in directories like "models--org--name" + guard dirName.hasPrefix("models--") else { continue } + + let modelName = String(dirName.dropFirst("models--".count)) + .replacingOccurrences(of: "--", with: "/") + + let snapshotsDir = entry.appendingPathComponent("snapshots", isDirectory: true) + guard fm.fileExists(atPath: snapshotsDir.path) else { continue } + + guard let latestSnapshot = findLatestSnapshot(in: snapshotsDir) else { continue } + + guard isMLXModel(snapshotDir: latestSnapshot, modelName: modelName) else { continue } + + guard let info = parseModelInfo(snapshotDir: latestSnapshot, modelName: modelName) else { + continue + } + + if info.estimatedMemoryGb <= Double(availableMemoryGB) { + models.append(info) + } else { + logger.debug( + "Skipping \(info.id) — needs \(String(format: "%.1f", info.estimatedMemoryGb)) GB but only \(availableMemoryGB) GB available" + ) + } + } + + // Sort by estimated memory ascending (smallest models first) + models.sort { $0.estimatedMemoryGb < $1.estimatedMemoryGb } + + return models + } + + /// Resolve a model ID to its local snapshot path on disk. + /// + /// Checks the HuggingFace cache for a directory matching the model ID. + /// Returns the snapshot path so the backend can load directly from disk. + public static func resolveLocalPath(modelID: String) -> URL? { + guard let cacheDir = defaultCacheDirectory() else { return nil } + let fm = FileManager.default + + // Try exact match: models--{id with / replaced by --} + let dirName = "models--\(modelID.replacingOccurrences(of: "/", with: "--"))" + let modelDir = cacheDir.appendingPathComponent(dirName, isDirectory: true) + if fm.fileExists(atPath: modelDir.path) { + let snapshotsDir = modelDir.appendingPathComponent("snapshots", isDirectory: true) + if let snapshot = findLatestSnapshot(in: snapshotsDir) { + return snapshot + } + } + + // Try without org prefix (for models like "qwen3.5-27b-claude-opus-8bit") + let dirNamePlain = "models--\(modelID)" + let modelDirPlain = cacheDir.appendingPathComponent(dirNamePlain, isDirectory: true) + if fm.fileExists(atPath: modelDirPlain.path) { + let snapshotsDir = modelDirPlain.appendingPathComponent("snapshots", isDirectory: true) + if let snapshot = findLatestSnapshot(in: snapshotsDir) { + return snapshot + } + } + + return nil + } + + // MARK: - Snapshot Discovery + + /// Find the latest snapshot directory by modification time. + static func findLatestSnapshot(in snapshotsDir: URL) -> URL? { + let fm = FileManager.default + let entries: [URL] + do { + entries = try fm.contentsOfDirectory( + at: snapshotsDir, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + } catch { + return nil + } + + var latest: (url: URL, date: Date)? + + for entry in entries { + guard let resourceValues = try? entry.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]), + resourceValues.isDirectory == true else { + continue + } + + let modified = resourceValues.contentModificationDate ?? Date.distantPast + + if latest == nil || modified > latest!.date { + latest = (entry, modified) + } + } + + return latest?.url + } + + // MARK: - MLX Detection + + /// Check if a snapshot directory contains an MLX model. + static func isMLXModel(snapshotDir: URL, modelName: String) -> Bool { + let nameLower = modelName.lowercased() + let fm = FileManager.default + + // Name contains "mlx" -- definitely MLX + if nameLower.contains("mlx") { + return true + } + + // Check for MLX-specific weight files + let hasMLXWeights = + fm.fileExists(atPath: snapshotDir.appendingPathComponent("weights.npz").path) + || fm.fileExists(atPath: snapshotDir.appendingPathComponent("model.safetensors").path) + || fm.fileExists(atPath: snapshotDir.appendingPathComponent("model.safetensors.index.json").path) + + // Weight files + quantization indicators in name + if hasMLXWeights + && (nameLower.contains("4bit") + || nameLower.contains("8bit") + || nameLower.contains("quantized")) + { + return true + } + + // Safetensors + config.json as fallback + if hasMLXWeights { + return fm.fileExists(atPath: snapshotDir.appendingPathComponent("config.json").path) + } + + return false + } + + // MARK: - Model Parsing + + /// Parse model info from a snapshot directory (fast, no weight hashing). + static func parseModelInfo(snapshotDir: URL, modelName: String) -> ModelInfo? { + let configPath = snapshotDir.appendingPathComponent("config.json") + + let (modelType, parameters) = FileManager.default.fileExists(atPath: configPath.path) + ? parseConfigJSON(at: configPath) + : (nil, nil) + + let quantization = detectQuantization(modelName: modelName, snapshotDir: snapshotDir) + let (sizeBytes, _) = collectWeightFiles(in: snapshotDir) + + guard sizeBytes > 0 else { return nil } + + let estimatedMemoryGb = (Double(sizeBytes) / (1024.0 * 1024.0 * 1024.0)) * memoryOverheadFactor + + return ModelInfo( + id: modelName, + modelType: modelType, + parameters: parameters, + quantization: quantization, + sizeBytes: sizeBytes, + estimatedMemoryGb: estimatedMemoryGb + ) + } + + // MARK: - Config Parsing + + /// Parse config.json to extract model_type and parameter count. + static func parseConfigJSON(at path: URL) -> (modelType: String?, parameters: UInt64?) { + guard let data = try? Data(contentsOf: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, nil) + } + + let modelType = json["model_type"] as? String + + // Try explicit parameter count first + var parameters: UInt64? + if let numParams = json["num_parameters"] as? Int64, numParams > 0 { + parameters = UInt64(numParams) + } else if let numParams = json["num_parameters"] as? UInt64 { + parameters = numParams + } + + // Estimate from architecture if no explicit count + if parameters == nil { + if let hidden = (json["hidden_size"] as? UInt64) ?? (json["hidden_size"] as? Int).map({ UInt64($0) }), + let layers = (json["num_hidden_layers"] as? UInt64) ?? (json["num_hidden_layers"] as? Int).map({ UInt64($0) }) + { + let vocab = (json["vocab_size"] as? UInt64) + ?? (json["vocab_size"] as? Int).map({ UInt64($0) }) + ?? 32000 + // Rough estimate: 12 * hidden^2 * layers + vocab * hidden + // The division then multiplication rounds to nearest million (matches Rust) + parameters = 12 * hidden * hidden * layers / 1_000_000 * 1_000_000 + vocab * hidden + } + } + + return (modelType, parameters) + } + + // MARK: - Quantization Detection + + /// Detect quantization from model name or config files. + static func detectQuantization(modelName: String, snapshotDir: URL) -> String? { + let nameLower = modelName.lowercased() + + if nameLower.contains("4bit") || nameLower.contains("q4") || nameLower.contains("int4") { + return "4bit" + } + if nameLower.contains("8bit") || nameLower.contains("q8") || nameLower.contains("int8") { + return "8bit" + } + if nameLower.contains("3bit") || nameLower.contains("q3") { + return "3bit" + } + if nameLower.contains("bf16") { + return "bf16" + } + if nameLower.contains("fp16") || nameLower.contains("f16") { + return "fp16" + } + + // Check for quantize_config.json + let quantConfigPath = snapshotDir.appendingPathComponent("quantize_config.json") + if let data = try? Data(contentsOf: quantConfigPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let bits = json["bits"] as? Int, bits > 0 + { + return "\(bits)bit" + } + + return nil + } + + // MARK: - Weight File Collection + + /// Whether a filename is an integrity-relevant file (weight or config/tokenizer/template). + static func isIntegrityFile(_ name: String) -> Bool { + if weightExtensions.contains(where: { name.hasSuffix($0) }) { + return true + } + if name == "weights.npz" { + return true + } + return integrityFileNames.contains(name) + } + + /// Whether a filename is a weight file (counts toward model size). + static func isWeightFile(_ name: String) -> Bool { + weightExtensions.contains(where: { name.hasSuffix($0) }) || name == "weights.npz" + } + + /// Collect integrity file paths and total weight size from a snapshot directory. + /// + /// Returns (totalWeightSizeBytes, sortedIntegrityFilePaths). + /// Only weight files (.safetensors, .npz, .bin) count toward totalWeightSizeBytes. + /// Config, tokenizer, and template files are included in the path list for + /// integrity hashing but not in the size calculation. + static func collectWeightFiles(in snapshotDir: URL) -> (sizeBytes: UInt64, paths: [URL]) { + let fm = FileManager.default + let entries: [URL] + do { + entries = try fm.contentsOfDirectory( + at: snapshotDir, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + } catch { + return (0, []) + } + + var totalSize: UInt64 = 0 + var paths: [URL] = [] + + for entry in entries { + let name = entry.lastPathComponent + guard isIntegrityFile(name) else { continue } + + let isWeight = isWeightFile(name) + + // Resolve symlinks to get actual file size + let resolvedURL: URL + if let resourceValues = try? entry.resourceValues(forKeys: [.isSymbolicLinkKey]), + resourceValues.isSymbolicLink == true + { + resolvedURL = entry.resolvingSymlinksInPath() + } else { + resolvedURL = entry + } + + guard let attrs = try? fm.attributesOfItem(atPath: resolvedURL.path), + let fileType = attrs[.type] as? FileAttributeType, + fileType == .typeRegular else { + continue + } + + if isWeight, let fileSize = attrs[.size] as? UInt64 { + totalSize += fileSize + } + paths.append(entry) + } + + return (totalSize, paths) + } +} diff --git a/provider-swift/Sources/ProviderCore/Models/WeightHasher.swift b/provider-swift/Sources/ProviderCore/Models/WeightHasher.swift new file mode 100644 index 00000000..ba7d580d --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Models/WeightHasher.swift @@ -0,0 +1,135 @@ +import CryptoKit +import Foundation +import os + +// MARK: - Weight Hasher + +/// On-demand SHA-256 weight hashing for model integrity verification. +/// +/// Computes a deterministic hash over all integrity-relevant files in a model +/// snapshot directory. Files are sorted by filename, each hashed independently +/// (in parallel), then the per-file digests are combined into a final hash. +/// +/// This is intentionally separated from `ModelScanner` because hashing is +/// expensive (reads every byte of every weight file) and should only be +/// performed for the model actually being served, not during discovery. +public struct WeightHasher: Sendable { + + private static let logger = Logger( + subsystem: "dev.darkbloom.provider", + category: "WeightHasher" + ) + + /// Buffer size for streaming file reads (64 KB, matches Rust implementation). + private static let bufferSize = 65536 + + // MARK: - Public API + + /// Compute the integrity hash for a model by its ID. + /// + /// Resolves the model ID to its local snapshot path, collects all integrity + /// files (weights, config, tokenizer, templates), and computes a combined + /// SHA-256 hash. Returns nil if the model is not found locally or has no + /// weight files. + public static func computeHash(for modelID: String) -> String? { + guard let snapshotDir = ModelScanner.resolveLocalPath(modelID: modelID) else { + return nil + } + return computeHash(snapshotDir: snapshotDir, modelID: modelID) + } + + /// Compute the integrity hash for a model at a specific snapshot path. + public static func computeHash(snapshotDir: URL, modelID: String? = nil) -> String? { + let (_, paths) = ModelScanner.collectWeightFiles(in: snapshotDir) + guard !paths.isEmpty else { return nil } + + let label = modelID ?? snapshotDir.lastPathComponent + logger.info("Computing weight hash for \(label) (\(paths.count) files)...") + + let hash = hashFilesSorted(paths) + + if let hash { + let prefix = String(hash.prefix(16)) + logger.info("Weight hash for \(label): \(prefix)") + } + + return hash + } + + // MARK: - Hashing Implementation + + /// Hash files in sorted filename order, combining per-file digests into a final hash. + /// + /// Each file is hashed independently (in parallel via DispatchQueue), then the + /// per-file SHA-256 digests are combined in sorted filename order into a single + /// final SHA-256 hash. This produces a consistent result regardless of filesystem + /// ordering and scales across CPU cores for sharded model weights. + static func hashFilesSorted(_ paths: [URL]) -> String? { + // Sort by full path (matches Rust's PathBuf::sort which sorts lexicographically) + let sorted = paths.sorted { $0.path < $1.path } + + // Hash each file in parallel + let group = DispatchGroup() + let queue = DispatchQueue(label: "dev.darkbloom.provider.weighthash", attributes: .concurrent) + + // Pre-allocate array for per-file hashes, indexed by position. + // Safety: each index is written by exactly one concurrent block, no two + // blocks share an index, and group.wait() provides the happens-before + // barrier before we read. The nonisolated(unsafe) annotation tells the + // compiler we've manually verified the data-race safety. + let count = sorted.count + let rawBuffer = UnsafeMutablePointer.allocate(capacity: count) + rawBuffer.initialize(repeating: nil, count: count) + nonisolated(unsafe) let buffer = rawBuffer + defer { + rawBuffer.deinitialize(count: count) + rawBuffer.deallocate() + } + + for (index, path) in sorted.enumerated() { + group.enter() + queue.async { + buffer[index] = hashSingleFile(at: path) + group.leave() + } + } + + group.wait() + + // Combine per-file hashes in sorted order + var finalHasher = SHA256() + for i in 0.. SHA256Digest? { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return nil + } + defer { try? handle.close() } + + var hasher = SHA256() + + while true { + guard let chunk = try? handle.read(upToCount: bufferSize) else { + return nil + } + if chunk.isEmpty { + break + } + hasher.update(data: chunk) + } + + return hasher.finalize() + } +} diff --git a/provider-swift/Sources/ProviderCore/Protocol/Enums.swift b/provider-swift/Sources/ProviderCore/Protocol/Enums.swift new file mode 100644 index 00000000..f09e1abc --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Protocol/Enums.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum ProviderStatus: String, Codable, Sendable { + case idle + case serving +} + +public enum ChipFamily: String, Codable, Sendable { + case m1 = "M1" + case m2 = "M2" + case m3 = "M3" + case m4 = "M4" + case m5 = "M5" + case unknown = "Unknown" +} + +public enum ChipTier: String, Codable, Sendable { + case base = "Base" + case pro = "Pro" + case max = "Max" + case ultra = "Ultra" + case unknown = "Unknown" +} + +public enum ThermalState: String, Codable, Sendable { + case nominal + case fair + case serious + case critical +} diff --git a/provider-swift/Sources/ProviderCore/Protocol/Messages.swift b/provider-swift/Sources/ProviderCore/Protocol/Messages.swift new file mode 100644 index 00000000..1da649d7 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Protocol/Messages.swift @@ -0,0 +1,531 @@ +import Foundation + +// MARK: - Provider -> Coordinator + +public enum ProviderMessage: Sendable, Equatable { + case register(Register) + case heartbeat(Heartbeat) + case inferenceAccepted(InferenceAccepted) + case inferenceResponseChunk(InferenceResponseChunk) + case inferenceComplete(InferenceComplete) + case inferenceError(InferenceError) + case attestationResponse(AttestationResponse) + + public struct Register: Sendable, Equatable { + public var hardware: HardwareInfo + public var models: [ModelInfo] + public var backend: String + public var version: String? + public var publicKey: String? + public var encryptedResponseChunks: Bool + public var walletAddress: String? + public var attestation: RawJSON? + public var prefillTps: Double? + public var decodeTps: Double? + public var authToken: String? + public var pythonHash: String? + public var runtimeHash: String? + public var templateHashes: [String: String] + public var privacyCapabilities: PrivacyCapabilities? + + public init( + hardware: HardwareInfo, + models: [ModelInfo], + backend: String, + version: String? = nil, + publicKey: String? = nil, + encryptedResponseChunks: Bool = false, + walletAddress: String? = nil, + attestation: RawJSON? = nil, + prefillTps: Double? = nil, + decodeTps: Double? = nil, + authToken: String? = nil, + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:], + privacyCapabilities: PrivacyCapabilities? = nil + ) { + self.hardware = hardware + self.models = models + self.backend = backend + self.version = version + self.publicKey = publicKey + self.encryptedResponseChunks = encryptedResponseChunks + self.walletAddress = walletAddress + self.attestation = attestation + self.prefillTps = prefillTps + self.decodeTps = decodeTps + self.authToken = authToken + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + self.privacyCapabilities = privacyCapabilities + } + } + + public struct Heartbeat: Sendable, Equatable { + public var status: ProviderStatus + public var activeModel: String? + public var warmModels: [String] + public var stats: ProviderStats + public var systemMetrics: SystemMetrics + public var backendCapacity: BackendCapacity? + + public init( + status: ProviderStatus, + activeModel: String? = nil, + warmModels: [String] = [], + stats: ProviderStats, + systemMetrics: SystemMetrics, + backendCapacity: BackendCapacity? = nil + ) { + self.status = status + self.activeModel = activeModel + self.warmModels = warmModels + self.stats = stats + self.systemMetrics = systemMetrics + self.backendCapacity = backendCapacity + } + } + + public struct InferenceAccepted: Sendable, Equatable { + public var requestId: String + public init(requestId: String) { self.requestId = requestId } + } + + public struct InferenceResponseChunk: Sendable, Equatable { + public var requestId: String + public var data: String + public var encryptedData: EncryptedPayload? + + public init(requestId: String, data: String = "", encryptedData: EncryptedPayload? = nil) { + self.requestId = requestId + self.data = data + self.encryptedData = encryptedData + } + } + + public struct InferenceComplete: Sendable, Equatable { + public var requestId: String + public var usage: UsageInfo + public var seSignature: String? + public var responseHash: String? + + public init(requestId: String, usage: UsageInfo, seSignature: String? = nil, responseHash: String? = nil) { + self.requestId = requestId + self.usage = usage + self.seSignature = seSignature + self.responseHash = responseHash + } + } + + public struct InferenceError: Sendable, Equatable { + public var requestId: String + public var error: String + public var statusCode: UInt16 + + public init(requestId: String, error: String, statusCode: UInt16) { + self.requestId = requestId + self.error = error + self.statusCode = statusCode + } + } + + public struct AttestationResponse: Sendable, Equatable { + public var nonce: String + public var signature: String + public var statusSignature: String? + public var publicKey: String + public var hypervisorActive: Bool? + public var rdmaDisabled: Bool? + public var sipEnabled: Bool? + public var secureBootEnabled: Bool? + public var binaryHash: String? + public var activeModelHash: String? + public var pythonHash: String? + public var runtimeHash: String? + public var templateHashes: [String: String] + public var modelHashes: [String: String] + + public init( + nonce: String, + signature: String, + statusSignature: String? = nil, + publicKey: String, + hypervisorActive: Bool? = nil, + rdmaDisabled: Bool? = nil, + sipEnabled: Bool? = nil, + secureBootEnabled: Bool? = nil, + binaryHash: String? = nil, + activeModelHash: String? = nil, + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:], + modelHashes: [String: String] = [:] + ) { + self.nonce = nonce + self.signature = signature + self.statusSignature = statusSignature + self.publicKey = publicKey + self.hypervisorActive = hypervisorActive + self.rdmaDisabled = rdmaDisabled + self.sipEnabled = sipEnabled + self.secureBootEnabled = secureBootEnabled + self.binaryHash = binaryHash + self.activeModelHash = activeModelHash + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + self.modelHashes = modelHashes + } + } +} + +// MARK: - ProviderMessage Codable + +extension ProviderMessage: Codable { + enum TypeValue: String, Codable { + case register + case heartbeat + case inferenceAccepted = "inference_accepted" + case inferenceResponseChunk = "inference_response_chunk" + case inferenceComplete = "inference_complete" + case inferenceError = "inference_error" + case attestationResponse = "attestation_response" + } + + enum CodingKeys: String, CodingKey { + case type + // Register + case hardware, models, backend, version + case publicKey = "public_key" + case encryptedResponseChunks = "encrypted_response_chunks" + case walletAddress = "wallet_address" + case attestation + case prefillTps = "prefill_tps" + case decodeTps = "decode_tps" + case authToken = "auth_token" + case pythonHash = "python_hash" + case runtimeHash = "runtime_hash" + case templateHashes = "template_hashes" + case privacyCapabilities = "privacy_capabilities" + // Heartbeat + case status + case activeModel = "active_model" + case warmModels = "warm_models" + case stats + case systemMetrics = "system_metrics" + case backendCapacity = "backend_capacity" + // Common + case requestId = "request_id" + // InferenceResponseChunk + case data + case encryptedData = "encrypted_data" + // InferenceComplete + case usage + case seSignature = "se_signature" + case responseHash = "response_hash" + // InferenceError + case error + case statusCode = "status_code" + // AttestationResponse + case nonce, signature + case statusSignature = "status_signature" + case hypervisorActive = "hypervisor_active" + case rdmaDisabled = "rdma_disabled" + case sipEnabled = "sip_enabled" + case secureBootEnabled = "secure_boot_enabled" + case binaryHash = "binary_hash" + case activeModelHash = "active_model_hash" + case modelHashes = "model_hashes" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .register(let r): + try container.encode(TypeValue.register, forKey: .type) + try container.encode(r.hardware, forKey: .hardware) + try container.encode(r.models, forKey: .models) + try container.encode(r.backend, forKey: .backend) + try container.encodeIfPresent(r.version, forKey: .version) + try container.encodeIfPresent(r.publicKey, forKey: .publicKey) + if r.encryptedResponseChunks { + try container.encode(true, forKey: .encryptedResponseChunks) + } + try container.encodeIfPresent(r.walletAddress, forKey: .walletAddress) + try container.encodeIfPresent(r.attestation, forKey: .attestation) + try container.encodeIfPresent(r.prefillTps, forKey: .prefillTps) + try container.encodeIfPresent(r.decodeTps, forKey: .decodeTps) + try container.encodeIfPresent(r.authToken, forKey: .authToken) + try container.encodeIfPresent(r.pythonHash, forKey: .pythonHash) + try container.encodeIfPresent(r.runtimeHash, forKey: .runtimeHash) + if !r.templateHashes.isEmpty { + try container.encode(r.templateHashes, forKey: .templateHashes) + } + try container.encodeIfPresent(r.privacyCapabilities, forKey: .privacyCapabilities) + + case .heartbeat(let h): + try container.encode(TypeValue.heartbeat, forKey: .type) + try container.encode(h.status, forKey: .status) + try container.encodeIfPresent(h.activeModel, forKey: .activeModel) + if !h.warmModels.isEmpty { + try container.encode(h.warmModels, forKey: .warmModels) + } + try container.encode(h.stats, forKey: .stats) + try container.encode(h.systemMetrics, forKey: .systemMetrics) + try container.encodeIfPresent(h.backendCapacity, forKey: .backendCapacity) + + case .inferenceAccepted(let a): + try container.encode(TypeValue.inferenceAccepted, forKey: .type) + try container.encode(a.requestId, forKey: .requestId) + + case .inferenceResponseChunk(let c): + try container.encode(TypeValue.inferenceResponseChunk, forKey: .type) + try container.encode(c.requestId, forKey: .requestId) + if !c.data.isEmpty { + try container.encode(c.data, forKey: .data) + } + try container.encodeIfPresent(c.encryptedData, forKey: .encryptedData) + + case .inferenceComplete(let c): + try container.encode(TypeValue.inferenceComplete, forKey: .type) + try container.encode(c.requestId, forKey: .requestId) + try container.encode(c.usage, forKey: .usage) + try container.encodeIfPresent(c.seSignature, forKey: .seSignature) + try container.encodeIfPresent(c.responseHash, forKey: .responseHash) + + case .inferenceError(let e): + try container.encode(TypeValue.inferenceError, forKey: .type) + try container.encode(e.requestId, forKey: .requestId) + try container.encode(e.error, forKey: .error) + try container.encode(e.statusCode, forKey: .statusCode) + + case .attestationResponse(let a): + try container.encode(TypeValue.attestationResponse, forKey: .type) + try container.encode(a.nonce, forKey: .nonce) + try container.encode(a.signature, forKey: .signature) + try container.encodeIfPresent(a.statusSignature, forKey: .statusSignature) + try container.encode(a.publicKey, forKey: .publicKey) + try container.encodeIfPresent(a.hypervisorActive, forKey: .hypervisorActive) + try container.encodeIfPresent(a.rdmaDisabled, forKey: .rdmaDisabled) + try container.encodeIfPresent(a.sipEnabled, forKey: .sipEnabled) + try container.encodeIfPresent(a.secureBootEnabled, forKey: .secureBootEnabled) + try container.encodeIfPresent(a.binaryHash, forKey: .binaryHash) + try container.encodeIfPresent(a.activeModelHash, forKey: .activeModelHash) + try container.encodeIfPresent(a.pythonHash, forKey: .pythonHash) + try container.encodeIfPresent(a.runtimeHash, forKey: .runtimeHash) + if !a.templateHashes.isEmpty { + try container.encode(a.templateHashes, forKey: .templateHashes) + } + if !a.modelHashes.isEmpty { + try container.encode(a.modelHashes, forKey: .modelHashes) + } + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeValue.self, forKey: .type) + + switch type { + case .register: + self = .register(Register( + hardware: try container.decode(HardwareInfo.self, forKey: .hardware), + models: try container.decode([ModelInfo].self, forKey: .models), + backend: try container.decode(String.self, forKey: .backend), + version: try container.decodeIfPresent(String.self, forKey: .version), + publicKey: try container.decodeIfPresent(String.self, forKey: .publicKey), + encryptedResponseChunks: try container.decodeIfPresent(Bool.self, forKey: .encryptedResponseChunks) ?? false, + walletAddress: try container.decodeIfPresent(String.self, forKey: .walletAddress), + attestation: try container.decodeIfPresent(RawJSON.self, forKey: .attestation), + prefillTps: try container.decodeIfPresent(Double.self, forKey: .prefillTps), + decodeTps: try container.decodeIfPresent(Double.self, forKey: .decodeTps), + authToken: try container.decodeIfPresent(String.self, forKey: .authToken), + pythonHash: try container.decodeIfPresent(String.self, forKey: .pythonHash), + runtimeHash: try container.decodeIfPresent(String.self, forKey: .runtimeHash), + templateHashes: try container.decodeIfPresent([String: String].self, forKey: .templateHashes) ?? [:], + privacyCapabilities: try container.decodeIfPresent(PrivacyCapabilities.self, forKey: .privacyCapabilities) + )) + + case .heartbeat: + self = .heartbeat(Heartbeat( + status: try container.decode(ProviderStatus.self, forKey: .status), + activeModel: try container.decodeIfPresent(String.self, forKey: .activeModel), + warmModels: try container.decodeIfPresent([String].self, forKey: .warmModels) ?? [], + stats: try container.decode(ProviderStats.self, forKey: .stats), + systemMetrics: try container.decode(SystemMetrics.self, forKey: .systemMetrics), + backendCapacity: try container.decodeIfPresent(BackendCapacity.self, forKey: .backendCapacity) + )) + + case .inferenceAccepted: + self = .inferenceAccepted(InferenceAccepted( + requestId: try container.decode(String.self, forKey: .requestId) + )) + + case .inferenceResponseChunk: + self = .inferenceResponseChunk(InferenceResponseChunk( + requestId: try container.decode(String.self, forKey: .requestId), + data: try container.decodeIfPresent(String.self, forKey: .data) ?? "", + encryptedData: try container.decodeIfPresent(EncryptedPayload.self, forKey: .encryptedData) + )) + + case .inferenceComplete: + self = .inferenceComplete(InferenceComplete( + requestId: try container.decode(String.self, forKey: .requestId), + usage: try container.decode(UsageInfo.self, forKey: .usage), + seSignature: try container.decodeIfPresent(String.self, forKey: .seSignature), + responseHash: try container.decodeIfPresent(String.self, forKey: .responseHash) + )) + + case .inferenceError: + self = .inferenceError(InferenceError( + requestId: try container.decode(String.self, forKey: .requestId), + error: try container.decode(String.self, forKey: .error), + statusCode: try container.decode(UInt16.self, forKey: .statusCode) + )) + + case .attestationResponse: + self = .attestationResponse(AttestationResponse( + nonce: try container.decode(String.self, forKey: .nonce), + signature: try container.decode(String.self, forKey: .signature), + statusSignature: try container.decodeIfPresent(String.self, forKey: .statusSignature), + publicKey: try container.decode(String.self, forKey: .publicKey), + hypervisorActive: try container.decodeIfPresent(Bool.self, forKey: .hypervisorActive), + rdmaDisabled: try container.decodeIfPresent(Bool.self, forKey: .rdmaDisabled), + sipEnabled: try container.decodeIfPresent(Bool.self, forKey: .sipEnabled), + secureBootEnabled: try container.decodeIfPresent(Bool.self, forKey: .secureBootEnabled), + binaryHash: try container.decodeIfPresent(String.self, forKey: .binaryHash), + activeModelHash: try container.decodeIfPresent(String.self, forKey: .activeModelHash), + pythonHash: try container.decodeIfPresent(String.self, forKey: .pythonHash), + runtimeHash: try container.decodeIfPresent(String.self, forKey: .runtimeHash), + templateHashes: try container.decodeIfPresent([String: String].self, forKey: .templateHashes) ?? [:], + modelHashes: try container.decodeIfPresent([String: String].self, forKey: .modelHashes) ?? [:] + )) + } + } +} + +// MARK: - Coordinator -> Provider + +public enum CoordinatorMessage: Sendable, Equatable { + case inferenceRequest(InferenceRequest) + case cancel(Cancel) + case attestationChallenge(AttestationChallenge) + case runtimeStatus(RuntimeStatus) + + public struct InferenceRequest: Sendable, Equatable { + public var requestId: String + public var body: JSONValue + public var encryptedBody: EncryptedPayload? + + public init(requestId: String, body: JSONValue = .null, encryptedBody: EncryptedPayload? = nil) { + self.requestId = requestId + self.body = body + self.encryptedBody = encryptedBody + } + } + + public struct Cancel: Sendable, Equatable { + public var requestId: String + public init(requestId: String) { self.requestId = requestId } + } + + public struct AttestationChallenge: Sendable, Equatable { + public var nonce: String + public var timestamp: String + public init(nonce: String, timestamp: String) { + self.nonce = nonce + self.timestamp = timestamp + } + } + + public struct RuntimeStatus: Sendable, Equatable { + public var verified: Bool + public var mismatches: [RuntimeMismatch] + public init(verified: Bool, mismatches: [RuntimeMismatch] = []) { + self.verified = verified + self.mismatches = mismatches + } + } +} + +// MARK: - CoordinatorMessage Codable + +extension CoordinatorMessage: Codable { + enum TypeValue: String, Codable { + case inferenceRequest = "inference_request" + case cancel + case attestationChallenge = "attestation_challenge" + case runtimeStatus = "runtime_status" + } + + enum CodingKeys: String, CodingKey { + case type + case requestId = "request_id" + case body + case encryptedBody = "encrypted_body" + case nonce, timestamp + case verified, mismatches + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .inferenceRequest(let r): + try container.encode(TypeValue.inferenceRequest, forKey: .type) + try container.encode(r.requestId, forKey: .requestId) + try container.encode(r.body, forKey: .body) + try container.encodeIfPresent(r.encryptedBody, forKey: .encryptedBody) + + case .cancel(let c): + try container.encode(TypeValue.cancel, forKey: .type) + try container.encode(c.requestId, forKey: .requestId) + + case .attestationChallenge(let a): + try container.encode(TypeValue.attestationChallenge, forKey: .type) + try container.encode(a.nonce, forKey: .nonce) + try container.encode(a.timestamp, forKey: .timestamp) + + case .runtimeStatus(let s): + try container.encode(TypeValue.runtimeStatus, forKey: .type) + try container.encode(s.verified, forKey: .verified) + if !s.mismatches.isEmpty { + try container.encode(s.mismatches, forKey: .mismatches) + } + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeValue.self, forKey: .type) + + switch type { + case .inferenceRequest: + self = .inferenceRequest(InferenceRequest( + requestId: try container.decode(String.self, forKey: .requestId), + body: try container.decodeIfPresent(JSONValue.self, forKey: .body) ?? .null, + encryptedBody: try container.decodeIfPresent(EncryptedPayload.self, forKey: .encryptedBody) + )) + + case .cancel: + self = .cancel(Cancel( + requestId: try container.decode(String.self, forKey: .requestId) + )) + + case .attestationChallenge: + self = .attestationChallenge(AttestationChallenge( + nonce: try container.decode(String.self, forKey: .nonce), + timestamp: try container.decode(String.self, forKey: .timestamp) + )) + + case .runtimeStatus: + self = .runtimeStatus(RuntimeStatus( + verified: try container.decode(Bool.self, forKey: .verified), + mismatches: try container.decodeIfPresent([RuntimeMismatch].self, forKey: .mismatches) ?? [] + )) + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Protocol/ProtocolCodec.swift b/provider-swift/Sources/ProviderCore/Protocol/ProtocolCodec.swift new file mode 100644 index 00000000..25e2e2d3 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Protocol/ProtocolCodec.swift @@ -0,0 +1,327 @@ +import Foundation + +/// Wire codec for provider/coordinator protocol messages. +/// +/// Most messages can use the Codable envelopes directly. Register messages with +/// attestation need a small custom path because JSONEncoder cannot emit an +/// already-encoded raw JSON fragment. +public enum ProviderProtocolCodec { + public static func encodeProviderMessage(_ message: ProviderMessage) throws -> Data { + if case .register(let register) = message, register.attestation != nil { + return try encodeRegisterPreservingRawAttestation(register) + } + + return try makeEncoder().encode(message) + } + + public static func encodeProviderMessageString(_ message: ProviderMessage) throws -> String { + let data = try encodeProviderMessage(message) + guard let string = String(data: data, encoding: .utf8) else { + throw ProtocolCodecError.nonUTF8Output + } + return string + } + + public static func decodeProviderMessage(from data: Data) throws -> ProviderMessage { + var message = try JSONDecoder().decode(ProviderMessage.self, from: data) + + if case .register(var register) = message, + register.attestation != nil, + let rawAttestation = JSONRawValueExtractor.rawValue(forKey: "attestation", in: data) { + register.attestation = RawJSON(rawBytes: rawAttestation) + message = .register(register) + } + + return message + } + + public static func decodeProviderMessage(from string: String) throws -> ProviderMessage { + guard let data = string.data(using: .utf8) else { + throw ProtocolCodecError.nonUTF8Input + } + return try decodeProviderMessage(from: data) + } + + public static func encodeCoordinatorMessage(_ message: CoordinatorMessage) throws -> Data { + try makeEncoder().encode(message) + } + + public static func encodeCoordinatorMessageString(_ message: CoordinatorMessage) throws -> String { + let data = try encodeCoordinatorMessage(message) + guard let string = String(data: data, encoding: .utf8) else { + throw ProtocolCodecError.nonUTF8Output + } + return string + } + + public static func decodeCoordinatorMessage(from data: Data) throws -> CoordinatorMessage { + try JSONDecoder().decode(CoordinatorMessage.self, from: data) + } + + public static func decodeCoordinatorMessage(from string: String) throws -> CoordinatorMessage { + guard let data = string.data(using: .utf8) else { + throw ProtocolCodecError.nonUTF8Input + } + return try decodeCoordinatorMessage(from: data) + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + private static func encodeRegisterPreservingRawAttestation( + _ register: ProviderMessage.Register + ) throws -> Data { + var fields: [(String, Data)] = [] + + try fields.append(("type", encodeValue("register"))) + try fields.append(("hardware", encodeValue(register.hardware))) + try fields.append(("models", encodeValue(register.models))) + try fields.append(("backend", encodeValue(register.backend))) + try appendIfPresent(register.version, key: "version", to: &fields) + try appendIfPresent(register.publicKey, key: "public_key", to: &fields) + if register.encryptedResponseChunks { + try fields.append(("encrypted_response_chunks", encodeValue(true))) + } + try appendIfPresent(register.walletAddress, key: "wallet_address", to: &fields) + if let attestation = register.attestation { + try validateRawJSON(attestation.rawBytes) + fields.append(("attestation", attestation.rawBytes)) + } + try appendIfPresent(register.prefillTps, key: "prefill_tps", to: &fields) + try appendIfPresent(register.decodeTps, key: "decode_tps", to: &fields) + try appendIfPresent(register.authToken, key: "auth_token", to: &fields) + try appendIfPresent(register.pythonHash, key: "python_hash", to: &fields) + try appendIfPresent(register.runtimeHash, key: "runtime_hash", to: &fields) + if !register.templateHashes.isEmpty { + try fields.append(("template_hashes", encodeValue(register.templateHashes))) + } + try appendIfPresent(register.privacyCapabilities, key: "privacy_capabilities", to: &fields) + + return makeObject(fields) + } + + private static func appendIfPresent( + _ value: T?, + key: String, + to fields: inout [(String, Data)] + ) throws { + guard let value else { return } + try fields.append((key, encodeValue(value))) + } + + private static func encodeValue(_ value: T) throws -> Data { + try makeEncoder().encode(value) + } + + private static func validateRawJSON(_ data: Data) throws { + _ = try JSONSerialization.jsonObject(with: data) + } + + private static func makeObject(_ fields: [(String, Data)]) -> Data { + var data = Data() + data.appendUTF8("{") + + for (index, field) in fields.enumerated() { + if index > 0 { + data.appendUTF8(",") + } + data.appendUTF8("\"\(field.0)\":") + data.append(field.1) + } + + data.appendUTF8("}") + return data + } +} + +public enum ProtocolCodecError: Error, Equatable { + case nonUTF8Input + case nonUTF8Output +} + +private enum JSONRawValueExtractor { + static func rawValue(forKey targetKey: String, in data: Data) -> Data? { + var parser = Parser(bytes: [UInt8](data)) + return parser.rawTopLevelValue(forKey: targetKey).map { range in + data.subdata(in: range) + } + } + + private struct Parser { + private let bytes: [UInt8] + private var index: Int = 0 + + init(bytes: [UInt8]) { + self.bytes = bytes + } + + mutating func rawTopLevelValue(forKey targetKey: String) -> Range? { + skipWhitespace() + guard consume(UInt8(ascii: "{")) else { return nil } + + while index < bytes.count { + skipWhitespace() + if consume(UInt8(ascii: "}")) { + return nil + } + + guard let key = parseString() else { return nil } + skipWhitespace() + guard consume(UInt8(ascii: ":")) else { return nil } + skipWhitespace() + + let valueStart = index + guard skipValue() else { return nil } + let valueEnd = index + + if key == targetKey { + return valueStart.. Bool { + skipWhitespace() + guard index < bytes.count else { return false } + + switch bytes[index] { + case UInt8(ascii: "{"): + return skipObject() + case UInt8(ascii: "["): + return skipArray() + case UInt8(ascii: "\""): + return parseString() != nil + default: + return skipPrimitive() + } + } + + private mutating func skipObject() -> Bool { + guard consume(UInt8(ascii: "{")) else { return false } + + while index < bytes.count { + skipWhitespace() + if consume(UInt8(ascii: "}")) { + return true + } + + guard parseString() != nil else { return false } + skipWhitespace() + guard consume(UInt8(ascii: ":")) else { return false } + skipWhitespace() + guard skipValue() else { return false } + skipWhitespace() + + if consume(UInt8(ascii: ",")) { + continue + } + if consume(UInt8(ascii: "}")) { + return true + } + } + + return false + } + + private mutating func skipArray() -> Bool { + guard consume(UInt8(ascii: "[")) else { return false } + + while index < bytes.count { + skipWhitespace() + if consume(UInt8(ascii: "]")) { + return true + } + + guard skipValue() else { return false } + skipWhitespace() + + if consume(UInt8(ascii: ",")) { + continue + } + if consume(UInt8(ascii: "]")) { + return true + } + } + + return false + } + + private mutating func skipPrimitive() -> Bool { + let start = index + while index < bytes.count { + switch bytes[index] { + case UInt8(ascii: ","), UInt8(ascii: "}"), UInt8(ascii: "]"), + UInt8(ascii: " "), UInt8(ascii: "\n"), UInt8(ascii: "\r"), UInt8(ascii: "\t"): + return index > start + default: + index += 1 + } + } + return index > start + } + + private mutating func parseString() -> String? { + guard consume(UInt8(ascii: "\"")) else { return nil } + + var scalarBytes: [UInt8] = [] + while index < bytes.count { + let byte = bytes[index] + index += 1 + + if byte == UInt8(ascii: "\"") { + return String(data: Data(scalarBytes), encoding: .utf8) + } + + if byte == UInt8(ascii: "\\") { + guard index < bytes.count else { return nil } + scalarBytes.append(byte) + scalarBytes.append(bytes[index]) + index += 1 + continue + } + + scalarBytes.append(byte) + } + + return nil + } + + private mutating func skipWhitespace() { + while index < bytes.count { + switch bytes[index] { + case UInt8(ascii: " "), UInt8(ascii: "\n"), UInt8(ascii: "\r"), UInt8(ascii: "\t"): + index += 1 + default: + return + } + } + } + + private mutating func consume(_ byte: UInt8) -> Bool { + guard index < bytes.count, bytes[index] == byte else { + return false + } + index += 1 + return true + } + } +} + +private extension Data { + mutating func appendUTF8(_ string: String) { + append(contentsOf: string.utf8) + } +} diff --git a/provider-swift/Sources/ProviderCore/Protocol/Types.swift b/provider-swift/Sources/ProviderCore/Protocol/Types.swift new file mode 100644 index 00000000..1417a8db --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Protocol/Types.swift @@ -0,0 +1,482 @@ +import Foundation + +public struct HardwareInfo: Codable, Sendable, Equatable { + public var machineModel: String + public var chipName: String + public var chipFamily: ChipFamily + public var chipTier: ChipTier + public var memoryGb: UInt64 + public var memoryAvailableGb: UInt64 + public var cpuCores: CpuCores + public var gpuCores: UInt32 + public var memoryBandwidthGbs: UInt32 + + enum CodingKeys: String, CodingKey { + case machineModel = "machine_model" + case chipName = "chip_name" + case chipFamily = "chip_family" + case chipTier = "chip_tier" + case memoryGb = "memory_gb" + case memoryAvailableGb = "memory_available_gb" + case cpuCores = "cpu_cores" + case gpuCores = "gpu_cores" + case memoryBandwidthGbs = "memory_bandwidth_gbs" + } + + public init( + machineModel: String, + chipName: String, + chipFamily: ChipFamily, + chipTier: ChipTier, + memoryGb: UInt64, + memoryAvailableGb: UInt64, + cpuCores: CpuCores, + gpuCores: UInt32, + memoryBandwidthGbs: UInt32 + ) { + self.machineModel = machineModel + self.chipName = chipName + self.chipFamily = chipFamily + self.chipTier = chipTier + self.memoryGb = memoryGb + self.memoryAvailableGb = memoryAvailableGb + self.cpuCores = cpuCores + self.gpuCores = gpuCores + self.memoryBandwidthGbs = memoryBandwidthGbs + } +} + +public struct CpuCores: Codable, Sendable, Equatable { + public var total: UInt32 + public var performance: UInt32 + public var efficiency: UInt32 + + public init(total: UInt32, performance: UInt32, efficiency: UInt32) { + self.total = total + self.performance = performance + self.efficiency = efficiency + } +} + +public struct SystemMetrics: Codable, Sendable, Equatable { + public var memoryPressure: Double + public var cpuUsage: Double + public var thermalState: ThermalState + + enum CodingKeys: String, CodingKey { + case memoryPressure = "memory_pressure" + case cpuUsage = "cpu_usage" + case thermalState = "thermal_state" + } + + public init(memoryPressure: Double, cpuUsage: Double, thermalState: ThermalState) { + self.memoryPressure = memoryPressure + self.cpuUsage = cpuUsage + self.thermalState = thermalState + } +} + +public struct ModelInfo: Codable, Sendable, Equatable { + public var id: String + public var modelType: String? + public var parameters: UInt64? + public var quantization: String? + public var sizeBytes: UInt64 + public var estimatedMemoryGb: Double + public var weightHash: String? + + enum CodingKeys: String, CodingKey { + case id + case modelType = "model_type" + case parameters + case quantization + case sizeBytes = "size_bytes" + case estimatedMemoryGb = "estimated_memory_gb" + case weightHash = "weight_hash" + } + + public init( + id: String, + modelType: String? = nil, + parameters: UInt64? = nil, + quantization: String? = nil, + sizeBytes: UInt64, + estimatedMemoryGb: Double, + weightHash: String? = nil + ) { + self.id = id + self.modelType = modelType + self.parameters = parameters + self.quantization = quantization + self.sizeBytes = sizeBytes + self.estimatedMemoryGb = estimatedMemoryGb + self.weightHash = weightHash + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(modelType, forKey: .modelType) + try container.encodeIfPresent(parameters, forKey: .parameters) + try container.encodeIfPresent(quantization, forKey: .quantization) + try container.encode(sizeBytes, forKey: .sizeBytes) + try container.encode(estimatedMemoryGb, forKey: .estimatedMemoryGb) + try container.encodeIfPresent(weightHash, forKey: .weightHash) + } +} + +public struct ProviderStats: Codable, Sendable, Equatable { + public var requestsServed: UInt64 + public var tokensGenerated: UInt64 + + enum CodingKeys: String, CodingKey { + case requestsServed = "requests_served" + case tokensGenerated = "tokens_generated" + } + + public init(requestsServed: UInt64 = 0, tokensGenerated: UInt64 = 0) { + self.requestsServed = requestsServed + self.tokensGenerated = tokensGenerated + } +} + +public struct UsageInfo: Codable, Sendable, Equatable { + public var promptTokens: UInt64 + public var completionTokens: UInt64 + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + } + + public init(promptTokens: UInt64, completionTokens: UInt64) { + self.promptTokens = promptTokens + self.completionTokens = completionTokens + } +} + +public struct EncryptedPayload: Codable, Sendable, Equatable { + public var ephemeralPublicKey: String + public var ciphertext: String + + enum CodingKeys: String, CodingKey { + case ephemeralPublicKey = "ephemeral_public_key" + case ciphertext + } + + public init(ephemeralPublicKey: String, ciphertext: String) { + self.ephemeralPublicKey = ephemeralPublicKey + self.ciphertext = ciphertext + } +} + +public struct PrivacyCapabilities: Codable, Sendable, Equatable { + public var textBackendInprocess: Bool + public var textProxyDisabled: Bool + public var pythonRuntimeLocked: Bool + public var dangerousModulesBlocked: Bool + public var sipEnabled: Bool + public var antiDebugEnabled: Bool + public var coreDumpsDisabled: Bool + public var envScrubbed: Bool + public var hypervisorActive: Bool + + enum CodingKeys: String, CodingKey { + case textBackendInprocess = "text_backend_inprocess" + case textProxyDisabled = "text_proxy_disabled" + case pythonRuntimeLocked = "python_runtime_locked" + case dangerousModulesBlocked = "dangerous_modules_blocked" + case sipEnabled = "sip_enabled" + case antiDebugEnabled = "anti_debug_enabled" + case coreDumpsDisabled = "core_dumps_disabled" + case envScrubbed = "env_scrubbed" + case hypervisorActive = "hypervisor_active" + } + + public init( + textBackendInprocess: Bool, + textProxyDisabled: Bool, + pythonRuntimeLocked: Bool, + dangerousModulesBlocked: Bool, + sipEnabled: Bool, + antiDebugEnabled: Bool, + coreDumpsDisabled: Bool, + envScrubbed: Bool, + hypervisorActive: Bool = false + ) { + self.textBackendInprocess = textBackendInprocess + self.textProxyDisabled = textProxyDisabled + self.pythonRuntimeLocked = pythonRuntimeLocked + self.dangerousModulesBlocked = dangerousModulesBlocked + self.sipEnabled = sipEnabled + self.antiDebugEnabled = antiDebugEnabled + self.coreDumpsDisabled = coreDumpsDisabled + self.envScrubbed = envScrubbed + self.hypervisorActive = hypervisorActive + } +} + +public struct RuntimeMismatch: Codable, Sendable, Equatable { + public var component: String + public var expected: String + public var got: String + + public init(component: String, expected: String, got: String) { + self.component = component + self.expected = expected + self.got = got + } +} + +public struct BackendSlotCapacity: Codable, Sendable, Equatable { + public var model: String + public var state: String + public var numRunning: UInt32 + public var numWaiting: UInt32 + public var activeTokens: Int64 + public var maxTokensPotential: Int64 + + enum CodingKeys: String, CodingKey { + case model + case state + case numRunning = "num_running" + case numWaiting = "num_waiting" + case activeTokens = "active_tokens" + case maxTokensPotential = "max_tokens_potential" + } + + public init( + model: String, + state: String, + numRunning: UInt32, + numWaiting: UInt32, + activeTokens: Int64, + maxTokensPotential: Int64 + ) { + self.model = model + self.state = state + self.numRunning = numRunning + self.numWaiting = numWaiting + self.activeTokens = activeTokens + self.maxTokensPotential = maxTokensPotential + } +} + +public struct BackendCapacity: Codable, Sendable, Equatable { + public var slots: [BackendSlotCapacity] + public var gpuMemoryActiveGb: Double + public var gpuMemoryPeakGb: Double + public var gpuMemoryCacheGb: Double + public var totalMemoryGb: Double + + enum CodingKeys: String, CodingKey { + case slots + case gpuMemoryActiveGb = "gpu_memory_active_gb" + case gpuMemoryPeakGb = "gpu_memory_peak_gb" + case gpuMemoryCacheGb = "gpu_memory_cache_gb" + case totalMemoryGb = "total_memory_gb" + } + + public init( + slots: [BackendSlotCapacity], + gpuMemoryActiveGb: Double, + gpuMemoryPeakGb: Double, + gpuMemoryCacheGb: Double, + totalMemoryGb: Double + ) { + self.slots = slots + self.gpuMemoryActiveGb = gpuMemoryActiveGb + self.gpuMemoryPeakGb = gpuMemoryPeakGb + self.gpuMemoryCacheGb = gpuMemoryCacheGb + self.totalMemoryGb = totalMemoryGb + } +} + +/// Opaque JSON blob preserved as raw bytes for signature verification. +/// The coordinator verifies the attestation signature against the exact bytes +/// produced by the Swift enclave CLI -- any re-encoding would break verification. +public struct RawJSON: Sendable, Equatable { + public let rawBytes: Data + + public init(rawBytes: Data) { + self.rawBytes = rawBytes + } + + public var string: String { + String(data: rawBytes, encoding: .utf8) ?? "" + } +} + +extension RawJSON: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let jsonObject = try container.decode(JSONValue.self) + let data = try JSONSerialization.data( + withJSONObject: jsonObject.toFoundation(), + options: [.sortedKeys, .withoutEscapingSlashes] + ) + self.rawBytes = data + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + let jsonObject = try JSONSerialization.jsonObject(with: rawBytes) + let value = JSONValue.fromFoundation(jsonObject) + try container.encode(value) + } +} + +/// Minimal JSON value type for capturing arbitrary JSON without structure loss. +/// Used both for RawJSON (attestation blobs) and for the InferenceRequest body +/// field which is an opaque serde_json::Value in the Rust source. +public enum JSONValue: Codable, Sendable, Equatable { + case null + case bool(Bool) + case int(Int64) + case double(Double) + case string(String) + case array([JSONValue]) + case object([(String, JSONValue)]) + + public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { + switch (lhs, rhs) { + case (.null, .null): + return true + case (.bool(let a), .bool(let b)): + return a == b + case (.int(let a), .int(let b)): + return a == b + case (.double(let a), .double(let b)): + return a == b + case (.string(let a), .string(let b)): + return a == b + case (.array(let a), .array(let b)): + return a == b + case (.object(let a), .object(let b)): + guard a.count == b.count else { return false } + for (pair, other) in zip(a, b) { + if pair.0 != other.0 || pair.1 != other.1 { return false } + } + return true + default: + return false + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int64.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if var arr = try? decoder.unkeyedContainer() { + var values: [JSONValue] = [] + while !arr.isAtEnd { + values.append(try arr.decode(JSONValue.self)) + } + self = .array(values) + } else { + let obj = try decoder.container(keyedBy: DynamicCodingKey.self) + var pairs: [(String, JSONValue)] = [] + for key in obj.allKeys { + pairs.append((key.stringValue, try obj.decode(JSONValue.self, forKey: key))) + } + self = .object(pairs) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let b): + try container.encode(b) + case .int(let i): + try container.encode(i) + case .double(let d): + try container.encode(d) + case .string(let s): + try container.encode(s) + case .array(let arr): + try container.encode(arr) + case .object(let pairs): + var obj = encoder.container(keyedBy: DynamicCodingKey.self) + for (key, value) in pairs { + try obj.encode(value, forKey: DynamicCodingKey(stringValue: key)) + } + } + } + + func toFoundation() -> Any { + switch self { + case .null: return NSNull() + case .bool(let b): return b + case .int(let i): return i + case .double(let d): return d + case .string(let s): return s + case .array(let arr): return arr.map { $0.toFoundation() } + case .object(let pairs): + var dict: [String: Any] = [:] + for (k, v) in pairs { dict[k] = v.toFoundation() } + return dict + } + } + + static func fromFoundation(_ value: Any) -> JSONValue { + switch value { + case is NSNull: + return .null + case let b as Bool: + return .bool(b) + case let n as NSNumber: + if CFNumberGetType(n) == .float32Type || CFNumberGetType(n) == .float64Type + || CFNumberGetType(n) == .doubleType + { + return .double(n.doubleValue) + } + return .int(n.int64Value) + case let s as String: + return .string(s) + case let arr as [Any]: + return .array(arr.map { fromFoundation($0) }) + case let dict as [String: Any]: + return .object( + dict.sorted(by: { $0.key < $1.key }).map { ($0.key, fromFoundation($0.value)) } + ) + default: + return .null + } + } + + public subscript(key: String) -> JSONValue? { + guard case .object(let pairs) = self else { return nil } + return pairs.first(where: { $0.0 == key })?.1 + } + + public var isNull: Bool { + if case .null = self { return true } + return false + } +} + +struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/provider-swift/Sources/ProviderCore/ProviderCore.swift b/provider-swift/Sources/ProviderCore/ProviderCore.swift new file mode 100644 index 00000000..2f60f7d7 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/ProviderCore.swift @@ -0,0 +1,9 @@ +import Foundation +import MLX +import MLXNN +import MLXLLM +import MLXLMCommon + +public enum ProviderCore { + public static let version = "0.4.0-swift" +} diff --git a/provider-swift/Sources/ProviderCore/ProviderLoop.swift b/provider-swift/Sources/ProviderCore/ProviderLoop.swift new file mode 100644 index 00000000..d2ce8b9a --- /dev/null +++ b/provider-swift/Sources/ProviderCore/ProviderLoop.swift @@ -0,0 +1,638 @@ +/// ProviderLoop -- the main event loop that ties all subsystems together. +/// +/// Owns the CoordinatorClient, BatchScheduler, NodeKeyPair, and +/// SecureEnclaveIdentity. Processes coordinator events: inference requests, +/// cancellations, attestation challenges, and connection lifecycle. +/// +/// Each inference request spawns its own Task for concurrent processing. +/// The BatchScheduler manages admission control and model loading. +/// Responses are encrypted with the consumer's ephemeral public key +/// and streamed back through the coordinator. + +import CryptoKit +import Foundation +#if canImport(os) +import os +#endif + +// MARK: - SendHandle (Sendable wrapper for the coordinator send function) + +/// Wraps the coordinator's outbound send function so it can be captured in +/// Tasks and closures that require `Sendable`. The underlying function is +/// thread-safe (it yields into an `AsyncStream.Continuation`) but its type +/// signature from `CoordinatorClient.start()` does not carry `@Sendable`. +public final class SendHandle: @unchecked Sendable { + private let fn: (OutboundMessage) -> Void + + public init(_ fn: @escaping (OutboundMessage) -> Void) { + self.fn = fn + } + + public func send(_ message: OutboundMessage) { + fn(message) + } +} + +private enum ProviderLoopError: Error, CustomStringConvertible { + case binaryHashUnavailable + + var description: String { + switch self { + case .binaryHashUnavailable: + return "provider binary hash could not be computed" + } + } +} + +// MARK: - Configuration + +public struct ProviderLoopConfig: Sendable { + public let coordinatorURL: String + public let hardware: HardwareInfo + public let models: [ModelInfo] + public let config: ProviderConfig + public let authToken: String? + public let runtimeHashes: RuntimeHashes? + public let modelHashes: [String: String] + + public init( + coordinatorURL: String, + hardware: HardwareInfo, + models: [ModelInfo], + config: ProviderConfig, + authToken: String? = nil, + runtimeHashes: RuntimeHashes? = nil, + modelHashes: [String: String] = [:] + ) { + self.coordinatorURL = coordinatorURL + self.hardware = hardware + self.models = models + self.config = config + self.authToken = authToken + self.runtimeHashes = runtimeHashes + self.modelHashes = modelHashes + } +} + +// MARK: - ProviderLoop + +public actor ProviderLoop { + private let loopConfig: ProviderLoopConfig + private let keyPair: NodeKeyPair + private let seIdentity: SecureEnclaveIdentity? + private let attestationBuilder: AttestationBuilder? + private let scheduler: BatchScheduler + private let stats: AtomicProviderStats + private let state: ProviderState + private let cancellationRegistry: InferenceCancellationRegistry + + /// Tracks in-flight inference tasks by request ID so they can be cancelled. + private var inflightTasks: [String: Task] = [:] + + /// Cached security posture from startup verification. + private var securityPosture: SecurityPosture? + + /// Cached binary hash for attestation responses. + private var binaryHash: String? + + private let logger = ProviderLogger(subsystem: "dev.darkbloom.provider", category: "loop") + + // MARK: - Initialization + + public init(config: ProviderLoopConfig) throws { + self.loopConfig = config + NodeKeyPair.purgeLegacyFiles() + self.keyPair = NodeKeyPair.generate() + self.seIdentity = try SecureEnclaveIdentity.createEphemeral() + self.attestationBuilder = seIdentity.map { AttestationBuilder(identity: $0) } + self.stats = AtomicProviderStats() + self.state = ProviderState() + self.cancellationRegistry = InferenceCancellationRegistry() + self.scheduler = BatchScheduler( + maxConcurrentRequests: 4, + pendingTimeout: .seconds(120), + defaultMaxTokens: 4096 + ) + } + + // MARK: - Main Run Loop + + public func run() async throws { + logger.info("darkbloom \(ProviderCore.version) starting") + logger.info("Hardware: \(loopConfig.hardware.chipName), \(loopConfig.hardware.memoryGb) GB RAM, \(loopConfig.hardware.gpuCores) GPU cores") + logger.info("Models: \(loopConfig.models.count) advertised") + logger.info("Coordinator: \(loopConfig.coordinatorURL)") + + // 1. Apply security hardening + try await applySecurityHardening() + + // 2. Build attestation blob for registration + let attestation = buildRegistrationAttestation() + + // 3. Create coordinator client config + let coordinatorConfig = CoordinatorClientConfig( + url: loopConfig.coordinatorURL, + hardware: loopConfig.hardware, + models: loopConfig.models, + backendName: "mlx-swift", + heartbeatInterval: TimeInterval(loopConfig.config.coordinator.heartbeatIntervalSecs), + publicKey: keyPair.publicKeyBase64, + walletAddress: nil, + attestation: attestation, + authToken: loopConfig.authToken, + runtimeHashes: loopConfig.runtimeHashes, + modelHashes: loopConfig.modelHashes, + privacyCapabilities: privacyCapabilitiesForRegistration() + ) + + // 4. Create coordinator client and start connection + let coordinator = CoordinatorClient( + config: coordinatorConfig, + stats: stats, + state: state + ) + + let (events, sendFn) = await coordinator.start() + let send = SendHandle(sendFn) + + logger.info("Coordinator client started, entering event loop") + + // 5. Process events + for await event in events { + switch event { + case .connected: + logger.info("Connected to coordinator") + + case .disconnected: + logger.warning("Disconnected from coordinator") + // Cancel all in-flight requests on disconnect -- the coordinator + // will not route responses for a dead connection. + await cancelAllInflight() + + case .inferenceRequest(let requestId, let body, let responsePublicKey): + await handleInferenceRequest( + requestId: requestId, + body: body, + responsePublicKey: responsePublicKey, + send: send + ) + + case .cancel(let requestId): + await handleCancellation(requestId: requestId) + + case .attestationChallenge(let nonce, let timestamp): + await handleAttestationChallenge( + nonce: nonce, + timestamp: timestamp, + send: send + ) + + case .runtimeOutdated(let mismatches): + logger.warning("Runtime outdated: \(mismatches.count) mismatch(es)") + for m in mismatches { + logger.warning(" \(m.component): expected=\(m.expected), got=\(m.got)") + } + } + } + + logger.info("Event stream ended, shutting down") + await coordinator.shutdown() + await cancelAllInflight() + } + + // MARK: - Security Hardening + + private func applySecurityHardening() async throws { + #if !DEBUG + let posture = try verifySecurityPosture() + guard let binaryHash = posture.binaryHash, !binaryHash.isEmpty else { + logger.error("Security hardening failed: provider binary hash unavailable") + throw ProviderLoopError.binaryHashUnavailable + } + self.securityPosture = posture + self.binaryHash = binaryHash + logger.info("Security posture verified: SIP=\(posture.sipEnabled), RDMA_disabled=\(posture.rdmaDisabled), SE=\(SecureEnclave.isAvailable)") + #else + logger.info("Security hardening skipped in DEBUG mode") + self.binaryHash = selfBinaryHash() + #endif + } + + private func privacyCapabilitiesForRegistration() -> PrivacyCapabilities { + if let posture = securityPosture { + return PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: posture.sipEnabled, + antiDebugEnabled: posture.antiDebugEnabled, + coreDumpsDisabled: posture.coreDumpsDisabled, + envScrubbed: posture.envScrubbed, + hypervisorActive: false + ) + } + + return PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: SecurityChecks.isSIPEnabled(), + antiDebugEnabled: false, + coreDumpsDisabled: false, + envScrubbed: false, + hypervisorActive: SecurityChecks.isHypervisorActive() + ) + } + + // MARK: - Attestation + + private func buildRegistrationAttestation() -> RawJSON? { + guard let builder = attestationBuilder else { + logger.info("No Secure Enclave identity -- registration without attestation") + return nil + } + do { + let jsonData = try builder.buildAttestationJSON( + encryptionPublicKey: keyPair.publicKeyBase64, + binaryHash: binaryHash + ) + return RawJSON(rawBytes: jsonData) + } catch { + logger.error("Failed to build attestation: \(error)") + return nil + } + } + + // MARK: - Inference Request Handling + + private func handleInferenceRequest( + requestId: String, + body: Data, + responsePublicKey: [UInt8]?, + send: SendHandle + ) async { + logger.info("Processing inference request: \(requestId)") + + // 1. Decrypt the request body + let decryptedData: Data + do { + // The body arrives as a base64-encoded ciphertext string from + // CoordinatorClient.handleIncomingText which wraps + // encrypted.ciphertext.utf8 into Data. We need to reconstruct + // the EncryptedPayload to decrypt it properly. + guard let responseKey = responsePublicKey else { + logger.error("[\(requestId)] No response public key for encrypted request") + send.send(.inferenceError(requestId: requestId, error: "missing response public key", statusCode: 400)) + return + } + + // The body is the base64 ciphertext string. The ephemeral public key + // was already extracted by CoordinatorClient and passed as responsePublicKey. + // However, looking at CoordinatorClient.handleIncomingText more carefully: + // it passes Data(encrypted.ciphertext.utf8) as body and the ephemeralPublicKey + // decoded as responsePublicKey. The ciphertext is base64, so we need to + // base64-decode it, then use the ephemeral key to decrypt. + guard let ciphertextBase64String = String(data: body, encoding: .utf8), + let ciphertextData = Data(base64Encoded: ciphertextBase64String) else { + logger.error("[\(requestId)] Failed to decode ciphertext from base64") + send.send(.inferenceError(requestId: requestId, error: "invalid ciphertext encoding", statusCode: 400)) + return + } + + let senderPublicKey = Data(responseKey) + decryptedData = try keyPair.decrypt(senderPublicKey: senderPublicKey, ciphertext: ciphertextData) + } catch { + logger.error("[\(requestId)] Decryption failed: \(error)") + send.send(.inferenceError(requestId: requestId, error: "decryption failed", statusCode: 400)) + return + } + + // 2. Parse the chat completion request + let chatRequest: ChatCompletionRequest + do { + chatRequest = try JSONDecoder().decode(ChatCompletionRequest.self, from: decryptedData) + } catch { + logger.error("[\(requestId)] Failed to parse chat request: \(error)") + send.send(.inferenceError(requestId: requestId, error: "invalid request body: \(error.localizedDescription)", statusCode: 400)) + return + } + + // 3. Send inference_accepted + send.send(.inferenceAccepted(requestId: requestId)) + + // 4. Ensure model is loaded + let modelId = chatRequest.model + do { + try await ensureModelLoaded(modelId: modelId) + } catch { + logger.error("[\(requestId)] Failed to load model '\(modelId)': \(error)") + send.send(.inferenceError(requestId: requestId, error: "model load failed: \(error.localizedDescription)", statusCode: 500)) + return + } + + // 5. Register cancellation token + let token = await cancellationRegistry.register(requestId: requestId) + + // 6. Capture values for the spawned task + let responsePublicKeyData: Data? = responsePublicKey.map { Data($0) } + let kp = self.keyPair + let sched = self.scheduler + let providerStats = self.stats + let providerState = self.state + let registry = self.cancellationRegistry + let signingIdentity = self.seIdentity + let log = self.logger + + // 7. Spawn inference task + let task = Task.detached { + defer { + Task { await registry.finish(requestId: requestId) } + } + + providerState.inferenceActive = true + + var usageAccumulator = UsageAccumulator() + var fullResponseText = "" + let formatter = ChatSSEFormatter() + let responseId = "chatcmpl-\(UUID().uuidString.prefix(12).lowercased())" + let created = Int(Date().timeIntervalSince1970) + + let emitSSE: @Sendable (String) -> Void = { sseData in + var encryptedPayload: EncryptedPayload? + if let recipientKey = responsePublicKeyData { + do { + encryptedPayload = try kp.encryptPayload( + recipientPublicKey: recipientKey, + plaintext: Data(sseData.utf8) + ) + } catch { + log.warning("[\(requestId)] Chunk encryption failed: \(error)") + } + } + + send.send(.inferenceChunk( + requestId: requestId, + data: encryptedPayload != nil ? "" : sseData, + encryptedData: encryptedPayload + )) + } + + if let roleChunk = try? formatter.roleChunk( + id: responseId, + model: chatRequest.model, + created: created + ) { + emitSSE(roleChunk.formatted) + } + + // Submit to the BatchScheduler + let generationStream = await sched.submit( + request: chatRequest, + requestId: requestId + ) + + for await event in generationStream { + // Check cancellation + if token.isCancelled { + log.info("[\(requestId)] Cancelled during generation") + send.send(.inferenceError(requestId: requestId, error: "request cancelled", statusCode: 499)) + return + } + + switch event { + case .chunk(let text): + fullResponseText += text + usageAccumulator.recordCompletionChunk() + + if let contentChunk = try? formatter.contentChunk( + id: responseId, + model: chatRequest.model, + created: created, + text: text + ) { + emitSSE(contentChunk.formatted) + } + + case .info(let promptTokens, let completionTokens, _): + usageAccumulator.setPromptTokens(promptTokens) + usageAccumulator.setCompletionTokens(completionTokens) + + case .error(let errorMessage): + log.error("[\(requestId)] Generation error: \(errorMessage)") + send.send(.inferenceError(requestId: requestId, error: errorMessage, statusCode: 500)) + return + } + } + + // Generation complete + let usage = usageAccumulator.snapshot + if let finishChunk = try? formatter.finishChunk( + id: responseId, + model: chatRequest.model, + created: created, + reason: .stop, + usage: usage + ) { + emitSSE(finishChunk.formatted) + emitSSE(SSEChunk.done.formatted) + } + + // Update stats + providerStats.incrementRequestsServed() + providerStats.addTokensGenerated(UInt64(usage.completionTokens)) + + // Update state + let cap = await sched.backendCapacity() + providerState.backendCapacity = cap + if await sched.capacity().activeRequests == 0 { + providerState.inferenceActive = false + } + + // Send completion + let attestation = computeResponseAttestation( + identity: signingIdentity, + requestId: requestId, + completionTokens: UInt64(max(usage.completionTokens, 0)), + responseBody: fullResponseText + ) + send.send(.inferenceComplete( + requestId: requestId, + usage: usage.protocolUsageInfo, + seSignature: attestation.signature, + responseHash: attestation.hash + )) + + log.info("[\(requestId)] Complete: \(usage.promptTokens) prompt + \(usage.completionTokens) completion tokens") + } + + inflightTasks[requestId] = task + } + + // MARK: - Model Loading + + private func ensureModelLoaded(modelId: String) async throws { + // Check if already loaded + if state.currentModel == modelId { + return + } + + // Resolve local path + guard let modelPath = ModelScanner.resolveLocalPath(modelID: modelId) else { + throw InferenceError.invalidModelDirectory( + "Model '\(modelId)' not found in local HuggingFace cache" + ) + } + + logger.info("Loading model: \(modelId) from \(modelPath.path)") + + let container = try await loadModelContainer(from: modelPath) + await scheduler.loadModel(container: container, modelId: modelId) + + // Update shared state + state.currentModel = modelId + state.warmModels = [modelId] + state.currentModelHash = loopConfig.modelHashes[modelId] + + logger.info("Model loaded: \(modelId)") + } + + private func loadModelContainer(from directory: URL) async throws -> MLXLMCommon.ModelContainer { + try await LLMModelFactory.shared.loadContainer( + from: directory, + using: LocalTokenizerLoader() + ) + } + + // MARK: - Cancellation + + private func handleCancellation(requestId: String) async { + logger.info("Cancelling request: \(requestId)") + + // Cancel in the registry (triggers the token) + await cancellationRegistry.cancel(requestId: requestId) + + // Cancel in the scheduler + await scheduler.cancel(requestId: requestId) + + // Cancel the inflight task + if let task = inflightTasks.removeValue(forKey: requestId) { + task.cancel() + } + } + + private func cancelAllInflight() async { + let requestIds = Array(inflightTasks.keys) + for requestId in requestIds { + await handleCancellation(requestId: requestId) + } + inflightTasks.removeAll() + } + + private func removeInflightTask(requestId: String) { + inflightTasks.removeValue(forKey: requestId) + } + + // MARK: - Attestation Challenge + + private func handleAttestationChallenge( + nonce: String, + timestamp: String, + send: SendHandle + ) async { + logger.info("Handling attestation challenge (timestamp: \(timestamp))") + + guard let builder = attestationBuilder else { + logger.warning("No Secure Enclave identity -- cannot respond to attestation challenge") + return + } + + do { + let activeModelHash = state.currentModel.flatMap { modelId in + loopConfig.modelHashes[modelId] + } + + let response = try builder.buildChallengeResponse( + nonce: nonce, + timestamp: timestamp, + providerPublicKey: keyPair.publicKeyBase64, + binaryHash: binaryHash, + activeModelHash: activeModelHash, + runtimeHashes: loopConfig.runtimeHashes, + modelHashes: loopConfig.modelHashes + ) + + send.send(.attestationResponse(AttestationResponsePayload( + nonce: response.nonce, + signature: response.signature, + statusSignature: response.statusSignature, + publicKey: response.publicKey, + hypervisorActive: response.hypervisorActive, + rdmaDisabled: response.rdmaDisabled, + sipEnabled: response.sipEnabled, + secureBootEnabled: response.secureBootEnabled, + binaryHash: response.binaryHash, + activeModelHash: response.activeModelHash, + pythonHash: response.pythonHash, + runtimeHash: response.runtimeHash, + templateHashes: response.templateHashes, + modelHashes: response.modelHashes + ))) + + logger.info("Attestation challenge response sent") + } catch { + logger.error("Failed to sign attestation challenge: \(error)") + } + } + + // MARK: - Helpers + +} + +// MARK: - Logger wrapper + +/// Unified logger that uses os.Logger on macOS. +private struct ProviderLogger: Sendable { + #if canImport(os) + private let osLogger: os.Logger + #endif + private let category: String + + init(subsystem: String, category: String) { + self.category = category + #if canImport(os) + self.osLogger = os.Logger(subsystem: subsystem, category: category) + #endif + } + + func info(_ message: String) { + #if canImport(os) + osLogger.info("\(message, privacy: .public)") + #else + print("[\(category)] INFO: \(message)") + #endif + } + + func warning(_ message: String) { + #if canImport(os) + osLogger.warning("\(message, privacy: .public)") + #else + print("[\(category)] WARN: \(message)") + #endif + } + + func error(_ message: String) { + #if canImport(os) + osLogger.error("\(message, privacy: .public)") + #else + print("[\(category)] ERROR: \(message)") + #endif + } +} + +// MARK: - Import bridge + +import MLX +import MLXLLM +import MLXLMCommon diff --git a/provider-swift/Sources/ProviderCore/Scheduling/Schedule.swift b/provider-swift/Sources/ProviderCore/Scheduling/Schedule.swift new file mode 100644 index 00000000..42a9d68b --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Scheduling/Schedule.swift @@ -0,0 +1,359 @@ +/// Provider scheduling -- time-based availability windows. +/// +/// Users configure when their machine should serve inference requests. +/// Outside scheduled windows the provider disconnects from the coordinator +/// and shuts down the backend to free GPU memory. +/// +/// Schedule windows support: +/// - Day-of-week selection (Mon-Sun) +/// - Start/end times in 24h local time +/// - Overnight windows (e.g. 22:00-08:00 = serve overnight) +/// - Multiple windows (e.g. weekday evenings + all weekend) +/// +/// When no schedule is configured or scheduling is disabled the provider +/// is always available (current default behavior). + +import Foundation + +// MARK: - Config types (serializable) + +/// A single availability window as stored in config. +public struct ScheduleWindow: Codable, Sendable, Equatable { + /// Days this window applies to (e.g. ["mon", "tue", "wed"]). + public var days: [String] + /// Start time in HH:MM 24h format. + public var start: String + /// End time in HH:MM 24h format. If end < start, wraps overnight. + public var end: String + + public init(days: [String], start: String, end: String) { + self.days = days + self.start = start + self.end = end + } +} + +/// Schedule configuration as stored in the config file. +public struct ScheduleConfig: Codable, Sendable, Equatable { + public var enabled: Bool + public var windows: [ScheduleWindow] + + public init(enabled: Bool = false, windows: [ScheduleWindow] = []) { + self.enabled = enabled + self.windows = windows + } +} + +// MARK: - Day of week + +/// Days of the week, matching the Rust implementation. +public enum DayOfWeek: Int, Sendable, CaseIterable { + case monday = 0 + case tuesday = 1 + case wednesday = 2 + case thursday = 3 + case friday = 4 + case saturday = 5 + case sunday = 6 + + /// The Foundation weekday index (Calendar component: 1=Sunday, 2=Monday, ..., 7=Saturday). + var foundationWeekday: Int { + switch self { + case .monday: return 2 + case .tuesday: return 3 + case .wednesday: return 4 + case .thursday: return 5 + case .friday: return 6 + case .saturday: return 7 + case .sunday: return 1 + } + } + + /// Create from Foundation Calendar weekday (1=Sunday .. 7=Saturday). + static func fromFoundationWeekday(_ weekday: Int) -> DayOfWeek? { + switch weekday { + case 1: return .sunday + case 2: return .monday + case 3: return .tuesday + case 4: return .wednesday + case 5: return .thursday + case 6: return .friday + case 7: return .saturday + default: return nil + } + } + + /// Parse a day name string (case-insensitive). Accepts short and full names. + public static func parse(_ s: String) -> DayOfWeek? { + switch s.lowercased() { + case "mon", "monday": return .monday + case "tue", "tuesday": return .tuesday + case "wed", "wednesday": return .wednesday + case "thu", "thursday": return .thursday + case "fri", "friday": return .friday + case "sat", "saturday": return .saturday + case "sun", "sunday": return .sunday + default: return nil + } + } + + /// Three-letter abbreviation. + public var abbreviation: String { + switch self { + case .monday: return "Mon" + case .tuesday: return "Tue" + case .wednesday: return "Wed" + case .thursday: return "Thu" + case .friday: return "Fri" + case .saturday: return "Sat" + case .sunday: return "Sun" + } + } + + /// The previous day of the week. + public var previous: DayOfWeek { + DayOfWeek(rawValue: (rawValue + 6) % 7)! + } + + /// Advance by `n` days (mod 7). + public func adding(_ n: Int) -> DayOfWeek { + DayOfWeek(rawValue: (rawValue + (n % 7) + 7) % 7)! + } +} + +// MARK: - Time of day + +/// A time within a day, represented as hours and minutes (24h format). +/// Independent of any timezone -- just a wall-clock reading. +public struct TimeOfDay: Sendable, Equatable, Comparable { + public let hour: Int + public let minute: Int + + /// Total seconds since midnight. + public var totalSeconds: Int { hour * 3600 + minute * 60 } + + public init(hour: Int, minute: Int) { + precondition(hour >= 0 && hour <= 23, "hour must be 0-23") + precondition(minute >= 0 && minute <= 59, "minute must be 0-59") + self.hour = hour + self.minute = minute + } + + /// Parse from "HH:MM" format. Returns nil on invalid input. + public static func parse(_ s: String) -> TimeOfDay? { + let parts = s.split(separator: ":") + guard parts.count == 2, + let h = Int(parts[0]), + let m = Int(parts[1]), + h >= 0, h <= 23, + m >= 0, m <= 59 + else { return nil } + return TimeOfDay(hour: h, minute: m) + } + + public static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool { + lhs.totalSeconds < rhs.totalSeconds + } + + public var description: String { + String(format: "%02d:%02d", hour, minute) + } +} + +// MARK: - Parsed schedule + +/// A parsed window ready for time evaluation. +struct ParsedWindow: Sendable { + let days: [DayOfWeek] + let start: TimeOfDay + let end: TimeOfDay + /// True when end <= start (e.g. 22:00-08:00 = serve overnight). + let overnight: Bool +} + +/// A fully-parsed schedule ready for `isActiveNow()` checks. +/// +/// Create via `Schedule.from(config:)`. If scheduling is disabled or no +/// valid windows exist, `from(config:)` returns nil -- meaning "always available". +public struct Schedule: Sendable { + let windows: [ParsedWindow] + + /// Parse a `ScheduleConfig` into a `Schedule`. + /// + /// Returns nil when scheduling is disabled or no valid windows can be parsed, + /// which means "always available" (no scheduling constraint). + public static func from(config: ScheduleConfig) -> Schedule? { + guard config.enabled, !config.windows.isEmpty else { return nil } + + var parsed: [ParsedWindow] = [] + for window in config.windows { + let days = window.days.compactMap { DayOfWeek.parse($0) } + guard !days.isEmpty else { continue } + guard let start = TimeOfDay.parse(window.start) else { continue } + guard let end = TimeOfDay.parse(window.end) else { continue } + + let overnight = end <= start + parsed.append(ParsedWindow(days: days, start: start, end: end, overnight: overnight)) + } + + guard !parsed.isEmpty else { return nil } + return Schedule(windows: parsed) + } + + /// Check whether the current local time falls within any scheduled window. + public func isActiveNow() -> Bool { + isActive(at: Date()) + } + + /// Check whether a specific date falls within any scheduled window. + /// Exposed for testing with deterministic times. + public func isActive(at date: Date) -> Bool { + let calendar = Calendar.current + let components = calendar.dateComponents([.weekday, .hour, .minute], from: date) + guard let weekday = components.weekday, + let hour = components.hour, + let minute = components.minute, + let today = DayOfWeek.fromFoundationWeekday(weekday) + else { return false } + + let now = TimeOfDay(hour: hour, minute: minute) + let yesterday = today.previous + + for w in windows { + if w.overnight { + // Overnight window (e.g. 22:00-08:00): + // Active if: (today in days AND time >= start) + // OR (yesterday in days AND time < end) + if w.days.contains(today) && now >= w.start { + return true + } + if w.days.contains(yesterday) && now < w.end { + return true + } + } else { + // Same-day window (e.g. 09:00-17:00): + if w.days.contains(today) && now >= w.start && now < w.end { + return true + } + } + } + + return false + } + + /// How long until the current active window ends. + /// Returns nil if not currently active. + public func durationUntilInactive(from date: Date = Date()) -> TimeInterval? { + let calendar = Calendar.current + let components = calendar.dateComponents([.weekday, .hour, .minute, .second], from: date) + guard let weekday = components.weekday, + let hour = components.hour, + let minute = components.minute, + let second = components.second, + let today = DayOfWeek.fromFoundationWeekday(weekday) + else { return nil } + + let now = TimeOfDay(hour: hour, minute: minute) + let nowSeconds = hour * 3600 + minute * 60 + second + let yesterday = today.previous + + for w in windows { + if w.overnight { + if w.days.contains(today) && now >= w.start { + // Window ends tomorrow at w.end + let remainingToday = 86400 - nowSeconds + let intoTomorrow = w.end.totalSeconds + return TimeInterval(remainingToday + intoTomorrow) + } + if w.days.contains(yesterday) && now < w.end { + // Window ends today at w.end + let diff = w.end.totalSeconds - nowSeconds + return TimeInterval(diff) + } + } else if w.days.contains(today) && now >= w.start && now < w.end { + let diff = w.end.totalSeconds - nowSeconds + return TimeInterval(diff) + } + } + + return nil + } + + /// How long until the next window opens. + /// Returns zero if already active. + public func durationUntilNextActive(from date: Date = Date()) -> TimeInterval { + if isActive(at: date) { return 0 } + + let calendar = Calendar.current + let components = calendar.dateComponents([.weekday, .hour, .minute, .second], from: date) + guard let weekday = components.weekday, + let hour = components.hour, + let minute = components.minute, + let second = components.second, + let today = DayOfWeek.fromFoundationWeekday(weekday) + else { return 3600 } + + let now = TimeOfDay(hour: hour, minute: minute) + let nowSeconds = hour * 3600 + minute * 60 + second + var minWait = Int.max + + // Check each window across the next 7 days + for w in windows { + for dayOffset in 0..<7 { + let checkDay = today.adding(dayOffset) + guard w.days.contains(checkDay) else { continue } + + let wait: Int + if dayOffset == 0 && now < w.start { + // Today, window hasn't started yet + wait = w.start.totalSeconds - nowSeconds + } else if dayOffset > 0 { + // Future day + let remainingToday = 86400 - nowSeconds + let fullDays = (dayOffset - 1) * 86400 + let intoTarget = w.start.totalSeconds + wait = remainingToday + fullDays + intoTarget + } else { + continue // Today but window already passed (or currently active, handled above) + } + + if wait < minWait { + minWait = wait + } + } + } + + if minWait == Int.max { + return 3600 // Fallback: check again in 1 hour + } + return TimeInterval(minWait) + } + + /// Human-readable description of the schedule. + public func describe() -> String { + windows.map { w in + let days = w.days.map(\.abbreviation).joined(separator: ",") + return "\(days) \(w.start.description)-\(w.end.description)" + }.joined(separator: " | ") + } +} + +// MARK: - Duration formatting + +/// Format a TimeInterval as a human-readable string (e.g. "2h 30m"). +public func formatDuration(_ interval: TimeInterval) -> String { + let secs = Int(interval) + if secs < 60 { + return "\(secs)s" + } else if secs < 3600 { + return "\(secs / 60)m" + } else { + let h = secs / 3600 + let m = (secs % 3600) / 60 + if m > 0 { + return "\(h)h \(m)m" + } else { + return "\(h)h" + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Security/AntiDebug.swift b/provider-swift/Sources/ProviderCore/Security/AntiDebug.swift new file mode 100644 index 00000000..2be56c18 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/AntiDebug.swift @@ -0,0 +1,80 @@ +/// Anti-debug protections: PT_DENY_ATTACH and debugger detection. + +import Darwin +import Foundation +import os + +@_silgen_name("ptrace") +private func ptrace_raw(_ request: CInt, _ pid: pid_t, _ addr: UnsafeMutableRawPointer?, _ data: CInt) -> CInt + +private let antiDebugLogger = Logger(subsystem: "dev.darkbloom.provider", category: "security") + +// MARK: - PT_DENY_ATTACH + +/// Prevent debugger attachment using ptrace(PT_DENY_ATTACH). +/// +/// On macOS, this syscall tells the kernel to deny any future ptrace requests +/// against this process. Even root cannot override this while SIP is enabled. +/// Combined with Hardened Runtime (no get-task-allow entitlement), this makes +/// the process's memory unreadable. +/// +/// Must be called early in process startup, before any sensitive data is loaded. +public func denyDebuggerAttachment() throws { + let PT_DENY_ATTACH: CInt = 31 + let result = ptrace_raw(PT_DENY_ATTACH, 0, nil, 0) + if result == 0 { + antiDebugLogger.info("Anti-debug: PT_DENY_ATTACH enabled -- debugger attachment blocked") + } else { + let err = String(cString: strerror(errno)) + throw SecurityError.ptDenyAttachFailed( + "refusing to continue without anti-debug protection: \(err)" + ) + } +} + +// MARK: - Core Dump Disabling + +/// Disable core dumps for this process. +/// +/// Core dumps can contain plaintext prompts, model weights, and private keys. +/// Setting RLIMIT_CORE to zero prevents the kernel from writing core files +/// even if the process crashes. This complements PT_DENY_ATTACH and Hardened +/// Runtime to ensure no crash artifact leaks sensitive data. +public func disableCoreDumps() throws { + var zero = rlimit(rlim_cur: 0, rlim_max: 0) + let ret = setrlimit(RLIMIT_CORE, &zero) + if ret == 0 { + antiDebugLogger.info("Core dumps disabled (RLIMIT_CORE = 0)") + } else { + let err = String(cString: strerror(errno)) + throw SecurityError.coreDumpDisableFailed(err) + } +} + +// MARK: - Anti-Debug Detection + +/// Check if a debugger is currently attached to this process. +/// +/// Uses `sysctl` to query the kernel for the P_TRACED flag on our own +/// process. This is a belt-and-suspenders check alongside PT_DENY_ATTACH -- +/// if PT_DENY_ATTACH was somehow bypassed (e.g., SIP disabled), this +/// detects the active attachment. +public func checkDebuggerAttached() -> Bool { + var info = kinfo_proc() + var size = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + + let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) + guard result == 0 else { + antiDebugLogger.warning("Anti-debug: sysctl failed, cannot check P_TRACED") + return false + } + + let flags = info.kp_proc.p_flag + let isTraced = (flags & P_TRACED) != 0 + + if isTraced { + antiDebugLogger.error("Anti-debug: P_TRACED flag detected -- debugger is attached") + } + return isTraced +} diff --git a/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift new file mode 100644 index 00000000..5170c4c2 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift @@ -0,0 +1,398 @@ +/// AttestationBuilder -- constructs signed attestation blobs for coordinator verification. +/// +/// Builds a JSON blob containing the provider's hardware identity, security +/// posture, and public keys, then signs it with the Secure Enclave P-256 key. +/// The coordinator verifies the signature to confirm the attestation came from +/// a genuine Secure Enclave on the claimed hardware. +/// +/// The blob is JSON-encoded with sorted keys (matching Go's encoding/json +/// map key ordering) so that both sides produce identical bytes for signature +/// verification. +/// +/// Ported from `enclave/Sources/DarkbloomEnclave/Attestation.swift`. + +import CryptoKit +import Foundation +import os + +private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "attestation") + +// MARK: - Data Types + +/// An attestation blob containing hardware and software security state. +/// +/// Fields are in alphabetical order by JSON key name. This ordering is critical +/// because the Go coordinator must produce identical JSON for signature verification. +/// Using JSONEncoder with .sortedKeys ensures deterministic output. +public struct AttestationBlob: Codable, Sendable { + public let authenticatedRootEnabled: Bool + public let binaryHash: String? + public let chipName: String + public let encryptionPublicKey: String? + public let hardwareModel: String + public let osVersion: String + public let publicKey: String + public let rdmaDisabled: Bool + public let secureBootEnabled: Bool + public let secureEnclaveAvailable: Bool + public let serialNumber: String? + public let sipEnabled: Bool + public let systemVolumeHash: String? + public let timestamp: Date + + enum CodingKeys: String, CodingKey { + case authenticatedRootEnabled + case binaryHash + case chipName + case encryptionPublicKey + case hardwareModel + case osVersion + case publicKey + case rdmaDisabled + case secureBootEnabled + case secureEnclaveAvailable + case serialNumber + case sipEnabled + case systemVolumeHash + case timestamp + } +} + +/// A signed attestation: the blob plus a DER-encoded P-256 ECDSA signature, +/// both base64-encoded. +/// +/// The signature covers the JSON-encoded attestation blob (with sorted keys). +/// The coordinator verifies this signature using the public key embedded in +/// the attestation blob itself. +public struct SignedAttestation: Codable, Sendable { + public let attestation: AttestationBlob + public let signature: String // base64 DER-encoded ECDSA signature +} + +/// Fields covered by `status_signature` in an attestation challenge response. +public struct StatusCanonicalInput: Sendable, Equatable { + public var nonce: String + public var timestamp: String + public var hypervisorActive: Bool? + public var rdmaDisabled: Bool? + public var sipEnabled: Bool? + public var secureBootEnabled: Bool? + public var binaryHash: String? + public var activeModelHash: String? + public var pythonHash: String? + public var runtimeHash: String? + public var templateHashes: [String: String] + public var modelHashes: [String: String] + + public init( + nonce: String, + timestamp: String, + hypervisorActive: Bool? = nil, + rdmaDisabled: Bool? = nil, + sipEnabled: Bool? = nil, + secureBootEnabled: Bool? = nil, + binaryHash: String? = nil, + activeModelHash: String? = nil, + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:], + modelHashes: [String: String] = [:] + ) { + self.nonce = nonce + self.timestamp = timestamp + self.hypervisorActive = hypervisorActive + self.rdmaDisabled = rdmaDisabled + self.sipEnabled = sipEnabled + self.secureBootEnabled = secureBootEnabled + self.binaryHash = binaryHash + self.activeModelHash = activeModelHash + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + self.modelHashes = modelHashes + } +} + +public enum StatusCanonical { + public static func build(_ input: StatusCanonicalInput) throws -> Data { + var object: [String: Any] = [ + "nonce": input.nonce, + "timestamp": input.timestamp, + ] + if let value = input.hypervisorActive { object["hypervisor_active"] = value } + if let value = input.rdmaDisabled { object["rdma_disabled"] = value } + if let value = input.sipEnabled { object["sip_enabled"] = value } + if let value = input.secureBootEnabled { object["secure_boot_enabled"] = value } + if let value = nonEmpty(input.binaryHash) { object["binary_hash"] = value } + if let value = nonEmpty(input.activeModelHash) { object["active_model_hash"] = value } + if let value = nonEmpty(input.pythonHash) { object["python_hash"] = value } + if let value = nonEmpty(input.runtimeHash) { object["runtime_hash"] = value } + if !input.templateHashes.isEmpty { object["template_hashes"] = input.templateHashes } + if !input.modelHashes.isEmpty { object["model_hashes"] = input.modelHashes } + + return try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} + +// MARK: - Builder + +/// Builds and signs attestation blobs using a Secure Enclave identity. +/// +/// Usage: +/// 1. Create or load a SecureEnclaveIdentity +/// 2. Create an AttestationBuilder with that identity +/// 3. Call `buildAttestation()` to get a SignedAttestation +/// 4. Serialize to JSON and include in the Register message +public final class AttestationBuilder: @unchecked Sendable { + private let identity: SecureEnclaveIdentity + + public init(identity: SecureEnclaveIdentity) { + self.identity = identity + } + + /// Build an attestation blob from the current system state and sign it. + /// + /// The blob is JSON-encoded with .sortedKeys for deterministic output, + /// then signed with the Secure Enclave P-256 key. The coordinator + /// reproduces the same JSON encoding to verify the signature. + /// + /// - Parameters: + /// - encryptionPublicKey: Optional base64-encoded X25519 public key to bind + /// to this attestation. + /// - binaryHash: Optional SHA-256 hex hash of the provider binary. The + /// coordinator verifies this matches the expected blessed version. + public func buildAttestation( + encryptionPublicKey: String? = nil, + binaryHash: String? = nil + ) throws -> SignedAttestation { + let blob = AttestationBlob( + authenticatedRootEnabled: checkAuthenticatedRootEnabled(), + binaryHash: binaryHash, + chipName: detectChipName(), + encryptionPublicKey: encryptionPublicKey, + hardwareModel: detectHardwareModel(), + osVersion: detectOSVersion(), + publicKey: identity.publicKeyBase64, + rdmaDisabled: checkRDMADisabled(), + secureBootEnabled: checkSecureBootEnabled(), + secureEnclaveAvailable: SecureEnclave.isAvailable, + serialNumber: detectSerialNumber(), + sipEnabled: checkSIPEnabled(), + systemVolumeHash: systemVolumeHash(), + timestamp: Date() + ) + + // Encode with sorted keys for deterministic JSON (must match Go's encoding) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .sortedKeys + let blobData = try encoder.encode(blob) + + // Sign the JSON bytes with the Secure Enclave key + let signature = try identity.sign(blobData) + + logger.info("Built signed attestation blob (\(blobData.count) bytes)") + return SignedAttestation( + attestation: blob, + signature: signature.base64EncodedString() + ) + } + + /// Build the attestation and return it as raw JSON bytes. + /// + /// Returns the signed attestation as deterministic JSON (sorted keys), + /// suitable for embedding in a WebSocket Register message. The raw bytes + /// preserve the exact encoding needed for signature verification. + public func buildAttestationJSON( + encryptionPublicKey: String? = nil, + binaryHash: String? = nil + ) throws -> Data { + let signed = try buildAttestation( + encryptionPublicKey: encryptionPublicKey, + binaryHash: binaryHash + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .sortedKeys + return try encoder.encode(signed) + } + + /// Verify a signed attestation's signature against the embedded public key. + /// + /// This re-encodes the attestation blob with the same encoder settings + /// (.sortedKeys, .iso8601) and verifies the P-256 ECDSA signature. + /// Used for local verification; the coordinator has its own Go + /// implementation of this verification. + public static func verify(_ signed: SignedAttestation) -> Bool { + guard let pubKeyData = Data(base64Encoded: signed.attestation.publicKey), + let sigData = Data(base64Encoded: signed.signature) + else { + return false + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .sortedKeys + guard let blobData = try? encoder.encode(signed.attestation) else { + return false + } + + return SecureEnclaveIdentity.verify( + signature: sigData, + for: blobData, + publicKey: pubKeyData + ) + } +} + +// MARK: - Challenge-Response + +extension AttestationBuilder { + + /// Sign an attestation challenge from the coordinator. + /// + /// The coordinator sends periodic `attestation_challenge` messages with + /// a random nonce and timestamp. The provider signs `nonce + timestamp` + /// with its Secure Enclave key to prove the same hardware is still running. + /// + /// Returns a base64-encoded DER ECDSA signature of the nonce bytes. + public func signChallenge(nonce: String, timestamp: String) throws -> String { + let sigData = try identity.sign(Data((nonce + timestamp).utf8)) + return sigData.base64EncodedString() + } + + /// Build a full attestation response for a coordinator challenge. + /// + /// Includes the signed nonce, current security state, and optionally + /// a signed status string for the coordinator to verify provider state. + public func buildChallengeResponse( + nonce: String, + timestamp: String, + providerPublicKey: String, + binaryHash: String? = nil, + activeModelHash: String? = nil, + runtimeHashes: RuntimeHashes? = nil, + hypervisorActive: Bool? = nil, + modelHashes: [String: String] = [:] + ) throws -> ProviderMessage.AttestationResponse { + let signature = try signChallenge(nonce: nonce, timestamp: timestamp) + + let rdmaDisabled = checkRDMADisabled() + let sipEnabled = checkSIPEnabled() + let secureBootEnabled = checkSecureBootEnabled() + let statusData = try StatusCanonical.build(StatusCanonicalInput( + nonce: nonce, + timestamp: timestamp, + hypervisorActive: hypervisorActive, + rdmaDisabled: rdmaDisabled, + sipEnabled: sipEnabled, + secureBootEnabled: secureBootEnabled, + binaryHash: binaryHash, + activeModelHash: activeModelHash, + pythonHash: runtimeHashes?.pythonHash, + runtimeHash: runtimeHashes?.runtimeHash, + templateHashes: runtimeHashes?.templateHashes ?? [:], + modelHashes: modelHashes + )) + let statusSignature = try identity.sign(statusData).base64EncodedString() + + return ProviderMessage.AttestationResponse( + nonce: nonce, + signature: signature, + statusSignature: statusSignature, + publicKey: providerPublicKey, + hypervisorActive: hypervisorActive, + rdmaDisabled: rdmaDisabled, + sipEnabled: sipEnabled, + secureBootEnabled: secureBootEnabled, + binaryHash: binaryHash, + activeModelHash: activeModelHash, + pythonHash: runtimeHashes?.pythonHash, + runtimeHash: runtimeHashes?.runtimeHash, + templateHashes: runtimeHashes?.templateHashes ?? [:], + modelHashes: modelHashes + ) + } +} + +// MARK: - System Info Helpers + +/// Get the machine model identifier (e.g., "Mac16,1") via sysctl. +private func detectHardwareModel() -> String { + var size: Int = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + guard size > 0 else { return "Unknown" } + var model = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &model, &size, nil, 0) + return String(cString: model) +} + +/// Get the chip name (e.g., "Apple M4 Max") from system_profiler. +/// +/// Parses the "Chip:" line from SPHardwareDataType output. Returns "Unknown" +/// if the chip name cannot be determined. +private func detectChipName() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler") + process.arguments = ["SPHardwareDataType"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + guard let _ = try? process.run() else { return "Unknown" } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + for line in output.components(separatedBy: "\n") { + if line.contains("Chip:") { + return line.components(separatedBy: ":").last? + .trimmingCharacters(in: .whitespaces) ?? "Unknown" + } + } + return "Unknown" +} + +/// Get the OS version string (e.g., "15.3.0"). +private func detectOSVersion() -> String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" +} + +/// Get the hardware serial number for MDM cross-reference. +/// +/// The coordinator uses this to look up the device in MicroMDM and +/// independently verify its security posture via MDM SecurityInfo. +private func detectSerialNumber() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler") + process.arguments = ["SPHardwareDataType"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + guard let _ = try? process.run() else { return nil } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + for line in output.components(separatedBy: "\n") { + if line.contains("Serial Number") { + return line.components(separatedBy: ":").last? + .trimmingCharacters(in: .whitespaces) + } + } + return nil +} diff --git a/provider-swift/Sources/ProviderCore/Security/BinaryHasher.swift b/provider-swift/Sources/ProviderCore/Security/BinaryHasher.swift new file mode 100644 index 00000000..9a38a1bd --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/BinaryHasher.swift @@ -0,0 +1,113 @@ +/// Binary self-hash computation and file hashing utilities. + +import CryptoKit +import Foundation +import os + +private let hashLogger = Logger(subsystem: "dev.darkbloom.provider", category: "security") + +// MARK: - Binary Self-Hash + +/// Compute the SHA-256 hash of the currently running binary. +/// +/// This hash is included in the attestation blob so the coordinator can +/// verify the provider is running the expected (blessed) version. A modified +/// binary produces a different hash and is rejected. +/// +/// Reads in 64 KB chunks to avoid loading the entire binary into memory. +public func selfBinaryHash() -> String? { + guard let path = executablePath() else { + hashLogger.error("Binary self-hash: cannot determine executable path") + return nil + } + guard let hash = hashFile(atPath: path) else { + hashLogger.error("Binary self-hash: failed to hash \(path, privacy: .public)") + return nil + } + let prefix = hash.prefix(16) + hashLogger.info("Binary self-hash (\(path, privacy: .public)): \(prefix, privacy: .public)...") + return hash +} + +/// Compute the SHA-256 hash of a file using streaming reads. +/// +/// Reads in 64 KB chunks to avoid loading entire files into memory. +/// Used for binary integrity verification and model weight fingerprinting. +public func hashFile(atPath path: String) -> String? { + guard let handle = FileHandle(forReadingAtPath: path) else { + return nil + } + defer { try? handle.close() } + + var hasher = SHA256() + let chunkSize = 65_536 + + while true { + let chunk = handle.readData(ofLength: chunkSize) + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + + let digest = hasher.finalize() + return digest.map { String(format: "%02x", $0) }.joined() +} + +/// Compute SHA-256 of a byte buffer, returning the hex digest. +public func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() +} + +/// Compute a deterministic SHA-256 fingerprint over multiple files. +/// +/// Each file is hashed independently, then the per-file hashes are combined +/// in sorted filename order into a final hash. This produces a consistent +/// result regardless of filesystem ordering. +public func hashFilesSorted(_ paths: [String]) -> String? { + let sorted = paths.sorted() + var finalHasher = SHA256() + + for path in sorted { + guard let handle = FileHandle(forReadingAtPath: path) else { + return nil + } + defer { try? handle.close() } + + var fileHasher = SHA256() + let chunkSize = 65_536 + while true { + let chunk = handle.readData(ofLength: chunkSize) + if chunk.isEmpty { break } + fileHasher.update(data: chunk) + } + + let fileDigest = fileHasher.finalize() + finalHasher.update(data: Data(fileDigest)) + } + + let digest = finalHasher.finalize() + return digest.map { String(format: "%02x", $0) }.joined() +} + +// MARK: - Helpers + +/// Get the path to the currently running executable. +func executablePath() -> String? { + // ProcessInfo gives us the full resolved path + let args = ProcessInfo.processInfo.arguments + guard let first = args.first else { return nil } + + // Resolve via /proc/self or _NSGetExecutablePath for accuracy + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(MAXPATHLEN) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + return first + } + + // Resolve symlinks + guard let resolved = realpath(buffer, nil) else { + return String(cString: buffer) + } + defer { free(resolved) } + return String(cString: resolved) +} diff --git a/provider-swift/Sources/ProviderCore/Security/EnvironmentScrubber.swift b/provider-swift/Sources/ProviderCore/Security/EnvironmentScrubber.swift new file mode 100644 index 00000000..67fbf376 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/EnvironmentScrubber.swift @@ -0,0 +1,39 @@ +/// Environment variable scrubbing to prevent library injection and debugging. + +import Foundation +import os + +private let envLogger = Logger(subsystem: "dev.darkbloom.provider", category: "security") + +/// Dangerous environment variables that could enable library injection, +/// path hijacking, or process inspection. +private let dangerousEnvVars: [String] = [ + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "LD_PRELOAD", + "MallocStackLogging", + "MallocStackLoggingNoCompact", + "MallocScribble", + "MallocGuardEdges", + "MallocLogFile", + "MallocErrorAbort", + "NSZombieEnabled", + "OBJC_DEBUG_POOL_ALLOCATION", + "CFNETWORK_DIAGNOSTICS", +] + +/// Scrub environment variables that could enable library injection or +/// debugging of this process or its children. +/// +/// Called once at startup before any sensitive data is loaded. Unlike the +/// Rust provider, Python-specific vars (PYTHONPATH, etc.) are not relevant +/// since the Swift provider does not spawn a Python runtime. +public func scrubDangerousEnvironment() { + for name in dangerousEnvVars { + if ProcessInfo.processInfo.environment[name] != nil { + envLogger.warning("Scrubbing dangerous env var: \(name, privacy: .public)") + unsetenv(name) + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Security/SecureEnclaveIdentity.swift b/provider-swift/Sources/ProviderCore/Security/SecureEnclaveIdentity.swift new file mode 100644 index 00000000..96f58068 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/SecureEnclaveIdentity.swift @@ -0,0 +1,149 @@ +/// SecureEnclaveIdentity -- hardware-bound P-256 signing key management. +/// +/// Manages a P-256 ECDSA signing key stored in the Apple Secure Enclave. +/// The private key never leaves the hardware -- only signing operations +/// can be performed through the CryptoKit API. +/// +/// The Secure Enclave is available on all Apple Silicon Macs and provides: +/// - Hardware-isolated key storage (private key cannot be exported) +/// - Tamper-resistant signing operations +/// - Device-bound identity (key cannot be cloned to another device) +/// +/// This identity serves two purposes in Darkbloom: +/// 1. **Attestation signing**: The provider signs a hardware/software state +/// blob with this key, proving its identity and security posture. +/// 2. **Challenge-response**: The coordinator periodically challenges the +/// provider to sign a nonce, verifying the same hardware is still connected. +/// +/// The public key is a raw P-256 point (64 bytes: X || Y, without the 0x04 +/// uncompressed prefix). Both base64 and hex representations are available +/// for interoperability with the Go coordinator (which expects either format). +/// +/// The provider uses this identity ephemerally. Each process launch creates a +/// fresh Secure Enclave signing key and binds the current X25519 public key in +/// the registration attestation. +/// +/// Ported from `enclave/Sources/DarkbloomEnclave/SecureEnclaveIdentity.swift`. +/// In pure Swift this is direct CryptoKit usage -- no FFI bridge needed. + +import CryptoKit +import Foundation +import os + +private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "secure-enclave") + +/// Manages a hardware-bound P-256 signing key in the Apple Secure Enclave. +/// +/// The private key never leaves the Secure Enclave. Production provider startup +/// intentionally does not persist or reload the opaque Secure Enclave key +/// handle; attested identity is fresh per launch and bound to the X25519 key +/// used for E2E encryption. +/// +/// Thread-safety: This type is `Sendable` because the Secure Enclave key +/// is immutable after creation and CryptoKit signing operations are safe +/// to call from any thread. +public final class SecureEnclaveIdentity: @unchecked Sendable { + private let privateKey: SecureEnclave.P256.Signing.PrivateKey + public let publicKey: P256.Signing.PublicKey + + // MARK: - Initializers + + /// Create a new identity (generates a new key in the Secure Enclave). + /// + /// Each call creates a distinct key -- there is no singleton and no + /// production persistence path. + public init() throws { + self.privateKey = try SecureEnclave.P256.Signing.PrivateKey() + self.publicKey = self.privateKey.publicKey + logger.info("Created new Secure Enclave identity") + } + + /// Public key as raw bytes (64 bytes: X || Y, without the 0x04 prefix). + /// + /// This format matches what the Go coordinator expects for P-256 public + /// key verification (64-byte raw representation). + public var publicKeyRaw: Data { + publicKey.rawRepresentation + } + + /// Public key as a base64-encoded string. + /// + /// Used in the attestation blob's `publicKey` field and sent to the + /// coordinator during registration. + public var publicKeyBase64: String { + publicKey.rawRepresentation.base64EncodedString() + } + + /// Public key as a lowercase hex string. + public var publicKeyHex: String { + publicKey.rawRepresentation.map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Sign / Verify + + /// Sign arbitrary data using the Secure Enclave private key. + /// + /// The signing operation happens inside the Secure Enclave hardware. + /// Returns the signature in DER-encoded format, which is the standard + /// format for ECDSA signatures and compatible with Go's crypto/ecdsa + /// and the ASN.1 DER parser. + public func sign(_ data: Data) throws -> Data { + let signature = try privateKey.signature(for: data) + return signature.derRepresentation + } + + /// Sign a UTF-8 string, returning the base64-encoded DER signature. + /// + /// Convenience method for signing string payloads (nonces, hashes). + /// Returns nil if signing fails. + public func signString(_ string: String) -> String? { + guard let sigData = try? sign(Data(string.utf8)) else { return nil } + return sigData.base64EncodedString() + } + + /// Verify a DER-encoded signature against this identity's public key. + /// + /// This is a convenience method for verifying signatures produced by + /// this same identity. + public func verify(signature: Data, for data: Data) -> Bool { + guard let sig = try? P256.Signing.ECDSASignature(derRepresentation: signature) else { + return false + } + return publicKey.isValidSignature(sig, for: data) + } + + /// Verify a DER-encoded signature against an arbitrary P-256 public key + /// given as raw bytes (64 bytes: X || Y). + /// + /// This static method is used by attestation verification code to check + /// signatures without needing the private key or Secure Enclave. + public static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { + guard let pk = try? P256.Signing.PublicKey(rawRepresentation: publicKey), + let sig = try? P256.Signing.ECDSASignature(derRepresentation: signature) + else { + return false + } + return pk.isValidSignature(sig, for: data) + } + + // MARK: - Availability + + /// Whether the Secure Enclave is available on this device. + /// + /// Returns `true` on all Apple Silicon Macs and iPhones with A7+. + /// Returns `false` on Intel Macs and non-Apple hardware. + public static var isAvailable: Bool { + SecureEnclave.isAvailable + } + + // MARK: - Ephemeral Startup + + /// Create a fresh provider signing identity for this process launch. + public static func createEphemeral() throws -> SecureEnclaveIdentity? { + guard SecureEnclave.isAvailable else { + logger.warning("Secure Enclave not available on this device") + return nil + } + return try SecureEnclaveIdentity() + } +} diff --git a/provider-swift/Sources/ProviderCore/Security/SecurityFoundation.swift b/provider-swift/Sources/ProviderCore/Security/SecurityFoundation.swift new file mode 100644 index 00000000..c7fde39a --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/SecurityFoundation.swift @@ -0,0 +1,488 @@ +import CryptoKit +import Darwin +import Foundation + +@_silgen_name("ptrace") +private func providerCorePtrace( + _ request: CInt, + _ pid: pid_t, + _ addr: UnsafeMutableRawPointer?, + _ data: CInt +) -> CInt + +// MARK: - Command Running + +public struct SecurityCommandResult: Sendable, Equatable { + public let terminationStatus: Int32 + public let stdout: String + public let stderr: String + + public init(terminationStatus: Int32, stdout: String = "", stderr: String = "") { + self.terminationStatus = terminationStatus + self.stdout = stdout + self.stderr = stderr + } +} + +public struct SecurityCommandRunner: @unchecked Sendable { + public var run: (_ executablePath: String, _ arguments: [String]) throws -> SecurityCommandResult + + public init( + run: @escaping (_ executablePath: String, _ arguments: [String]) throws -> SecurityCommandResult + ) { + self.run = run + } + + public static let live = SecurityCommandRunner { executablePath, arguments in + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + + return SecurityCommandResult( + terminationStatus: process.terminationStatus, + stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + ) + } +} + +// MARK: - SIP Status + +public enum SIPStatus: Sendable, Equatable { + case enabled + case enabledWithCustomConfiguration(disabledProtections: [String]) + case disabled + case unavailable(reason: String) + case unrecognized(output: String) + + public var isFullyEnabled: Bool { + self == .enabled + } + + public var reportsEnabled: Bool { + switch self { + case .enabled, .enabledWithCustomConfiguration: + return true + case .disabled, .unavailable, .unrecognized: + return false + } + } +} + +public enum SIPStatusParser { + public static func parse(_ result: SecurityCommandResult) -> SIPStatus { + if result.terminationStatus != 0 { + let reason = [result.stdout, result.stderr] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return .unavailable(reason: reason.isEmpty ? "csrutil exited with \(result.terminationStatus)" : reason) + } + return parse(result.stdout) + } + + public static func parse(_ output: String) -> SIPStatus { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .unrecognized(output: output) + } + + let statusLine = trimmed + .components(separatedBy: .newlines) + .first { $0.localizedCaseInsensitiveContains("System Integrity Protection status:") } + ?? trimmed.components(separatedBy: .newlines).first + ?? trimmed + + let normalizedStatus = statusLine.lowercased() + if normalizedStatus.contains("disabled") { + return .disabled + } + + if normalizedStatus.contains("enabled") { + if normalizedStatus.contains("custom configuration") { + return .enabledWithCustomConfiguration( + disabledProtections: disabledSIPProtections(in: trimmed) + ) + } + return .enabled + } + + return .unrecognized(output: output) + } + + private static func disabledSIPProtections(in output: String) -> [String] { + output.components(separatedBy: .newlines).compactMap { line in + let parts = line.split(separator: ":", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard parts.count == 2 else { return nil } + guard parts[0].localizedCaseInsensitiveCompare("System Integrity Protection status") != .orderedSame else { + return nil + } + return parts[1].localizedCaseInsensitiveContains("disabled") ? parts[0] : nil + } + } +} + +public struct SIPStatusChecker: Sendable { + private let runner: SecurityCommandRunner + + public init(runner: SecurityCommandRunner = .live) { + self.runner = runner + } + + public func status() -> SIPStatus { + do { + return SIPStatusParser.parse(try runner.run("/usr/bin/csrutil", ["status"])) + } catch { + return .unavailable(reason: String(describing: error)) + } + } + + public func isFullyEnabled() -> Bool { + status().isFullyEnabled + } +} + +// MARK: - SHA-256 Hashing + +public enum SecurityHashError: Error, CustomStringConvertible, Equatable { + case fileNotFound(String) + case unreadableFile(String) + case invalidChunkSize(Int) + + public var description: String { + switch self { + case .fileNotFound(let path): + return "file not found: \(path)" + case .unreadableFile(let path): + return "file is not readable: \(path)" + case .invalidChunkSize(let chunkSize): + return "invalid chunk size: \(chunkSize)" + } + } +} + +public struct BinarySHA256Hasher: Sendable { + public let chunkSize: Int + + public init(chunkSize: Int = 65_536) { + self.chunkSize = chunkSize + } + + public func hashData(_ data: Data) -> String { + hexString(for: SHA256.hash(data: data)) + } + + public func hashFile(at url: URL) throws -> String { + try hexString(for: hashFileDigest(at: url)) + } + + public func hashFilesSorted(_ urls: [URL]) throws -> String { + var finalHasher = SHA256() + for url in urls.sorted(by: { $0.path < $1.path }) { + let digest = try hashFileDigest(at: url) + finalHasher.update(data: Data(digest)) + } + return hexString(for: finalHasher.finalize()) + } + + private func hashFileDigest(at url: URL) throws -> SHA256.Digest { + guard chunkSize > 0 else { + throw SecurityHashError.invalidChunkSize(chunkSize) + } + guard FileManager.default.fileExists(atPath: url.path) else { + throw SecurityHashError.fileNotFound(url.path) + } + guard let handle = FileHandle(forReadingAtPath: url.path) else { + throw SecurityHashError.unreadableFile(url.path) + } + defer { try? handle.close() } + + var hasher = SHA256() + while true { + let chunk = handle.readData(ofLength: chunkSize) + if chunk.isEmpty { + break + } + hasher.update(data: chunk) + } + return hasher.finalize() + } + + private func hexString(for bytes: D) -> String where D.Element == UInt8 { + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - Runtime Hash Reporting + +public struct RuntimeHashReport: Sendable, Equatable { + public let binaryHash: String? + public let pythonHash: String? + public let runtimeHash: String? + public let templateHashes: [String: String] + + public init( + binaryHash: String? = nil, + pythonHash: String? = nil, + runtimeHash: String? = nil, + templateHashes: [String: String] = [:] + ) { + self.binaryHash = binaryHash + self.pythonHash = pythonHash + self.runtimeHash = runtimeHash + self.templateHashes = templateHashes + } + + public var coordinatorRuntimeHashes: RuntimeHashes { + RuntimeHashes( + pythonHash: pythonHash, + runtimeHash: runtimeHash, + templateHashes: templateHashes + ) + } +} + +public struct RuntimeHashReporter: @unchecked Sendable { + private let hasher: BinarySHA256Hasher + private let fileManager: FileManager + + public init(hasher: BinarySHA256Hasher = BinarySHA256Hasher(), fileManager: FileManager = .default) { + self.hasher = hasher + self.fileManager = fileManager + } + + public func report( + binaryURL: URL? = RuntimeHashReporter.currentExecutableURL(), + runtimeDirectories: [URL] = [], + templateDirectory: URL? = nil + ) throws -> RuntimeHashReport { + RuntimeHashReport( + binaryHash: try binaryURL.map { try hasher.hashFile(at: $0) }, + pythonHash: nil, + runtimeHash: try runtimeDirectories.isEmpty ? nil : hasher.hashFilesSorted(runtimeFiles(in: runtimeDirectories)), + templateHashes: try templateDirectory.map(templateHashes(in:)) ?? [:] + ) + } + + public static func currentExecutableURL() -> URL? { + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(MAXPATHLEN) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + return nil + } + guard let resolved = realpath(buffer, nil) else { + return URL(fileURLWithPath: String(cString: buffer)) + } + defer { free(resolved) } + return URL(fileURLWithPath: String(cString: resolved)) + } + + private func runtimeFiles(in directories: [URL]) throws -> [URL] { + try directories.flatMap { directory in + try filesUnder(directory).filter { url in + url.lastPathComponent != ".DS_Store" + && url.pathExtension != "pyc" + && !url.pathComponents.contains("__pycache__") + } + } + } + + private func templateHashes(in directory: URL) throws -> [String: String] { + guard fileManager.fileExists(atPath: directory.path) else { + return [:] + } + + let templateURLs = try filesUnder(directory) + .filter { $0.pathExtension == "jinja" } + .sorted(by: { $0.path < $1.path }) + + var result: [String: String] = [:] + for url in templateURLs { + result[url.deletingPathExtension().lastPathComponent] = try hasher.hashFile(at: url) + } + return result + } + + private func filesUnder(_ directory: URL) throws -> [URL] { + guard fileManager.fileExists(atPath: directory.path) else { + return [] + } + + let resourceKeys: [URLResourceKey] = [.isRegularFileKey, .isDirectoryKey] + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: resourceKeys, + options: [], + errorHandler: nil + ) else { + return [] + } + + var urls: [URL] = [] + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: Set(resourceKeys)) + if values.isDirectory == true, url.lastPathComponent == "__pycache__" { + enumerator.skipDescendants() + continue + } + if values.isRegularFile == true { + urls.append(url) + } + } + return urls + } +} + +// MARK: - Environment Scrubbing + +public struct EnvironmentVariableScrub: Sendable, Equatable { + public let name: String + public let reason: String + + public init(name: String, reason: String) { + self.name = name + self.reason = reason + } +} + +public struct EnvironmentScrubPlan: Sendable, Equatable { + public let removals: [EnvironmentVariableScrub] + + public init(removals: [EnvironmentVariableScrub]) { + self.removals = removals + } + + public var variableNames: [String] { + removals.map(\.name).sorted() + } + + public var isEmpty: Bool { + removals.isEmpty + } +} + +public struct EnvironmentScrubPlanner: Sendable { + public let dangerousVariables: [String: String] + + public init(dangerousVariables: [String: String] = EnvironmentScrubPlanner.defaultDangerousVariables) { + self.dangerousVariables = dangerousVariables + } + + public func plan(for environment: [String: String] = ProcessInfo.processInfo.environment) -> EnvironmentScrubPlan { + let removals = dangerousVariables.keys + .filter { environment[$0] != nil } + .sorted() + .map { EnvironmentVariableScrub(name: $0, reason: dangerousVariables[$0] ?? "unsafe provider environment") } + return EnvironmentScrubPlan(removals: removals) + } + + public func apply( + to environment: [String: String] = ProcessInfo.processInfo.environment, + unset: (String) -> Void = { unsetenv($0) } + ) -> EnvironmentScrubPlan { + let scrubPlan = plan(for: environment) + for removal in scrubPlan.removals { + unset(removal.name) + } + return scrubPlan + } + + public static let defaultDangerousVariables: [String: String] = [ + "CFNETWORK_DIAGNOSTICS": "enables verbose networking diagnostics", + "DYLD_FRAMEWORK_PATH": "can redirect framework loading", + "DYLD_INSERT_LIBRARIES": "can inject code into the provider process", + "DYLD_LIBRARY_PATH": "can redirect dynamic library loading", + "LD_PRELOAD": "can inject code into child processes", + "MallocErrorAbort": "changes allocator behavior during sensitive work", + "MallocGuardEdges": "changes allocator layout and diagnostics", + "MallocLogFile": "can write allocator activity to disk", + "MallocScribble": "changes allocator behavior during sensitive work", + "MallocStackLogging": "can retain allocation backtraces for inspection", + "MallocStackLoggingNoCompact": "can retain allocation backtraces for inspection", + "NSZombieEnabled": "keeps Objective-C objects alive for debugging", + "OBJC_DEBUG_POOL_ALLOCATION": "enables Objective-C allocation diagnostics", + "PYTHONDONTWRITEBYTECODE": "legacy Python runtime control must not leak into child processes", + "PYTHONHOME": "legacy Python runtime path override", + "PYTHONIOENCODING": "legacy Python runtime IO override", + "PYTHONPATH": "legacy Python import path override", + "PYTHONSTARTUP": "legacy Python startup hook", + ] +} + +// MARK: - PT_DENY_ATTACH + +public struct PtraceClient: @unchecked Sendable { + public var ptrace: (_ request: CInt, _ pid: pid_t, _ addr: UnsafeMutableRawPointer?, _ data: CInt) -> CInt + public var lastErrno: () -> CInt + + public init( + ptrace: @escaping (_ request: CInt, _ pid: pid_t, _ addr: UnsafeMutableRawPointer?, _ data: CInt) -> CInt, + lastErrno: @escaping () -> CInt + ) { + self.ptrace = ptrace + self.lastErrno = lastErrno + } + + public static let live = PtraceClient( + ptrace: { request, pid, addr, data in + providerCorePtrace(request, pid, addr, data) + }, + lastErrno: { errno } + ) +} + +public enum DebugAttachmentProtectionError: Error, CustomStringConvertible, Equatable { + case denyAttachFailed(errno: CInt, message: String) + + public var description: String { + switch self { + case .denyAttachFailed(let code, let message): + return "PT_DENY_ATTACH failed with errno \(code): \(message)" + } + } +} + +public struct DebugAttachmentProtector: Sendable { + public static let ptDenyAttachRequest: CInt = 31 + + private let client: PtraceClient + private let shouldInvokePtrace: Bool + + public init(client: PtraceClient = .live, shouldInvokePtrace: Bool = true) { + self.client = client + self.shouldInvokePtrace = shouldInvokePtrace + } + + public static var disabledForTests: DebugAttachmentProtector { + DebugAttachmentProtector( + client: PtraceClient(ptrace: { _, _, _, _ in 0 }, lastErrno: { 0 }), + shouldInvokePtrace: false + ) + } + + @discardableResult + public func denyDebuggerAttachment() throws -> Bool { + guard shouldInvokePtrace else { + return false + } + + let result = client.ptrace(Self.ptDenyAttachRequest, 0, nil, 0) + guard result == 0 else { + let code = client.lastErrno() + throw DebugAttachmentProtectionError.denyAttachFailed( + errno: code, + message: String(cString: strerror(code)) + ) + } + return true + } +} diff --git a/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift b/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift new file mode 100644 index 00000000..cefdebb0 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Security/SecurityHardening.swift @@ -0,0 +1,556 @@ +/// SecurityHardening -- runtime protections for the provider process. +/// +/// Implements the same security hardening as the Rust provider (`security.rs`), +/// ported to pure Swift with Darwin C bridging: +/// +/// - PT_DENY_ATTACH: prevents debugger attachment (lldb, dtrace) +/// - SIP verification: checks System Integrity Protection is enabled +/// - RDMA check: reports Thunderbolt RDMA status for coordinator policy +/// - Binary self-hash: SHA-256 of the running executable +/// - Core dump disabling: RLIMIT_CORE set to 0 +/// - Environment scrubbing: removes dangerous env vars (DYLD_*, etc.) +/// - Anti-debug detection: P_TRACED flag, sysctl kern.proc +/// - Secure Boot check: Apple Silicon full security mode +/// - Hardened Runtime check: codesign entitlement verification +/// - Bundle signature verification: validates .app code signature +/// - MDM enrollment detection: MicroMDM profile checks +/// +/// These protections work with macOS Hardened Runtime (applied at code signing +/// time) and SIP to prevent the provider (machine owner) from inspecting +/// inference data in flight. + +import CryptoKit +import Darwin +import Foundation +import os + +private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "security") + +// MARK: - Errors + +public enum SecurityError: Error, CustomStringConvertible, Sendable { + case ptDenyAttachFailed(String) + case coreDumpDisableFailed(String) + case sipDisabled + case rdmaEnabled + case debuggerDetected + case bundleSignatureInvalid(String) + + public var description: String { + switch self { + case .ptDenyAttachFailed(let reason): + return "PT_DENY_ATTACH failed: \(reason)" + case .coreDumpDisableFailed(let reason): + return "Failed to disable core dumps: \(reason)" + case .sipDisabled: + return "System Integrity Protection is disabled" + case .rdmaEnabled: + return "RDMA policy rejected this runtime" + case .debuggerDetected: + return "Debugger attachment detected (P_TRACED)" + case .bundleSignatureInvalid(let reason): + return "App bundle signature invalid: \(reason)" + } + } +} + +// MARK: - SIP Check + +/// Check if System Integrity Protection (SIP) is enabled. +/// +/// SIP is the foundation of the security model. With SIP enabled: +/// - Hardened Runtime protections are enforced by the kernel +/// - Unsigned kernel extensions cannot load +/// - /dev/mem does not exist on Apple Silicon +/// - Root cannot modify /System or attach to protected processes +/// +/// SIP cannot be disabled at runtime -- it requires rebooting into +/// Recovery Mode. So if this check passes, SIP will remain enabled +/// for the lifetime of this process. +public func checkSIPEnabled() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil") + process.arguments = ["status"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + logger.error("SIP check: failed to run csrutil: \(error)") + return false + } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let enabled = output.contains("enabled") + + if enabled { + logger.info("SIP check: System Integrity Protection is enabled") + } else { + logger.error("SIP check: System Integrity Protection is DISABLED") + } + return enabled +} + +// MARK: - RDMA Check + +/// Check if RDMA (Remote Direct Memory Access) is disabled. +/// +/// RDMA over Thunderbolt exposes IOMMU-mapped memory regions registered by +/// the RDMA runtime. It is allowed for RDMA-aware provider modes, but must be +/// reported truthfully so the coordinator can apply the correct routing policy. +/// Enabling RDMA requires booting into Recovery OS and running +/// `rdma_ctl enable`. +/// +/// Returns true if RDMA is disabled (safe) or if rdma_ctl is not +/// available (older macOS without RDMA support). +public func checkRDMADisabled() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/rdma_ctl") + process.arguments = ["status"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + // rdma_ctl not found means RDMA is not supported on this Mac + // (pre-macOS 26.2 or hardware without Thunderbolt 5 RDMA support). + logger.debug("RDMA check: rdma_ctl not available, assuming safe") + return true + } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let disabled = output.trimmingCharacters(in: .whitespacesAndNewlines) == "disabled" + + if disabled { + logger.debug("RDMA check: RDMA is disabled") + } else { + logger.debug("RDMA check: RDMA is enabled") + } + return disabled +} + +// MARK: - Secure Boot Check + +/// Check if Secure Boot is enabled. +/// +/// On Apple Silicon, Secure Boot is always enabled in Full Security mode. +/// Reduced Security or Permissive Security are set via Recovery OS. +/// +/// Returns true if running in Full Security mode. +public func checkSecureBootEnabled() -> Bool { + // On Apple Silicon, the default (and only safe) configuration is + // Full Security. Checking bputil would require root. The coordinator + // independently verifies via MDM SecurityInfo, so returning true here + // is safe -- a downgraded device will fail the MDM cross-check. + // + // For software-level detection without root, we check the Authenticated + // Root Volume which is only sealed under Full Security. + return checkAuthenticatedRootEnabled() +} + +// MARK: - Authenticated Root Volume + +/// Check if Authenticated Root Volume (ARV) is enabled. +/// +/// ARV seals the system volume with a cryptographic hash. Any modification +/// to system files breaks the seal and the volume won't mount. +/// +/// Detection: checks `diskutil info /` for "Sealed: Yes" which works +/// reliably on all macOS configurations including multi-boot EC2 Macs +/// where `csrutil authenticated-root status` prompts interactively. +public func checkAuthenticatedRootEnabled() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["info", "/"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + logger.error("ARV check: failed to run diskutil: \(error)") + return false + } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("Sealed:") { + return trimmed.contains("Yes") + } + } + return false +} + +// MARK: - Hardened Runtime Check + +/// Check if this binary is running with Hardened Runtime enabled. +/// +/// Hardened Runtime prevents code injection, DYLD environment variables, +/// and debugging (unless the get-task-allow entitlement is present). +/// It is applied at code signing time and enforced by the kernel. +/// +/// Verifies using `codesign --display --verbose` on the current executable. +public func checkHardenedRuntimeEnabled() -> Bool { + guard let exePath = executablePath() else { return false } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--display", "--verbose", exePath] + + // codesign writes to stderr, not stdout + let errPipe = Pipe() + process.standardOutput = Pipe() + process.standardError = errPipe + + do { + try process.run() + } catch { + logger.warning("Hardened Runtime check: failed to run codesign: \(error)") + return false + } + process.waitUntilExit() + + let data = errPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + // Look for "flags=0x10000(runtime)" which indicates hardened runtime + let hasRuntime = output.contains("runtime") + if hasRuntime { + logger.info("Hardened Runtime is enabled") + } else { + logger.warning("Hardened Runtime is NOT enabled") + } + return hasRuntime +} + +// MARK: - Bundle Signature Verification + +/// Verify the app bundle's code signature using macOS codesign. +/// +/// If the binary is running from within a .app bundle, verify the +/// bundle's code signature is valid. A modified bundle (any file changed) +/// will fail this check. +/// +/// Returns nil if the signature is valid or we're not in a bundle. +/// Returns an error message string if the signature is invalid. +public func verifyBundleSignature() throws { + guard let exePath = executablePath() else { return } + + // Walk up to find the .app bundle + var url = URL(fileURLWithPath: exePath) + var appPath: String? + + while url.path != "/" { + if url.pathExtension == "app" { + appPath = url.path + break + } + url = url.deletingLastPathComponent() + } + + guard let bundlePath = appPath else { + logger.debug("Not running from .app bundle, skipping bundle signature check") + return + } + + logger.info("Verifying app bundle signature: \(bundlePath, privacy: .public)") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--verify", "--verbose=0", bundlePath] + + let errPipe = Pipe() + process.standardOutput = Pipe() + process.standardError = errPipe + + do { + try process.run() + } catch { + logger.warning("Could not verify bundle signature: \(error)") + return // Don't fail if codesign isn't available + } + process.waitUntilExit() + + if process.terminationStatus == 0 { + logger.info("App bundle signature valid") + } else { + let data = errPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: data, encoding: .utf8) ?? "unknown error" + throw SecurityError.bundleSignatureInvalid(stderr) + } +} + +// MARK: - MDM Enrollment Check + +/// Check if this Mac is enrolled in Darkbloom MDM. +/// +/// Tries multiple detection methods since system-level profiles +/// require sudo to see via `profiles list`. This is the single +/// source of truth for MDM enrollment status. +public func checkMDMEnrolled() -> Bool { + // Method 1: Check if the system profiles marker file exists. + // This file is created when any configuration profile is installed + // at the system level, even if `profiles list` can't show it without sudo. + let markerPath = "/var/db/ConfigurationProfiles/Settings/.profilesAreInstalled" + if FileManager.default.fileExists(atPath: markerPath) { + logger.debug("MDM check: profiles marker file exists") + return true + } + + // Method 2: Try `profiles list` (works for user-level profiles) + if checkProfilesList(["list"]) { + logger.debug("MDM check: found via profiles list") + return true + } + if checkProfilesList(["list", "-type", "enrollment"]) { + logger.debug("MDM check: found via profiles list -type enrollment") + return true + } + + // Method 3: Check if mdmclient shows enrollment + let mdmProcess = Process() + mdmProcess.executableURL = URL(fileURLWithPath: "/usr/libexec/mdmclient") + mdmProcess.arguments = ["QueryDeviceInformation"] + let mdmPipe = Pipe() + mdmProcess.standardOutput = mdmPipe + mdmProcess.standardError = Pipe() + + if let _ = try? mdmProcess.run() { + mdmProcess.waitUntilExit() + let data = mdmPipe.fileHandleForReading.readDataToEndOfFile() + let output = (String(data: data, encoding: .utf8) ?? "").lowercased() + if output.contains("enrolled") || output.contains("serverurl") { + logger.debug("MDM check: found via mdmclient") + return true + } + } + + return false +} + +private func checkProfilesList(_ args: [String]) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/profiles") + process.arguments = args + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + guard let _ = try? process.run() else { return false } + process.waitUntilExit() + + let stdout = String( + data: outPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let stderr = String( + data: errPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let combined = (stdout + stderr).lowercased() + + // Positive signals + let hasProfile = combined.contains("micromdm") + || combined.contains("com.github.micromdm") + || combined.contains("darkbloom") + || combined.contains("eigeninference") // legacy MDM profile name + || combined.contains("attribute: profileidentifier") + + // Negative signal + let noProfiles = combined.contains("no configuration profiles") + + return hasProfile || (!noProfiles && combined.contains("profileidentifier")) +} + +// MARK: - Secure Memory Zeroing + +/// Zero out a byte buffer to prevent sensitive data from persisting in memory. +/// +/// Uses `memset_s` which is guaranteed not to be optimized away by the +/// compiler (unlike a regular memset or loop). This is the C11 standard +/// mechanism for secure memory clearing. +public func secureZero(_ buffer: UnsafeMutableRawBufferPointer) { + guard !buffer.isEmpty, let ptr = buffer.baseAddress else { return } + // memset_s is guaranteed not to be optimized away (C11 Annex K / macOS). + _ = memset_s(ptr, buffer.count, 0, buffer.count) +} + +/// Zero a Data value in place and replace it with empty data. +/// +/// After this call the original bytes are overwritten. The Data value +/// is then replaced with an empty Data to drop the buffer. +public func secureZeroData(_ data: inout Data) { + data.withUnsafeMutableBytes { buffer in + guard !buffer.isEmpty, let ptr = buffer.baseAddress else { return } + _ = memset_s(ptr, buffer.count, 0, buffer.count) + } + data = Data() +} + +// MARK: - Security Posture Verification + +/// Result of a full security posture check. +public struct SecurityPosture: Sendable { + public let sipEnabled: Bool + public let rdmaDisabled: Bool + public let secureBootEnabled: Bool + public let authenticatedRootEnabled: Bool + public let hardenedRuntimeEnabled: Bool + public let antiDebugEnabled: Bool + public let coreDumpsDisabled: Bool + public let envScrubbed: Bool + public let mdmEnrolled: Bool + public let bundleSignatureValid: Bool + public let binaryHash: String? + + /// Whether the minimum security requirements are met for serving inference. + /// + /// RDMA status is reported separately. RDMA-enabled providers are allowed + /// when the signed runtime owns the registered-buffer policy. + public var isSafeToServe: Bool { + sipEnabled + } +} + +/// Verify all security prerequisites before accepting inference work. +/// +/// This performs every check and returns a full posture report. +/// Call at process startup and optionally before each inference request. +/// +/// Throws `SecurityError.sipDisabled` if SIP is off. RDMA is no longer a +/// startup-fatal condition; it is included in the signed posture report. +public func verifySecurityPosture(hypervisorActive _: Bool = false) throws -> SecurityPosture { + let sipEnabled = checkSIPEnabled() + if !sipEnabled { + throw SecurityError.sipDisabled + } + + let rdmaDisabled = checkRDMADisabled() + + // Anti-debug: PT_DENY_ATTACH + P_TRACED check + var antiDebugOk = true + do { + try denyDebuggerAttachment() + } catch { + logger.warning("PT_DENY_ATTACH failed (may already be set): \(error)") + // Not fatal on retry -- PT_DENY_ATTACH returns EPERM if already set + antiDebugOk = !checkDebuggerAttached() + } + + var coreDumpsOk = true + do { + try disableCoreDumps() + } catch { + logger.warning("Failed to disable core dumps: \(error)") + coreDumpsOk = false + } + + scrubDangerousEnvironment() + + var bundleOk = true + do { + try verifyBundleSignature() + } catch { + logger.error("Bundle signature verification failed: \(error)") + bundleOk = false + } + + return SecurityPosture( + sipEnabled: sipEnabled, + rdmaDisabled: rdmaDisabled, + secureBootEnabled: checkSecureBootEnabled(), + authenticatedRootEnabled: checkAuthenticatedRootEnabled(), + hardenedRuntimeEnabled: checkHardenedRuntimeEnabled(), + antiDebugEnabled: antiDebugOk, + coreDumpsDisabled: coreDumpsOk, + envScrubbed: true, + mdmEnrolled: checkMDMEnrolled(), + bundleSignatureValid: bundleOk, + binaryHash: selfBinaryHash() + ) +} + +// MARK: - Response Attestation + +/// Compute a SHA-256 hash and optional Secure Enclave signature over an +/// inference response, for coordinator verification. +/// +/// The hash covers `requestId:completionTokens:responseBody` -- identical +/// to the Rust provider's `compute_response_attestation`. +public func computeResponseAttestation( + identity: SecureEnclaveIdentity?, + requestId: String, + completionTokens: UInt64, + responseBody: String +) -> (hash: String, signature: String?) { + let signData = "\(requestId):\(completionTokens):\(responseBody)" + let responseHash = sha256Hex(Data(signData.utf8)) + + var signature: String? + if let identity { + if let sigData = try? identity.sign(Data(responseHash.utf8)) { + signature = sigData.base64EncodedString() + } + } + + return (responseHash, signature) +} + +// MARK: - System Volume Hash + +/// Get the Authenticated Root Volume snapshot hash. +/// +/// This is the cryptographic hash of the sealed system volume. It proves +/// the system volume is Apple's original, unmodified volume. The hash +/// is embedded in the APFS snapshot name. +public func systemVolumeHash() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["info", "/"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + // Extract hash from snapshot name: com.apple.os.update- + for line in output.components(separatedBy: "\n") { + if line.contains("APFS Snapshot Name") { + if let range = line.range(of: "com.apple.os.update-") { + let hash = String(line[range.upperBound...]) + .trimmingCharacters(in: .whitespaces) + if !hash.isEmpty { + return hash + } + } + } + } + return nil +} diff --git a/provider-swift/Sources/ProviderCore/Server/ServerRoutes.swift b/provider-swift/Sources/ProviderCore/Server/ServerRoutes.swift new file mode 100644 index 00000000..c18021ff --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Server/ServerRoutes.swift @@ -0,0 +1,90 @@ +/// Shared Hummingbird response helpers for the standalone local server. + +import Foundation +import Hummingbird + +struct HealthResponse: Codable, Sendable { + let status: String + let version: String +} + +struct OpenAIModel: Codable, Sendable { + let id: String + let object: String + let created: Int + let owned_by: String +} + +struct OpenAIModelsResponse: Codable, Sendable { + let object: String + let data: [OpenAIModel] +} + +struct OpenAIErrorResponse: Codable, Sendable { + struct ErrorObject: Codable, Sendable { + let message: String + let type: String + } + + let error: ErrorObject +} + +struct StandaloneHeadersMiddleware: RouterMiddleware { + func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + if request.method == .options { + return Response(status: .noContent, headers: defaultHeaders()) + } + + var response = try await next(request, context) + response.headers[.accessControlAllowOrigin] = "*" + return response + } +} + +func defaultHeaders(contentType: String? = nil) -> HTTPFields { + var headers = HTTPFields() + headers[.accessControlAllowOrigin] = "*" + headers[.accessControlAllowHeaders] = "accept, authorization, content-type, origin" + headers[.accessControlAllowMethods] = "GET, POST, HEAD, OPTIONS" + if let contentType { + headers[.contentType] = contentType + } + return headers +} + +func jsonResponse( + _ value: T, + status: HTTPResponse.Status = .ok +) -> Response { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + do { + let data = try encoder.encode(value) + return Response( + status: status, + headers: defaultHeaders(contentType: "application/json"), + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } catch { + return openAIErrorResponse( + status: .internalServerError, + message: "Failed to encode response" + ) + } +} + +func openAIErrorResponse( + status: HTTPResponse.Status, + message: String, + type: String = "invalid_request_error" +) -> Response { + let response = OpenAIErrorResponse( + error: .init(message: message, type: type) + ) + return jsonResponse(response, status: status) +} diff --git a/provider-swift/Sources/ProviderCore/Server/StandaloneServer.swift b/provider-swift/Sources/ProviderCore/Server/StandaloneServer.swift new file mode 100644 index 00000000..af292347 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Server/StandaloneServer.swift @@ -0,0 +1,253 @@ +/// Standalone HTTP server for local/standalone mode. +/// +/// Serves OpenAI-compatible inference requests directly without a coordinator. +/// The HTTP transport is handled by Hummingbird; inference still flows through +/// `BatchScheduler`. +/// +/// Endpoints: +/// - GET /health -> {"status":"ok","version":"..."} +/// - GET /v1/models -> OpenAI models list +/// - POST /v1/chat/completions -> streaming SSE or JSON response + +import Foundation +import Hummingbird +import os + +// MARK: - Public API + +/// Configuration for the standalone server. +public struct StandaloneServerConfig: Sendable { + public let port: UInt16 + public let host: String + + public init(port: UInt16 = 8000, host: String = "127.0.0.1") { + self.port = port + self.host = host + } +} + +private let standaloneLogger = Logger( + subsystem: "dev.darkbloom.provider", + category: "StandaloneServer" +) + +public actor StandaloneServer { + + private let config: StandaloneServerConfig + private let scheduler: BatchScheduler + private var models: [ModelInfo] + private var serverTask: Task? + + public init( + config: StandaloneServerConfig = StandaloneServerConfig(), + scheduler: BatchScheduler, + models: [ModelInfo] = [] + ) { + self.config = config + self.scheduler = scheduler + self.models = models + } + + /// Update the advertised model list (e.g. after a rescan). + public func setModels(_ newModels: [ModelInfo]) { + self.models = newModels + } + + /// Start listening for HTTP connections. The server runs in a child task. + public func start() throws { + guard serverTask == nil else { return } + + let app = makeApplication() + serverTask = Task { + do { + standaloneLogger.info("Standalone server listening on \(self.config.host):\(self.config.port)") + try await app.runService(gracefulShutdownSignals: []) + } catch is CancellationError { + standaloneLogger.info("Standalone server cancelled") + } catch { + standaloneLogger.error("Standalone server failed: \(error.localizedDescription)") + } + } + } + + /// Stop the server. + public func stop() { + serverTask?.cancel() + serverTask = nil + } + + /// Returns the port the server is configured on. + public var port: UInt16 { + config.port + } + + /// Build a Hummingbird application for this server. This is internal so + /// endpoint tests can exercise the router without opening a socket. + nonisolated func makeApplication() -> Application> { + let router = Router() + router.add(middleware: StandaloneHeadersMiddleware()) + + router.get("/health") { _, _ async -> Response in + self.healthResponse() + } + + router.get("/v1/models") { _, _ async -> Response in + await self.modelsResponse() + } + + router.post("/v1/chat/completions") { request, context async -> Response in + await self.chatCompletionsResponse(request: request, context: context) + } + + return Application( + router: router, + configuration: .init( + address: .hostname(config.host, port: Int(config.port)), + serverName: "darkbloom-provider" + ) + ) + } + + // MARK: - Endpoint Handlers + + private nonisolated func healthResponse() -> Response { + jsonResponse(HealthResponse(status: "ok", version: ProviderCore.version)) + } + + private func modelsResponse() -> Response { + let modelObjects = models.map { model in + OpenAIModel( + id: model.id, + object: "model", + created: 0, + owned_by: "local" + ) + } + let response = OpenAIModelsResponse(object: "list", data: modelObjects) + return jsonResponse(response) + } + + private func chatCompletionsResponse( + request: Request, + context: BasicRequestContext + ) async -> Response { + if let contentType = request.headers[.contentType], + !contentType.lowercased().hasPrefix("application/json") + { + return openAIErrorResponse( + status: .unsupportedMediaType, + message: "Content-Type must be application/json" + ) + } + + let chatRequest: ChatCompletionRequest + do { + chatRequest = try await request.decode(as: ChatCompletionRequest.self, context: context) + } catch { + return openAIErrorResponse(status: .badRequest, message: "Invalid request body") + } + + if chatRequest.stream ?? false { + let stream = await scheduler.submit(request: chatRequest) + return streamingCompletionResponse(stream: stream, model: chatRequest.model) + } + + return await nonStreamingCompletion(chatRequest) + } + + private nonisolated func streamingCompletionResponse( + stream: AsyncStream, + model: String + ) -> Response { + var headers = defaultHeaders(contentType: "text/event-stream") + headers[.cacheControl] = "no-cache" + headers[.connection] = "keep-alive" + + let body = ResponseBody { writer in + let formatter = OpenAIFormatter() + let completionID = formatter.makeCompletionID() + let created = Int(Date().timeIntervalSince1970) + + try await writer.write(ByteBuffer(string: formatter.roleChunk( + id: completionID, + model: model, + created: created + ).formatted)) + + var promptTokens = 0 + var completionTokens = 0 + + for await event in stream { + switch event { + case .chunk(let text): + completionTokens += 1 + let chunk = formatter.contentChunk( + id: completionID, + model: model, + created: created, + text: text + ) + try await writer.write(ByteBuffer(string: chunk.formatted)) + + case .info(let prompt, let completion, _): + promptTokens = prompt + completionTokens = completion + + case .error(let message): + standaloneLogger.error("Generation error during streaming: \(message)") + } + } + + let usage = ChunkUsage(prompt_tokens: promptTokens, completion_tokens: completionTokens) + let stopChunk = formatter.stopChunk( + id: completionID, + model: model, + created: created, + finishReason: "stop", + usage: usage + ) + try await writer.write(ByteBuffer(string: stopChunk.formatted)) + try await writer.write(ByteBuffer(string: SSEChunk.done.formatted)) + try await writer.finish(nil) + } + + return Response(status: .ok, headers: headers, body: body) + } + + private func nonStreamingCompletion(_ chatRequest: ChatCompletionRequest) async -> Response { + let stream = await scheduler.submit(request: chatRequest) + let formatter = OpenAIFormatter() + let completionID = formatter.makeCompletionID() + let created = Int(Date().timeIntervalSince1970) + + var fullContent = "" + var promptTokens = 0 + var completionTokens = 0 + + for await event in stream { + switch event { + case .chunk(let text): + fullContent += text + + case .info(let prompt, let completion, _): + promptTokens = prompt + completionTokens = completion + + case .error(let message): + return openAIErrorResponse(status: .internalServerError, message: message) + } + } + + let usage = ChunkUsage(prompt_tokens: promptTokens, completion_tokens: completionTokens) + let response = formatter.nonStreamingResponse( + id: completionID, + model: chatRequest.model, + created: created, + content: fullContent, + finishReason: "stop", + usage: usage + ) + + return jsonResponse(response) + } +} diff --git a/provider-swift/Sources/ProviderCore/Service/LaunchAgent.swift b/provider-swift/Sources/ProviderCore/Service/LaunchAgent.swift new file mode 100644 index 00000000..676b61ff --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Service/LaunchAgent.swift @@ -0,0 +1,277 @@ +/// LaunchAgent -- launchd user agent management for the Darkbloom provider. +/// +/// The provider only runs when the user explicitly starts it via +/// `darkbloom start` or the macOS app's "Go Online" toggle. +/// It does NOT auto-start on login or auto-restart after crashes. +/// The user is always in control of when their GPU is being used. +/// +/// Mirrors the Rust implementation in `provider/src/service.rs`. + +import Foundation + +public enum LaunchAgent: Sendable { + + public static let label = "io.darkbloom.provider" + private static let legacyLabels = ["dev.darkbloom.provider"] + + // MARK: - Paths + + /// Path to the launchd plist: ~/Library/LaunchAgents/io.darkbloom.provider.plist + public static func plistPath() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents") + .appendingPathComponent("\(label).plist") + } + + /// Path to the provider log file: ~/.darkbloom/provider.log + public static func logPath() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".darkbloom/provider.log") + } + + // MARK: - Queries + + /// Whether the plist file exists on disk. + public static func isInstalled() -> Bool { + FileManager.default.fileExists(atPath: plistPath().path) + } + + /// Whether the launchd service is currently loaded (registered with launchd). + public static func isLoaded() -> Bool { + isLoaded(label: label) + } + + private static func isLoaded(label: String) -> Bool { + let target = "gui/\(getuid())/\(label)" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["print", target] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + // MARK: - Install & Start + + /// Write the plist, load the service, and kickstart the process. + /// + /// If the service is already loaded it is unloaded first to pick up + /// any plist changes. The plist is written with: + /// - KeepAlive = false (no auto-restart on crash) + /// - RunAtLoad = false (no auto-start on login) + /// - ProcessType = Interactive (high priority for real-time inference) + /// - Nice = -5 (slight scheduling boost) + /// + /// - Parameters: + /// - coordinatorURL: WebSocket URL for the coordinator (ws:// or wss://). + /// - models: Model IDs to serve (passed as --model flags to `serve`). + /// - idleTimeout: Optional idle timeout in minutes (passed as --idle-timeout). + public static func installAndStart( + coordinatorURL: String, + models: [String] = [], + idleTimeout: UInt64? = nil + ) throws { + // Determine the binary path (current executable) + let binaryPath = currentExecutablePath() + + // If already loaded, unload first so we pick up plist changes. + if isLoaded() { + try unloadService() + Thread.sleep(forTimeInterval: 0.5) + } + for legacyLabel in legacyLabels where isLoaded(label: legacyLabel) { + try unloadService(label: legacyLabel) + } + + try writePlist( + binaryPath: binaryPath, + coordinatorURL: coordinatorURL, + models: models, + idleTimeout: idleTimeout + ) + try loadService() + } + + // MARK: - Stop + + /// Stop the provider by unloading the launchd agent. + /// + /// If the service is not loaded this is a no-op. + public static func stop() throws { + if isLoaded() { + try unloadService() + } + for legacyLabel in legacyLabels where isLoaded(label: legacyLabel) { + try unloadService(label: legacyLabel) + } + } + + // MARK: - Uninstall + + /// Completely remove the service: unload + delete plist. + public static func uninstall() throws { + try stop() + let path = plistPath() + if FileManager.default.fileExists(atPath: path.path) { + try FileManager.default.removeItem(at: path) + } + } + + // MARK: - Private + + private static func writePlist( + binaryPath: String, + coordinatorURL: String, + models: [String], + idleTimeout: UInt64? + ) throws { + let plist = plistPath() + let parentDir = plist.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true + ) + + let log = logPath().path + + // Build the ProgramArguments array. + var programArguments: [String] = [ + binaryPath, + "start", + "--foreground", + "--coordinator-url", + coordinatorURL, + ] + for model in models { + programArguments.append("--model") + programArguments.append(model) + } + if let timeout = idleTimeout { + programArguments.append("--idle-timeout") + programArguments.append("\(timeout)") + } + + let plistDict: [String: Any] = [ + "Label": label, + "ProgramArguments": programArguments, + "KeepAlive": false, + "RunAtLoad": false, + "StandardOutPath": log, + "StandardErrorPath": log, + "ProcessType": "Interactive", + "Nice": -5, + ] + + let data = try PropertyListSerialization.data( + fromPropertyList: plistDict, + format: .xml, + options: 0 + ) + try data.write(to: plist, options: .atomic) + } + + private static func loadService() throws { + let path = plistPath() + let domain = "gui/\(getuid())" + + // Bootstrap registers the service with launchd. + let bootstrap = Process() + bootstrap.executableURL = URL(fileURLWithPath: "/bin/launchctl") + bootstrap.arguments = ["bootstrap", domain, path.path] + + let errPipe = Pipe() + bootstrap.standardOutput = FileHandle.nullDevice + bootstrap.standardError = errPipe + + try bootstrap.run() + bootstrap.waitUntilExit() + + if bootstrap.terminationStatus != 0 { + let stderr = String( + data: errPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + // Error 37 = "already loaded" -- not a real failure. + if !stderr.contains("37:") && !stderr.contains("already loaded") { + throw LaunchAgentError.bootstrapFailed(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + // With RunAtLoad=false, bootstrap registers the service but doesn't + // start it. Kickstart actually launches the process. + let target = "gui/\(getuid())/\(label)" + let kickstart = Process() + kickstart.executableURL = URL(fileURLWithPath: "/bin/launchctl") + kickstart.arguments = ["kickstart", target] + kickstart.standardOutput = FileHandle.nullDevice + kickstart.standardError = FileHandle.nullDevice + _ = try? kickstart.run() + kickstart.waitUntilExit() + } + + private static func unloadService(label serviceLabel: String = LaunchAgent.label) throws { + let target = "gui/\(getuid())/\(serviceLabel)" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["bootout", target] + + let errPipe = Pipe() + process.standardOutput = FileHandle.nullDevice + process.standardError = errPipe + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let stderr = String( + data: errPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + // Error 3 = "could not find service" -- already unloaded, not an error. + if !stderr.contains("3:") && !stderr.contains("could not find service") { + throw LaunchAgentError.bootoutFailed(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + } + + /// Resolve the current executable path. Falls back to ~/.darkbloom/bin/darkbloom. + private static func currentExecutablePath() -> String { + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(MAXPATHLEN) + if _NSGetExecutablePath(&buffer, &size) == 0 { + if let resolved = realpath(buffer, nil) { + defer { free(resolved) } + return String(cString: resolved) + } + return String(cString: buffer) + } + // Fallback + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".darkbloom/bin/darkbloom") + .path + } + +} + +// MARK: - Errors + +public enum LaunchAgentError: Error, CustomStringConvertible, Sendable { + case bootstrapFailed(String) + case bootoutFailed(String) + + public var description: String { + switch self { + case .bootstrapFailed(let detail): + return "launchctl bootstrap failed: \(detail)" + case .bootoutFailed(let detail): + return "launchctl bootout failed: \(detail)" + } + } +} diff --git a/provider-swift/Sources/ProviderCore/Telemetry/TelemetryClient.swift b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryClient.swift new file mode 100644 index 00000000..e29ddbf1 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryClient.swift @@ -0,0 +1,466 @@ +/// Async telemetry client. Owns a background batcher task that collects +/// events and POSTs them to the coordinator in batches. +/// +/// The client is a global singleton (`TelemetryClient.shared`). Call-sites +/// never block on the network; overflow spills to disk via +/// `TelemetryOverflowQueue`. +/// +/// Identity fields (version, machine_id, source) are stamped automatically +/// on every event so call-sites don't need to provide them. + +import Foundation +#if canImport(os) +import os +#endif + +// MARK: - Configuration + +/// Configuration for the telemetry client. +public struct TelemetryClientConfig: Sendable { + /// Coordinator base URL. WebSocket URLs (wss://, ws://) are normalized + /// to their HTTP(S) base for the telemetry ingest endpoint. + public var coordinatorURL: String + + /// Device-linked auth token for `Authorization: Bearer ...`. When nil, + /// events are sent anonymously (stricter server-side rate limits). + public var authToken: String? + + /// This component's version (e.g. "0.4.0-swift"). + public var version: String + + /// Stable per-machine identifier (usually the provider's SE public key). + public var machineId: String + + /// Account the machine is linked to, if any. + public var accountId: String? + + /// Source tag for all events coming through this client. + public var source: TelemetrySource + + /// Max number of events per HTTP batch. The coordinator accepts up to 100. + public var maxBatch: Int + + /// How often to flush a partially-filled batch (seconds). + public var flushIntervalSeconds: TimeInterval + + /// Max events held in the in-memory buffer before spilling to disk. + public var memQueueCap: Int + + public init( + coordinatorURL: String, + source: TelemetrySource = .provider, + authToken: String? = nil, + version: String = ProviderCore.version, + machineId: String = "", + accountId: String? = nil, + maxBatch: Int = 50, + flushIntervalSeconds: TimeInterval = 10.0, + memQueueCap: Int = 1000 + ) { + self.coordinatorURL = coordinatorURL + self.authToken = authToken + self.version = version + self.machineId = machineId + self.accountId = accountId + self.source = source + self.maxBatch = maxBatch + self.flushIntervalSeconds = flushIntervalSeconds + self.memQueueCap = memQueueCap + } +} + +// MARK: - Client + +/// Global telemetry pipeline. Thread-safe; the `emit()` path never blocks +/// on I/O. All mutable state is accessed through `withLock` for async-context +/// safety. A detached Task runs the periodic flush loop. +public final class TelemetryClient: @unchecked Sendable { + + /// Global singleton. Configure via `TelemetryClient.shared.configure(_:)` + /// before first use. Until configured, events are silently dropped. + public static let shared = TelemetryClient() + + private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "telemetry") + + // State protected by a lock -- avoids actor overhead on the hot emit path. + // All access goes through `withLock` for async-context safety. + private let lock = NSLock() + private var buffer: [TelemetryEvent] = [] + private var config: TelemetryClientConfig? + private var flushTask: Task? + private var isShutdown = false + + private let urlSession: URLSession = { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 10 + cfg.timeoutIntervalForResource = 15 + return URLSession(configuration: cfg) + }() + + private init() {} + + // MARK: - Setup + + /// Configure the telemetry pipeline. Must be called before events can flow. + /// Safe to call multiple times (reconfigures). + public func configure(_ config: TelemetryClientConfig) { + let needsFlushTask = lock.withLock { + self.config = config + let needs = flushTask == nil && !isShutdown + return needs + } + + if needsFlushTask { + startFlushLoop() + } + + logger.info("Telemetry configured: endpoint=\(Self.ingestEndpoint(from: config.coordinatorURL))") + } + + /// Update the auth token after device linking. + public func setAuthToken(_ token: String?) { + lock.withLock { config?.authToken = token } + } + + /// Update the machine ID (e.g. after SE key generation). + public func setMachineId(_ machineId: String) { + lock.withLock { config?.machineId = machineId } + } + + /// Update the account ID (e.g. after device linking). + public func setAccountId(_ accountId: String?) { + lock.withLock { config?.accountId = accountId } + } + + // MARK: - Emit + + /// Non-blocking emit. Drops the event if not configured or if shutdown. + /// When the in-memory buffer is full, spills to the disk overflow queue + /// rather than dropping events. + public func emit(_ event: TelemetryEvent) { + // Capture everything we need under the lock in one scoped call. + let result: EmitResult = lock.withLock { + guard let cfg = config, !isShutdown else { + return .dropped + } + + var stamped = event + stamp(&stamped, config: cfg) + + if buffer.count >= cfg.memQueueCap { + return .spillToDisk(stamped) + } + + buffer.append(stamped) + + if buffer.count >= cfg.maxBatch { + let batch = extractBatch(max: cfg.maxBatch) + let endpoint = Self.ingestEndpoint(from: cfg.coordinatorURL) + return .flushBatch(batch ?? [], endpoint: endpoint, authToken: cfg.authToken) + } + + return .buffered + } + + switch result { + case .dropped, .buffered: + break + case .spillToDisk(let ev): + TelemetryOverflowQueue.shared.push(ev) + case .flushBatch(let batch, let endpoint, let authToken): + Task { + await self.sendBatch(batch, endpoint: endpoint, authToken: authToken) + } + } + } + + /// Convenience: build and emit in one call. + public func emit( + kind: TelemetryKind, + severity: TelemetrySeverity, + message: String, + fields: [String: AnyCodableValue]? = nil, + stack: String? = nil, + requestId: String? = nil + ) { + var ev = TelemetryEvent( + source: .provider, + severity: severity, + kind: kind, + message: message + ) + if let fields = fields { + ev.fields = TelemetryFieldFilter.filter(fields) + } + if let stack = stack { + ev.stack = stack + } + if let requestId = requestId { + ev.requestId = requestId + } + emit(ev) + } + + // MARK: - Shutdown + + /// Gracefully flush all pending events and stop the flush loop. + /// Call from the shutdown path (e.g. before process exit). + public func shutdown() async { + let snapshot: ShutdownSnapshot = lock.withLock { + isShutdown = true + flushTask?.cancel() + flushTask = nil + let pending = buffer + buffer.removeAll() + guard let cfg = config else { + return .empty + } + return .flush( + events: pending, + endpoint: Self.ingestEndpoint(from: cfg.coordinatorURL), + authToken: cfg.authToken + ) + } + + switch snapshot { + case .empty: + break + case .flush(let events, let endpoint, let authToken): + if !events.isEmpty { + await sendBatch(events, endpoint: endpoint, authToken: authToken) + } + } + } + + /// Synchronous shutdown for use from signal handlers. Writes remaining + /// events to the disk queue rather than attempting a network send. + public func shutdownSync() { + let pending: [TelemetryEvent] = lock.withLock { + isShutdown = true + flushTask?.cancel() + flushTask = nil + let events = buffer + buffer.removeAll() + return events + } + + for ev in pending { + TelemetryOverflowQueue.shared.push(ev) + } + } + + // MARK: - Flush Loop + + private func startFlushLoop() { + let task = Task.detached { [weak self] in + while !Task.isCancelled { + guard let self = self else { return } + + let interval: TimeInterval = self.lock.withLock { + self.config?.flushIntervalSeconds ?? 10.0 + } + + do { + try await Task.sleep(for: .seconds(interval)) + } catch { + return // Cancelled + } + + await self.flushOnce() + } + } + + lock.withLock { + flushTask = task + } + } + + private func flushOnce() async { + let snapshot: FlushSnapshot = lock.withLock { + guard let cfg = config else { + return .noConfig + } + let batch = extractBatch(max: cfg.maxBatch) + let endpoint = Self.ingestEndpoint(from: cfg.coordinatorURL) + let authToken = cfg.authToken + let bufferEmpty = buffer.isEmpty + if let batch = batch { + return .sendBatch(batch, endpoint: endpoint, authToken: authToken) + } else if bufferEmpty { + return .drainDisk(endpoint: endpoint, authToken: authToken, limit: cfg.maxBatch) + } + return .noConfig + } + + switch snapshot { + case .noConfig: + break + case .sendBatch(let batch, let endpoint, let authToken): + await sendBatch(batch, endpoint: endpoint, authToken: authToken) + case .drainDisk(let endpoint, let authToken, let limit): + await drainDiskQueue(endpoint: endpoint, authToken: authToken, limit: limit) + } + } + + // MARK: - Batch extraction (must hold lock) + + /// Extract up to `max` events from the buffer. Returns nil if empty. + /// Caller must hold `lock`. + private func extractBatch(max: Int) -> [TelemetryEvent]? { + guard !buffer.isEmpty else { return nil } + let count = min(buffer.count, max) + let batch = Array(buffer.prefix(count)) + buffer.removeFirst(count) + return batch + } + + // MARK: - Network + + private func sendBatch( + _ events: [TelemetryEvent], + endpoint: String, + authToken: String? + ) async { + guard !events.isEmpty, let url = URL(string: endpoint) else { return } + + let batch = TelemetryBatch(events: events) + let encoder = JSONEncoder() + + guard let body = try? encoder.encode(batch) else { + logger.warning("Telemetry: failed to encode batch of \(events.count) events") + spillToDisk(events) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + + do { + let (_, response) = try await urlSession.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + logger.warning("Telemetry ingest failed: HTTP \(http.statusCode)") + spillToDisk(events) + } + } catch { + logger.debug("Telemetry send failed: \(error.localizedDescription)") + spillToDisk(events) + } + } + + private func spillToDisk(_ events: [TelemetryEvent]) { + for ev in events { + TelemetryOverflowQueue.shared.push(ev) + } + } + + private func drainDiskQueue(endpoint: String, authToken: String?, limit: Int) async { + let events = TelemetryOverflowQueue.shared.drain(limit: limit) + guard !events.isEmpty else { return } + + let batch = TelemetryBatch(events: events) + guard let url = URL(string: endpoint), + let body = try? JSONEncoder().encode(batch) else { + for ev in events { + TelemetryOverflowQueue.shared.push(ev) + } + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + + do { + let (_, response) = try await urlSession.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + logger.debug("Telemetry disk drain failed: HTTP \(http.statusCode), re-queuing") + for ev in events { TelemetryOverflowQueue.shared.push(ev) } + } + } catch { + logger.debug("Telemetry disk drain failed: \(error.localizedDescription), re-queuing") + for ev in events { TelemetryOverflowQueue.shared.push(ev) } + } + } + + // MARK: - Stamping + + /// Stamp server-relevant defaults (version, machine_id, source, account) + /// that individual call sites don't bother setting. + private func stamp(_ ev: inout TelemetryEvent, config: TelemetryClientConfig) { + if ev.version == nil || ev.version?.isEmpty == true { + ev.version = config.version + } + if ev.machineId == nil || ev.machineId?.isEmpty == true { + ev.machineId = config.machineId.isEmpty ? nil : config.machineId + } + if ev.accountId == nil || ev.accountId?.isEmpty == true { + ev.accountId = config.accountId + } + // Source is always the client's configured source -- trust the transport. + ev.source = config.source + } + + // MARK: - URL normalization + + /// Convert a coordinator URL (which may be a WebSocket URL) to the HTTPS + /// telemetry ingest endpoint. + public static func ingestEndpoint(from coordinatorURL: String) -> String { + var base = coordinatorURL + while base.hasSuffix("/") { + base = String(base.dropLast()) + } + if base.hasPrefix("wss://") { + base = "https://" + base.dropFirst("wss://".count) + } else if base.hasPrefix("ws://") { + base = "http://" + base.dropFirst("ws://".count) + } + if base.hasSuffix("/ws/provider") { + base = String(base.dropLast("/ws/provider".count)) + } + while base.hasSuffix("/") { + base = String(base.dropLast()) + } + return base + "/v1/telemetry/events" + } +} + +// MARK: - Internal result types (avoid holding locks across await points) + +private enum EmitResult { + case dropped + case buffered + case spillToDisk(TelemetryEvent) + case flushBatch([TelemetryEvent], endpoint: String, authToken: String?) +} + +private enum FlushSnapshot { + case noConfig + case sendBatch([TelemetryEvent], endpoint: String, authToken: String?) + case drainDisk(endpoint: String, authToken: String?, limit: Int) +} + +private enum ShutdownSnapshot { + case empty + case flush(events: [TelemetryEvent], endpoint: String, authToken: String?) +} + +// MARK: - Logger shim + +#if !canImport(os) +private struct Logger { + let subsystem: String + let category: String + func info(_ msg: String) { print("[\(category)] INFO: \(msg)") } + func warning(_ msg: String) { print("[\(category)] WARN: \(msg)") } + func debug(_ msg: String) { print("[\(category)] DEBUG: \(msg)") } +} +#endif diff --git a/provider-swift/Sources/ProviderCore/Telemetry/TelemetryEvent.swift b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryEvent.swift new file mode 100644 index 00000000..d592032b --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryEvent.swift @@ -0,0 +1,246 @@ +/// Telemetry wire types -- mirror of +/// `coordinator/internal/protocol/telemetry.go`. +/// +/// JSON shapes MUST match the Go definitions. Source, Severity, and Kind raw +/// values are the exact strings the coordinator expects. Any mismatch silently +/// coerces to "custom" server-side, which breaks filtering. + +import Foundation + +// MARK: - Enums + +/// Source of a telemetry event (which component produced it). +/// Raw values match `TelemetrySource` constants in Go. +public enum TelemetrySource: String, Codable, Sendable { + case coordinator + case provider + case app + case console + case bridge +} + +/// Severity level, narrowed subset of syslog/RFC 5424. +/// Raw values match `TelemetrySeverity` constants in Go. +public enum TelemetrySeverity: String, Codable, Sendable { + case debug + case info + case warn + case error + case fatal +} + +/// Coarse categorization for filtering in the admin UI. +/// Raw values match `TelemetryKind` constants in Go. +public enum TelemetryKind: String, Codable, Sendable { + case panic + case httpError = "http_error" + case protocolError = "protocol_error" + case backendCrash = "backend_crash" + case attestationFailure = "attestation_failure" + case inferenceError = "inference_error" + case runtimeMismatch = "runtime_mismatch" + case connectivity + case log + case custom +} + +// MARK: - Event + +/// Single telemetry record. Serialization matches the Go `TelemetryEvent` and +/// Rust `TelemetryEvent` wire shape exactly: snake_case keys, omitting empty +/// optional fields. +public struct TelemetryEvent: Codable, Sendable { + public var id: String + /// ISO 8601 with fractional seconds, matching Go `time.Time` and Rust + /// `chrono::Utc::now().to_rfc3339_opts(Nanos, true)`. + public var timestamp: String + public var source: TelemetrySource + public var severity: TelemetrySeverity + public var kind: TelemetryKind + public var version: String? + public var machineId: String? + public var accountId: String? + public var requestId: String? + public var sessionId: String? + public var message: String + public var fields: [String: AnyCodableValue]? + public var stack: String? + + enum CodingKeys: String, CodingKey { + case id, timestamp, source, severity, kind, message + case version + case machineId = "machine_id" + case accountId = "account_id" + case requestId = "request_id" + case sessionId = "session_id" + case fields, stack + } + + /// Build a new event with sensible defaults (id, timestamp, session_id). + public init( + source: TelemetrySource, + severity: TelemetrySeverity, + kind: TelemetryKind, + message: String + ) { + self.id = UUID().uuidString.lowercased() + self.timestamp = Self.isoNow() + self.source = source + self.severity = severity + self.kind = kind + self.message = message + self.sessionId = TelemetrySession.id + } + + // MARK: - Builder methods + + /// Attach structured fields. + public func withFields(_ fields: [String: AnyCodableValue]) -> TelemetryEvent { + var copy = self + copy.fields = fields + return copy + } + + /// Attach a single field, merging into existing fields. + public func withField(_ key: String, _ value: AnyCodableValue) -> TelemetryEvent { + var copy = self + var merged = copy.fields ?? [:] + merged[key] = value + copy.fields = merged + return copy + } + + /// Attach a stack trace. + public func withStack(_ stack: String) -> TelemetryEvent { + var copy = self + copy.stack = stack + return copy + } + + /// Attach a request ID for correlation. + public func withRequestId(_ requestId: String) -> TelemetryEvent { + var copy = self + copy.requestId = requestId + return copy + } + + // MARK: - Timestamp + + /// ISO 8601 formatter is not Sendable, but we only access it through this + /// function which creates a fresh instance each call. The cost is negligible + /// compared to the network flush path. + static func isoNow() -> String { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fmt.string(from: Date()) + } +} + +// MARK: - Batch + +/// Wire shape for batch ingestion: `POST /v1/telemetry/events`. +public struct TelemetryBatch: Codable, Sendable { + public var events: [TelemetryEvent] + + public init(events: [TelemetryEvent]) { + self.events = events + } +} + +// MARK: - Session ID + +/// Per-process UUID. Events from the same boot share this ID so the admin UI +/// can group a crash report with the log lines leading up to it. +public enum TelemetrySession { + public static let id: String = UUID().uuidString.lowercased() +} + +// MARK: - AnyCodableValue + +/// Lightweight Codable wrapper for JSON-compatible primitive values. +/// Used for the `fields` dictionary on telemetry events. +public struct AnyCodableValue: Codable, Sendable, CustomStringConvertible { + public let value: any Sendable + + public init(_ value: any Sendable) { + self.value = value + } + + // Convenience initializers for common types + public static func string(_ s: String) -> AnyCodableValue { AnyCodableValue(s) } + public static func int(_ i: Int) -> AnyCodableValue { AnyCodableValue(i) } + public static func int64(_ i: Int64) -> AnyCodableValue { AnyCodableValue(i) } + public static func double(_ d: Double) -> AnyCodableValue { AnyCodableValue(d) } + public static func bool(_ b: Bool) -> AnyCodableValue { AnyCodableValue(b) } + + public var description: String { + switch value { + case let s as String: return s + case let i as Int: return String(i) + case let i as Int64: return String(i) + case let d as Double: return String(d) + case let b as Bool: return String(b) + default: return "null" + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let b = try? container.decode(Bool.self) { + value = b + } else if let i = try? container.decode(Int64.self) { + value = i + } else if let d = try? container.decode(Double.self) { + value = d + } else if let s = try? container.decode(String.self) { + value = s + } else { + value = "null" as String + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let b as Bool: + try container.encode(b) + case let i as Int: + try container.encode(i) + case let i as Int64: + try container.encode(i) + case let i as UInt64: + try container.encode(i) + case let d as Double: + try container.encode(d) + case let s as String: + try container.encode(s) + default: + try container.encodeNil() + } + } +} + +// MARK: - Field allowlist + +/// Client-side allowlist. The coordinator enforces its own, but we preempt +/// bandwidth waste. Keys must match the server list in +/// `coordinator/internal/api/telemetry_handlers.go`. +public enum TelemetryFieldFilter { + private static let allowed: Set = [ + "component", "operation", "duration_ms", "attempt", "endpoint", + "status_code", "error_class", "error", "model", "backend", + "exit_code", "signal", "hardware_chip", "memory_gb", "macos_version", + "handler", "provider_id", "trust_level", "queue_depth", "reason", + "runtime_component", "reconnect_count", "last_error", "ws_state", + "billing_method", "payment_failed", "target", + ] + + /// Filter a dictionary to only the keys the coordinator accepts. + public static func filter(_ input: [String: AnyCodableValue]) -> [String: AnyCodableValue]? { + var out: [String: AnyCodableValue] = [:] + for (k, v) in input where allowed.contains(k) { + out[k] = v + } + return out.isEmpty ? nil : out + } +} diff --git a/provider-swift/Sources/ProviderCore/Telemetry/TelemetryOverflowQueue.swift b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryOverflowQueue.swift new file mode 100644 index 00000000..fa469f6d --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Telemetry/TelemetryOverflowQueue.swift @@ -0,0 +1,167 @@ +/// Disk-backed overflow queue for telemetry events. +/// +/// Format: JSONL (one JSON-encoded `TelemetryEvent` per line). +/// Location: `~/.darkbloom/telemetry-queue.jsonl`. +/// Size cap: 5 MB. On overflow, the oldest half of the file is discarded. +/// +/// The queue is intentionally simple: open-for-append for writes, read+rewrite +/// for drains. It is NOT a cross-process durable queue -- one provider process +/// owns the file. A crash mid-write may lose the last partial line; that's +/// acceptable because telemetry is best-effort. + +import Foundation +#if canImport(os) +import os +#endif + +// MARK: - Overflow Queue + +public final class TelemetryOverflowQueue: @unchecked Sendable { + + /// Shared instance. Uses the default path `~/.darkbloom/telemetry-queue.jsonl`. + public static let shared = TelemetryOverflowQueue() + + /// Maximum size of the disk queue before rotation kicks in. + private static let maxBytes: UInt64 = 5 * 1024 * 1024 + + private let path: URL + private let lock = NSLock() + private let logger = Logger(subsystem: "dev.darkbloom.provider", category: "telemetry-queue") + private let encoder = JSONEncoder() + + public init(path: URL? = nil) { + if let path = path { + self.path = path + } else { + let home = FileManager.default.homeDirectoryForCurrentUser + self.path = home + .appendingPathComponent(".darkbloom") + .appendingPathComponent("telemetry-queue.jsonl") + } + } + + // MARK: - Push + + /// Append an event to the disk queue. Thread-safe. + public func push(_ event: TelemetryEvent) { + lock.lock() + defer { lock.unlock() } + + guard let line = try? encoder.encode(event), + let lineString = String(data: line, encoding: .utf8) else { + return // unencodable -- best-effort drop + } + + ensureParentDirectory() + rotateIfNeeded() + + guard let handle = try? FileHandle(forWritingTo: path) else { + // File doesn't exist yet -- create it. + let content = lineString + "\n" + try? content.write(to: path, atomically: false, encoding: .utf8) + return + } + + defer { try? handle.close() } + handle.seekToEndOfFile() + if let data = (lineString + "\n").data(using: .utf8) { + handle.write(data) + } + } + + // MARK: - Drain + + /// Drain up to `limit` events from the head of the queue and rewrite the + /// rest back to disk. Returns the drained events. Thread-safe. + public func drain(limit: Int) -> [TelemetryEvent] { + lock.lock() + defer { lock.unlock() } + + guard FileManager.default.fileExists(atPath: path.path) else { + return [] + } + + guard let content = try? String(contentsOf: path, encoding: .utf8) else { + return [] + } + + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + let decoder = JSONDecoder() + var drained: [TelemetryEvent] = [] + var remaining: [String] = [] + + for line in lines { + if drained.count < limit { + if let data = line.data(using: .utf8), + let ev = try? decoder.decode(TelemetryEvent.self, from: data) { + drained.append(ev) + } + // Malformed lines: drop silently. + continue + } + remaining.append(line) + } + + // Rewrite the remaining lines atomically. + let tmpPath = path.appendingPathExtension("tmp") + let remainingContent = remaining.isEmpty ? "" : remaining.joined(separator: "\n") + "\n" + + if remaining.isEmpty { + // Nothing left -- remove the file. + try? FileManager.default.removeItem(at: path) + } else { + do { + try remainingContent.write(to: tmpPath, atomically: true, encoding: .utf8) + _ = try FileManager.default.replaceItemAt(path, withItemAt: tmpPath) + } catch { + // Best-effort: try a simple overwrite. + try? remainingContent.write(to: path, atomically: true, encoding: .utf8) + try? FileManager.default.removeItem(at: tmpPath) + } + } + + return drained + } + + // MARK: - Rotation + + /// Trim the queue to its most recent half when it grows past `maxBytes`. + /// Caller must hold `lock`. + private func rotateIfNeeded() { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: path.path), + let size = attrs[.size] as? UInt64, + size > Self.maxBytes else { + return + } + + guard let content = try? String(contentsOf: path, encoding: .utf8) else { + return + } + + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + let keepFrom = lines.count / 2 + let kept = Array(lines[keepFrom...]) + let newContent = kept.joined(separator: "\n") + "\n" + try? newContent.write(to: path, atomically: true, encoding: .utf8) + + logger.debug("Telemetry queue rotated: dropped \(keepFrom) old events, kept \(kept.count)") + } + + /// Ensure the parent directory exists. Caller must hold `lock`. + private func ensureParentDirectory() { + let dir = path.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: dir.path) { + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + } +} + +// MARK: - Logger shim + +#if !canImport(os) +private struct Logger { + let subsystem: String + let category: String + func debug(_ msg: String) { print("[\(category)] DEBUG: \(msg)") } +} +#endif diff --git a/provider-swift/Sources/ProviderCore/Update/SelfUpdater.swift b/provider-swift/Sources/ProviderCore/Update/SelfUpdater.swift new file mode 100644 index 00000000..e3cd9588 --- /dev/null +++ b/provider-swift/Sources/ProviderCore/Update/SelfUpdater.swift @@ -0,0 +1,279 @@ +import Foundation +import CryptoKit + +/// Release information returned by the coordinator. +public struct ReleaseInfo: Sendable { + public let version: String + public let platform: String + public let url: String + public let sha256: String + + public init(version: String, platform: String, url: String, sha256: String) { + self.version = version + self.platform = platform + self.url = url + self.sha256 = sha256 + } +} + +/// Result of an update check. +public enum UpdateCheckResult: Sendable { + case upToDate(currentVersion: String) + case updateAvailable(current: String, latest: ReleaseInfo) + case checkFailed(reason: String) +} + +/// Result of an update attempt. +public enum UpdateResult: Sendable { + case updated(from: String, to: String) + case alreadyUpToDate(version: String) + case downloadFailed(reason: String) + case hashMismatch(expected: String, got: String) + case replaceFailed(reason: String) +} + +/// Self-updater that checks the coordinator for new releases and applies updates. +public struct SelfUpdater: Sendable { + + private let coordinatorBaseURL: String + + public init(coordinatorBaseURL: String) { + // Convert WebSocket URL to HTTP if needed + var base = coordinatorBaseURL + if base.hasPrefix("ws://") { + base = "http://" + base.dropFirst("ws://".count) + } else if base.hasPrefix("wss://") { + base = "https://" + base.dropFirst("wss://".count) + } + // Strip trailing path components (e.g. /ws/provider) + if let url = URL(string: base), let scheme = url.scheme, let host = url.host { + let port = url.port.map { ":\($0)" } ?? "" + base = "\(scheme)://\(host)\(port)" + } + self.coordinatorBaseURL = base + } + + // MARK: - Version Check + + /// Check the coordinator for the latest release. + public func checkForUpdate() async -> UpdateCheckResult { + let currentVersion = ProviderCore.version + let endpoint = "\(coordinatorBaseURL)/v1/releases/latest?platform=macos-arm64" + + guard let url = URL(string: endpoint) else { + return .checkFailed(reason: "invalid coordinator URL: \(endpoint)") + } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + return .checkFailed(reason: "unexpected response type") + } + + guard httpResponse.statusCode == 200 else { + return .checkFailed( + reason: "coordinator returned HTTP \(httpResponse.statusCode)" + ) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .checkFailed(reason: "invalid JSON response") + } + + guard let version = json["version"] as? String, + let platform = json["platform"] as? String, + let downloadURL = json["url"] as? String + else { + return .checkFailed(reason: "missing required fields in release response") + } + guard let sha256 = (json["bundle_hash"] as? String) + ?? (json["sha256"] as? String) + ?? (json["binary_hash"] as? String) + else { + return .checkFailed(reason: "missing release hash field") + } + + let release = ReleaseInfo( + version: version, + platform: platform, + url: downloadURL, + sha256: sha256 + ) + + if isNewer(latest: version, current: currentVersion) { + return .updateAvailable(current: currentVersion, latest: release) + } else { + return .upToDate(currentVersion: currentVersion) + } + } catch { + return .checkFailed(reason: error.localizedDescription) + } + } + + // MARK: - Download and Verify + + /// Download the release binary and verify its SHA-256 hash. + public func downloadAndVerify(release: ReleaseInfo) async -> Result { + guard let downloadURL = URL(string: release.url) else { + return .failure(.invalidURL(release.url)) + } + + do { + let (tempFileURL, response) = try await URLSession.shared.download(from: downloadURL) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + return .failure(.downloadFailed("HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)")) + } + + // Verify SHA-256 + let fileData = try Data(contentsOf: tempFileURL) + let digest = SHA256.hash(data: fileData) + let computedHash = digest.map { String(format: "%02x", $0) }.joined() + + guard computedHash == release.sha256.lowercased() else { + try? FileManager.default.removeItem(at: tempFileURL) + return .failure(.hashMismatch(expected: release.sha256, got: computedHash)) + } + + return .success(tempFileURL) + } catch { + return .failure(.downloadFailed(error.localizedDescription)) + } + } + + // MARK: - Replace Binary + + /// Replace the running binary with the downloaded one via atomic rename. + /// + /// This is a best-effort operation. On macOS, replacing a running binary + /// requires the process to restart afterward. + public func replaceBinary(with downloadedFile: URL) -> Result { + guard let executablePath = Bundle.main.executablePath else { + return .failure(.replaceFailed("could not determine current executable path")) + } + + let currentBinary = URL(fileURLWithPath: executablePath) + let backupPath = currentBinary.appendingPathExtension("bak") + + let fm = FileManager.default + + do { + // Remove old backup if it exists + if fm.fileExists(atPath: backupPath.path) { + try fm.removeItem(at: backupPath) + } + + // Back up current binary + try fm.moveItem(at: currentBinary, to: backupPath) + + // Move new binary into place + try fm.moveItem(at: downloadedFile, to: currentBinary) + + // Make executable + try fm.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: currentBinary.path + ) + + // Clean up backup + try? fm.removeItem(at: backupPath) + + return .success(()) + } catch { + // Try to restore backup + if fm.fileExists(atPath: backupPath.path), + !fm.fileExists(atPath: currentBinary.path) + { + try? fm.moveItem(at: backupPath, to: currentBinary) + } + return .failure(.replaceFailed(error.localizedDescription)) + } + } + + // MARK: - Full Update Flow + + /// Check for updates and apply if available. + public func update() async -> UpdateResult { + let checkResult = await checkForUpdate() + + switch checkResult { + case .upToDate(let version): + return .alreadyUpToDate(version: version) + + case .checkFailed(let reason): + return .downloadFailed(reason: "update check failed: \(reason)") + + case .updateAvailable(let current, let release): + let downloadResult = await downloadAndVerify(release: release) + + switch downloadResult { + case .failure(let error): + switch error { + case .hashMismatch(let expected, let got): + return .hashMismatch(expected: expected, got: got) + case .downloadFailed(let reason): + return .downloadFailed(reason: reason) + case .invalidURL(let url): + return .downloadFailed(reason: "invalid download URL: \(url)") + case .replaceFailed(let reason): + return .replaceFailed(reason: reason) + } + + case .success(let tempFile): + let replaceResult = replaceBinary(with: tempFile) + switch replaceResult { + case .success: + return .updated(from: current, to: release.version) + case .failure(let error): + switch error { + case .replaceFailed(let reason): + return .replaceFailed(reason: reason) + default: + return .replaceFailed(reason: "\(error)") + } + } + } + } + } + + // MARK: - Version Comparison + + /// Compare semver-style version strings. Returns true if `latest` is newer than `current`. + /// + /// Handles versions like "0.4.0-swift", "0.4.1", etc. The suffix after '-' is + /// stripped for comparison (pre-release suffixes are ignored for ordering). + internal static func isNewer(latest: String, current: String) -> Bool { + let latestParts = parseVersion(latest) + let currentParts = parseVersion(current) + + for i in 0.. c { return true } + if l < c { return false } + } + return false + } + + private static func parseVersion(_ version: String) -> [Int] { + // Strip pre-release suffix (e.g. "-swift", "-beta1") + let base = version.split(separator: "-").first ?? Substring(version) + return base.split(separator: ".").compactMap { Int($0) } + } + + private func isNewer(latest: String, current: String) -> Bool { + Self.isNewer(latest: latest, current: current) + } +} + +// MARK: - Errors + +public enum UpdateError: Error, Sendable { + case invalidURL(String) + case downloadFailed(String) + case hashMismatch(expected: String, got: String) + case replaceFailed(String) +} diff --git a/provider-swift/Sources/darkbloom/BenchmarkCommand.swift b/provider-swift/Sources/darkbloom/BenchmarkCommand.swift new file mode 100644 index 00000000..1e613c83 --- /dev/null +++ b/provider-swift/Sources/darkbloom/BenchmarkCommand.swift @@ -0,0 +1,61 @@ +import ArgumentParser +import ProviderCore + +struct Benchmark: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run standardized inference benchmarks.", + discussion: "Loads an MLX model and measures prefill latency, decode throughput, and total generation time." + ) + + @OptionGroup var configOptions: ConfigOptions + + @Option(help: "Model ID to benchmark. Defaults to the largest model that fits.") + var model: String? + + @Option(help: "Prompt for the benchmark generation.") + var prompt = ModelBenchmark.defaultPrompt + + @Option(help: "Number of benchmark iterations.") + var iterations = ModelBenchmark.defaultIterations + + @Option(name: .long, help: "Maximum tokens to generate per iteration.") + var maxTokens = ModelBenchmark.defaultMaxTokens + + mutating func run() async throws { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + + guard let hardware = snapshot.hardware else { + printError("hardware detection failed: \(snapshot.hardwareError?.localizedDescription ?? "unknown")") + throw ExitCode.failure + } + + let models = advertisedModels(from: snapshot.models, config: snapshot.config) + + guard let selectedModel = ModelBenchmark.selectModel( + models: models, + preferredModel: model ?? snapshot.config.backend.model + ) else { + printError("no suitable model found for benchmarking. Download an MLX model first.") + throw ExitCode.failure + } + + guard let modelPath = ModelScanner.resolveLocalPath(modelID: selectedModel.id) else { + printError("could not resolve local path for model '\(selectedModel.id)'") + throw ExitCode.failure + } + + print("darkbloom benchmark") + print("") + + let report = try await ModelBenchmark.run( + modelID: selectedModel.id, + modelDirectory: modelPath, + prompt: prompt, + iterations: iterations, + maxTokens: maxTokens, + hardware: hardware + ) + + report.printTable() + } +} diff --git a/provider-swift/Sources/darkbloom/Darkbloom.swift b/provider-swift/Sources/darkbloom/Darkbloom.swift new file mode 100644 index 00000000..2ad0f552 --- /dev/null +++ b/provider-swift/Sources/darkbloom/Darkbloom.swift @@ -0,0 +1,213 @@ +import Foundation +import ArgumentParser +import ProviderCore + +@main +struct Darkbloom: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "darkbloom", + abstract: "Swift-native provider CLI for Darkbloom.", + discussion: "Runs on Apple Silicon Macs. Connects to the coordinator, serves inference requests via mlx-swift.", + version: ProviderCore.version, + subcommands: [ + Start.self, + Stop.self, + Status.self, + Doctor.self, + Models.self, + Login.self, + Logout.self, + Benchmark.self, + Update.self, + ] + ) + + mutating func run() async throws { + throw CleanExit.helpRequest(self) + } +} + +// MARK: - Shared Options + +struct ConfigOptions: ParsableArguments { + @Option(name: [.customShort("c"), .long], help: "Path to provider TOML config.") + var config: String? +} + +// MARK: - Runtime Snapshot + +struct RuntimeSnapshot { + let configPath: URL + let configFileExists: Bool + let config: ProviderConfig + let hardware: HardwareInfo? + let hardwareError: Error? + let models: [ModelInfo] +} + +func loadRuntimeSnapshot(configOptions: ConfigOptions) throws -> RuntimeSnapshot { + let configPath = try resolveConfigPath(configOptions.config) + let configFileExists = FileManager.default.fileExists(atPath: configPath.path) + + let hardware: HardwareInfo? + let hardwareError: Error? + do { + hardware = try HardwareDetector.detect() + hardwareError = nil + } catch { + hardware = nil + hardwareError = error + } + + let config: ProviderConfig + if configFileExists { + config = try ConfigManager.load(from: configPath) + } else if let hardware { + config = ProviderConfig.defaultForHardware(hardware) + } else { + config = ConfigManager.loadDefault() + } + + let models = hardware.map { ModelScanner.scanModels(hardwareInfo: $0) } ?? [] + + return RuntimeSnapshot( + configPath: configPath, + configFileExists: configFileExists, + config: config, + hardware: hardware, + hardwareError: hardwareError, + models: models + ) +} + +private func resolveConfigPath(_ rawPath: String?) throws -> URL { + if let rawPath { + return URL(fileURLWithPath: (rawPath as NSString).expandingTildeInPath) + } + return try ConfigManager.defaultConfigPath() +} + +func describeConfigPath(_ snapshot: RuntimeSnapshot) -> String { + if snapshot.configFileExists { + return snapshot.configPath.path + } + return "\(snapshot.configPath.path) (missing, using defaults)" +} + +func advertisedModels( + from models: [ModelInfo], + config: ProviderConfig, + modelOverrides: [String] = [], + includeDisabled: Bool = false +) -> [ModelInfo] { + if !modelOverrides.isEmpty { + let byID = Dictionary(uniqueKeysWithValues: models.map { ($0.id, $0) }) + return modelOverrides.compactMap { byID[$0] } + } + guard !includeDisabled, !config.backend.enabledModels.isEmpty else { + return models + } + let enabled = Set(config.backend.enabledModels) + return models.filter { enabled.contains($0.id) } +} + +func attachWeightHashes(to models: [ModelInfo]) -> ([ModelInfo], [String: String]) { + var hashes: [String: String] = [:] + let withHashes = models.map { model -> ModelInfo in + var updated = model + if let hash = WeightHasher.computeHash(for: model.id) { + updated.weightHash = hash + hashes[model.id] = hash + } + return updated + } + return (withHashes, hashes) +} + +// MARK: - Output Helpers + +struct ModelsOutput: Encodable { + let cacheDirectory: String? + let filteredByConfig: Bool + let models: [ModelInfo] +} + +struct HashOutput: Encodable { + let model: String + let weightHash: String +} + +func printModelTable(_ models: [ModelInfo]) { + let rows = models.map { model in + [ + model.id, + model.modelType ?? "-", + model.quantization ?? "-", + formatParameters(model.parameters), + formatBytes(model.sizeBytes), + String(format: "%.1f GB", model.estimatedMemoryGb), + ] + } + + printTable( + headers: ["ID", "TYPE", "QUANT", "PARAMS", "SIZE", "EST MEM"], + rows: rows + ) +} + +private func printTable(headers: [String], rows: [[String]]) { + let widths = headers.enumerated().map { index, header in + rows.reduce(header.count) { max($0, $1[index].count) } + } + + func line(_ columns: [String]) -> String { + columns.enumerated().map { index, value in + value.padding(toLength: widths[index], withPad: " ", startingAt: 0) + }.joined(separator: " ") + } + + print(line(headers)) + print(line(widths.map { String(repeating: "-", count: $0) })) + for row in rows { + print(line(row)) + } +} + +private func formatParameters(_ value: UInt64?) -> String { + guard let value else { return "-" } + if value >= 1_000_000_000 { + return String(format: "%.1fB", Double(value) / 1_000_000_000.0) + } + if value >= 1_000_000 { + return String(format: "%.1fM", Double(value) / 1_000_000.0) + } + return "\(value)" +} + +private func formatBytes(_ bytes: UInt64) -> String { + let gib = Double(bytes) / 1_073_741_824.0 + if gib >= 1 { + return String(format: "%.1f GB", gib) + } + let mib = Double(bytes) / 1_048_576.0 + return String(format: "%.1f MB", mib) +} + +func printJSON(_ value: T) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw ValidationError("failed to encode JSON output") + } + print(string) +} + +func printError(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) +} + +private func failNotImplemented(_ message: String) throws { + printError(message) + throw ExitCode.failure +} diff --git a/provider-swift/Sources/darkbloom/DoctorCommand.swift b/provider-swift/Sources/darkbloom/DoctorCommand.swift new file mode 100644 index 00000000..093f1709 --- /dev/null +++ b/provider-swift/Sources/darkbloom/DoctorCommand.swift @@ -0,0 +1,158 @@ +import Foundation +import ArgumentParser +import ProviderCore + +struct Doctor: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run local provider diagnostics.", + discussion: "Diagnostics are read-only except for subprocesses used by public ProviderCore checks." + ) + + @OptionGroup var configOptions: ConfigOptions + + @Flag(help: "Treat warning-level checks as failures.") + var strict = false + + mutating func run() async throws { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + let checks = buildDoctorChecks(snapshot: snapshot) + + print("darkbloom doctor") + print("Config: \(describeConfigPath(snapshot))") + for check in checks { + print("\(check.status.marker) \(check.name): \(check.detail)") + } + + let hasFailure = checks.contains { $0.status == .fail } + let hasWarning = checks.contains { $0.status == .warn } + + if hasFailure || (strict && hasWarning) { + throw ExitCode.failure + } + } +} + +// MARK: - Doctor + +enum CheckStatus: Equatable { + case pass + case warn + case fail + + var marker: String { + switch self { + case .pass: return "[PASS]" + case .warn: return "[WARN]" + case .fail: return "[FAIL]" + } + } +} + +struct DoctorCheck { + let name: String + let status: CheckStatus + let detail: String +} + +func buildDoctorChecks(snapshot: RuntimeSnapshot) -> [DoctorCheck] { + var checks: [DoctorCheck] = [] + + if let hardware = snapshot.hardware { + checks.append(.init( + name: "hardware", + status: .pass, + detail: "\(hardware.chipName), \(hardware.memoryGb) GB RAM, \(hardware.gpuCores) GPU cores" + )) + } else { + checks.append(.init( + name: "hardware", + status: .fail, + detail: snapshot.hardwareError?.localizedDescription ?? "hardware detection failed" + )) + } + + checks.append(.init( + name: "config", + status: snapshot.configFileExists ? .pass : .warn, + detail: snapshot.configFileExists ? "loaded" : "missing, defaults are in memory only" + )) + + if let cacheDir = ModelScanner.defaultCacheDirectory(), + FileManager.default.fileExists(atPath: cacheDir.path) { + checks.append(.init( + name: "huggingface cache", + status: .pass, + detail: cacheDir.path + )) + } else { + checks.append(.init( + name: "huggingface cache", + status: .warn, + detail: "not found" + )) + } + + checks.append(.init( + name: "local mlx models", + status: snapshot.models.isEmpty ? .warn : .pass, + detail: "\(snapshot.models.count) discovered" + )) + + let sipEnabled = checkSIPEnabled() + checks.append(.init( + name: "sip", + status: sipEnabled ? .pass : .fail, + detail: sipEnabled ? "enabled" : "disabled" + )) + + let rdmaDisabled = checkRDMADisabled() + checks.append(.init( + name: "rdma", + status: rdmaDisabled ? .pass : .warn, + detail: rdmaDisabled ? "disabled" : "enabled; allowed for RDMA-aware runtimes" + )) + + let secureBoot = checkSecureBootEnabled() + checks.append(.init( + name: "secure boot", + status: secureBoot ? .pass : .warn, + detail: secureBoot ? "enabled" : "not confirmed" + )) + + let authenticatedRoot = checkAuthenticatedRootEnabled() + checks.append(.init( + name: "authenticated root", + status: authenticatedRoot ? .pass : .warn, + detail: authenticatedRoot ? "enabled" : "not confirmed" + )) + + let hardenedRuntime = checkHardenedRuntimeEnabled() + checks.append(.init( + name: "hardened runtime", + status: hardenedRuntime ? .pass : .warn, + detail: hardenedRuntime ? "enabled" : "not confirmed for this executable" + )) + + let debuggerAttached = checkDebuggerAttached() + checks.append(.init( + name: "debugger", + status: debuggerAttached ? .fail : .pass, + detail: debuggerAttached ? "attached" : "not attached" + )) + + if let binaryHash = selfBinaryHash() { + checks.append(.init( + name: "binary hash", + status: .pass, + detail: binaryHash + )) + } else { + checks.append(.init( + name: "binary hash", + status: .warn, + detail: "could not compute" + )) + } + + return checks +} diff --git a/provider-swift/Sources/darkbloom/LoginCommand.swift b/provider-swift/Sources/darkbloom/LoginCommand.swift new file mode 100644 index 00000000..6492a621 --- /dev/null +++ b/provider-swift/Sources/darkbloom/LoginCommand.swift @@ -0,0 +1,55 @@ +import ArgumentParser +import Foundation +import ProviderCore + +struct Login: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Link this machine to a Darkbloom account.", + discussion: """ + Uses the RFC 8628 device code flow. The CLI requests a one-time code + from the coordinator, displays it, and opens the verification URL in + your browser. Once you authorize the code, the provider is linked to + your account and earnings are credited to your account wallet. + """ + ) + + @OptionGroup var configOptions: ConfigOptions + + mutating func run() async throws { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + let coordinatorURL = snapshot.config.coordinator.url + + do { + try await performDeviceCodeLogin( + coordinatorURL: coordinatorURL, + onDisplayCode: { userCode, verificationURI, expiresIn in + print() + print(" To link this machine, open this URL in your browser:") + print() + print(" \(verificationURI)") + print() + print(" Then enter this code:") + print() + print(" \(userCode)") + print() + print(" Waiting for approval (expires in \(expiresIn / 60) minutes)...") + }, + onPollTick: { + print(".", terminator: "") + fflush(stdout) + } + ) + + print() + print() + print(" Account linked successfully!") + print(" Your provider will now be connected to your account.") + print(" Earnings will be credited to your account wallet.") + print() + print(" Start serving with: darkbloom serve") + } catch let error as DeviceAuthError { + printError("\(error)") + throw ExitCode.failure + } + } +} diff --git a/provider-swift/Sources/darkbloom/LogoutCommand.swift b/provider-swift/Sources/darkbloom/LogoutCommand.swift new file mode 100644 index 00000000..8cf31c2a --- /dev/null +++ b/provider-swift/Sources/darkbloom/LogoutCommand.swift @@ -0,0 +1,19 @@ +import ArgumentParser +import ProviderCore + +struct Logout: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Remove local account credentials and unlink this machine." + ) + + mutating func run() async throws { + guard AuthTokenStore.load() != nil else { + print("Not currently logged in.") + return + } + + try AuthTokenStore.delete() + print("Logged out. This machine is no longer linked to an account.") + print("Provider earnings will use the local wallet until you log in again.") + } +} diff --git a/provider-swift/Sources/darkbloom/ModelsCommand.swift b/provider-swift/Sources/darkbloom/ModelsCommand.swift new file mode 100644 index 00000000..e4f09403 --- /dev/null +++ b/provider-swift/Sources/darkbloom/ModelsCommand.swift @@ -0,0 +1,60 @@ +import Foundation +import ArgumentParser +import ProviderCore + +struct Models: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List locally cached MLX models." + ) + + @OptionGroup var configOptions: ConfigOptions + + @Flag(help: "Emit JSON instead of a table.") + var json = false + + @Flag(help: "Show every discovered local model, ignoring the config enabled_models filter.") + var all = false + + @Option(help: "Compute an on-demand integrity hash for one model ID.") + var hash: String? + + mutating func run() async throws { + if let hash { + let digest = WeightHasher.computeHash(for: hash) + guard let digest else { + throw ValidationError("could not compute weight hash for '\(hash)'") + } + if json { + let payload = HashOutput(model: hash, weightHash: digest) + try printJSON(payload) + } else { + print("\(hash) \(digest)") + } + return + } + + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + let models = advertisedModels(from: snapshot.models, config: snapshot.config, includeDisabled: all) + + if json { + let payload = ModelsOutput( + cacheDirectory: ModelScanner.defaultCacheDirectory()?.path, + filteredByConfig: !all && !snapshot.config.backend.enabledModels.isEmpty, + models: models + ) + try printJSON(payload) + return + } + + guard !models.isEmpty else { + print("No local MLX models found.") + if let cache = ModelScanner.defaultCacheDirectory() { + print("Cache: \(cache.path)") + } + return + } + + print("Local MLX models") + printModelTable(models) + } +} diff --git a/provider-swift/Sources/darkbloom/StartCommand.swift b/provider-swift/Sources/darkbloom/StartCommand.swift new file mode 100644 index 00000000..cced9e44 --- /dev/null +++ b/provider-swift/Sources/darkbloom/StartCommand.swift @@ -0,0 +1,196 @@ +import Foundation +import ArgumentParser +import ProviderCore + +struct Start: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Start the provider as a background service.", + discussion: """ + Scans local MLX models, lets you pick which to serve, then launches + a launchd background service. Use --model to skip the interactive picker. + """ + ) + + @OptionGroup var configOptions: ConfigOptions + + @Option(help: "Override coordinator WebSocket URL.") + var coordinatorURL: String? + + @Option(help: "Model ID to serve (repeatable, skips interactive picker).") + var model: [String] = [] + + @Flag(help: "Serve all local models (skips interactive picker).") + var all = false + + @Option(help: "Idle timeout in minutes before unloading the model.") + var idleTimeout: UInt64? + + @Flag(inversion: .prefixedNo, help: .hidden) + var foreground = false + + mutating func run() async throws { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + let effectiveCoordinator = coordinatorURL ?? snapshot.config.coordinator.url + var effectiveConfig = snapshot.config + if let idleTimeout { + effectiveConfig.backend.idleTimeoutMins = idleTimeout + } + + guard let hardware = snapshot.hardware else { + printError("Cannot start: hardware detection failed (\(snapshot.hardwareError?.localizedDescription ?? "unknown"))") + throw ExitCode.failure + } + + guard !snapshot.models.isEmpty else { + printError("No local MLX models found. Download models to ~/.cache/huggingface/hub/") + throw ExitCode.failure + } + + if foreground { + try await runForeground( + snapshot: snapshot, + hardware: hardware, + config: effectiveConfig, + coordinatorURL: effectiveCoordinator + ) + } else { + try launchDaemon( + snapshot: snapshot, + config: effectiveConfig, + coordinatorURL: effectiveCoordinator + ) + } + } + + // MARK: - Foreground (invoked by launchd) + + private func runForeground( + snapshot: RuntimeSnapshot, + hardware: HardwareInfo, + config: ProviderConfig, + coordinatorURL: String + ) async throws { + let selectedModels: [ModelInfo] + if !model.isEmpty { + selectedModels = advertisedModels(from: snapshot.models, config: config, modelOverrides: model) + } else if all { + selectedModels = snapshot.models + } else { + selectedModels = advertisedModels(from: snapshot.models, config: config) + } + + guard !selectedModels.isEmpty else { + printError("No models selected.") + throw ExitCode.failure + } + + let (models, modelHashes) = attachWeightHashes(to: selectedModels) + let runtimeHashes = (try? RuntimeHashReporter().report().coordinatorRuntimeHashes) + let authToken = AuthTokenStore.load() + + print("darkbloom \(ProviderCore.version)") + print("Backend: mlx-swift") + print("Config: \(describeConfigPath(snapshot))") + print("Coordinator: \(coordinatorURL)") + print("Advertised models: \(models.count)") + for m in models { + print(" \(m.id) (\(String(format: "%.1f", m.estimatedMemoryGb)) GB)") + } + + let loopConfig = ProviderLoopConfig( + coordinatorURL: coordinatorURL, + hardware: hardware, + models: models, + config: config, + authToken: authToken, + runtimeHashes: runtimeHashes, + modelHashes: modelHashes + ) + + let loop = try ProviderLoop(config: loopConfig) + try await loop.run() + } + + // MARK: - Daemon (interactive picker → launchd) + + private func launchDaemon( + snapshot: RuntimeSnapshot, + config: ProviderConfig, + coordinatorURL: String + ) throws { + let selectedModelIDs: [String] + + if !model.isEmpty { + selectedModelIDs = model + } else if all { + selectedModelIDs = snapshot.models.map(\.id) + } else { + selectedModelIDs = try interactiveModelPicker(snapshot: snapshot, config: config) + } + + guard !selectedModelIDs.isEmpty else { + printError("No models selected.") + throw ExitCode.failure + } + + try LaunchAgent.installAndStart( + coordinatorURL: coordinatorURL, + models: selectedModelIDs, + idleTimeout: idleTimeout ?? (config.backend.idleTimeoutMins > 0 ? config.backend.idleTimeoutMins : nil) + ) + + let logPath = LaunchAgent.logPath().path + print("Provider started as background service.") + print(" Models: \(selectedModelIDs.count)") + for id in selectedModelIDs { + print(" \(id)") + } + print(" Logs: \(logPath)") + print() + print(" darkbloom stop Stop the provider") + print(" darkbloom status Check status") + } + + // MARK: - Interactive Picker + + private func interactiveModelPicker( + snapshot: RuntimeSnapshot, + config: ProviderConfig + ) throws -> [String] { + let models = snapshot.models.sorted { $0.id < $1.id } + + print() + print(" Available models:") + print() + for (i, m) in models.enumerated() { + let sizeStr = String(format: "%.1f GB", m.estimatedMemoryGb) + let quant = m.quantization ?? "" + print(" [\(i + 1)] \(m.id) (\(sizeStr)\(quant.isEmpty ? "" : ", \(quant)"))") + } + print() + print(" Select models (comma-separated numbers, or 'all'): ", terminator: "") + + guard let input = readLine()?.trimmingCharacters(in: .whitespaces), !input.isEmpty else { + return [] + } + + if input.lowercased() == "all" { + return models.map(\.id) + } + + let indices = input.split(separator: ",").compactMap { token -> Int? in + guard let n = Int(token.trimmingCharacters(in: .whitespaces)) else { return nil } + return n + } + + var selected: [String] = [] + for idx in indices { + guard idx >= 1, idx <= models.count else { + printError("Invalid selection: \(idx) (must be 1-\(models.count))") + throw ExitCode.failure + } + selected.append(models[idx - 1].id) + } + return selected + } +} diff --git a/provider-swift/Sources/darkbloom/StatusCommand.swift b/provider-swift/Sources/darkbloom/StatusCommand.swift new file mode 100644 index 00000000..8a92402f --- /dev/null +++ b/provider-swift/Sources/darkbloom/StatusCommand.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import ProviderCore + +struct Status: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Show local provider configuration and hardware status." + ) + + @OptionGroup var configOptions: ConfigOptions + + mutating func run() async throws { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + let config = snapshot.config + let models = advertisedModels(from: snapshot.models, config: config) + + print("darkbloom \(ProviderCore.version)") + print("Provider: \(config.provider.name)") + print("Config: \(describeConfigPath(snapshot))") + print("Coordinator: \(config.coordinator.url)") + print("Backend port: \(config.backend.port)") + print("Configured model: \(config.backend.model ?? "auto-select")") + print("Continuous batching: \(config.backend.continuousBatching ? "enabled" : "disabled")") + print("Idle timeout: \(config.backend.idleTimeoutMins == 0 ? "disabled" : "\(config.backend.idleTimeoutMins)m")") + + if let hardware = snapshot.hardware { + print("Hardware: \(hardware.chipName), \(hardware.memoryGb) GB RAM, \(hardware.gpuCores) GPU cores") + print("Inference memory: \(hardware.memoryAvailableGb) GB available") + } else { + print("Hardware: unavailable (\(snapshot.hardwareError?.localizedDescription ?? "unknown error"))") + } + + if let scheduleConfig = config.schedule, + let schedule = Schedule.from(config: scheduleConfig) { + let active = schedule.isActiveNow() + print("Schedule: \(schedule.describe())") + print("Availability: \(active ? "active" : "inactive")") + } else { + print("Schedule: always available") + } + + let enabledFilter = config.backend.enabledModels.isEmpty ? "none" : config.backend.enabledModels.joined(separator: ", ") + print("Enabled model filter: \(enabledFilter)") + print("Local MLX models: \(models.count)") + print("Process control: not available yet in the Swift CLI") + } +} diff --git a/provider-swift/Sources/darkbloom/StopCommand.swift b/provider-swift/Sources/darkbloom/StopCommand.swift new file mode 100644 index 00000000..a3ae4dff --- /dev/null +++ b/provider-swift/Sources/darkbloom/StopCommand.swift @@ -0,0 +1,27 @@ +import ArgumentParser +import ProviderCore + +struct Stop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Stop the provider launchd service." + ) + + @Flag(help: "Also remove the launchd plist (full uninstall).") + var uninstall = false + + mutating func run() async throws { + let wasLoaded = LaunchAgent.isLoaded() + + if uninstall { + try LaunchAgent.uninstall() + print("Provider service uninstalled.") + } else { + try LaunchAgent.stop() + if wasLoaded { + print("Provider service stopped.") + } else { + print("Provider service is not running.") + } + } + } +} diff --git a/provider-swift/Sources/darkbloom/UpdateCommand.swift b/provider-swift/Sources/darkbloom/UpdateCommand.swift new file mode 100644 index 00000000..073ffe3f --- /dev/null +++ b/provider-swift/Sources/darkbloom/UpdateCommand.swift @@ -0,0 +1,77 @@ +import ArgumentParser +import ProviderCore + +struct Update: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Check for updates and self-update the provider binary." + ) + + @OptionGroup var configOptions: ConfigOptions + + @Flag(help: "Only check for updates without installing.") + var checkOnly = false + + mutating func run() async throws { + let config: ProviderConfig + do { + let snapshot = try loadRuntimeSnapshot(configOptions: configOptions) + config = snapshot.config + } catch { + config = ConfigManager.loadDefault() + } + + print("darkbloom update") + print("Current version: \(ProviderCore.version)") + print("") + + let updater = SelfUpdater(coordinatorBaseURL: config.coordinator.url) + + if checkOnly { + let result = await updater.checkForUpdate() + switch result { + case .upToDate(let version): + print("Up to date (v\(version)).") + + case .updateAvailable(let current, let latest): + print("Update available: v\(current) -> v\(latest.version)") + print("Download URL: \(latest.url)") + print("SHA-256: \(latest.sha256)") + print("") + print("Run 'darkbloom update' to install.") + + case .checkFailed(let reason): + printError("update check failed: \(reason)") + throw ExitCode.failure + } + return + } + + print("Checking for updates...") + let result = await updater.update() + + switch result { + case .alreadyUpToDate(let version): + print("Already up to date (v\(version)).") + + case .updated(let from, let to): + print("Updated: v\(from) -> v\(to)") + print("Restart the provider for the new version to take effect.") + + case .downloadFailed(let reason): + printError("download failed: \(reason)") + throw ExitCode.failure + + case .hashMismatch(let expected, let got): + printError("SHA-256 hash mismatch!") + printError(" Expected: \(expected)") + printError(" Got: \(got)") + printError("The downloaded binary may be corrupted or tampered with.") + throw ExitCode.failure + + case .replaceFailed(let reason): + printError("failed to replace binary: \(reason)") + throw ExitCode.failure + } + } +} diff --git a/provider-swift/Tests/ProviderCoreTests/BatchingTests.swift b/provider-swift/Tests/ProviderCoreTests/BatchingTests.swift new file mode 100644 index 00000000..20736762 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/BatchingTests.swift @@ -0,0 +1,169 @@ +import Testing +@testable import ProviderCore + +@Test func plannerRejectsInvalidDuplicateAndOverBudgetRequests() async { + let planner = BatchQueuePlanner( + policy: BatchSchedulingPolicy( + maxConcurrentRequests: 2, + maxQueuedRequests: 1, + maxActiveTokenBudget: 10, + maxTokensPerBatch: 6 + ) + ) + + #expect( + await planner.admit(id: "zero", promptTokenCount: 0, maxOutputTokens: 1) + == .rejected(requestID: "zero", reason: .invalidTokenCount) + ) + #expect( + await planner.admit(id: "too-large", promptTokenCount: 6, maxOutputTokens: 5) + == .rejected(requestID: "too-large", reason: .requestExceedsActiveTokenBudget) + ) + #expect( + await planner.admit(id: "prefill-too-large", promptTokenCount: 7, maxOutputTokens: 1) + == .rejected(requestID: "prefill-too-large", reason: .requestExceedsBatchTokenBudget) + ) + + #expect( + await planner.admit(id: "a", promptTokenCount: 4, maxOutputTokens: 4) + == .queued(requestID: "a", position: 1) + ) + #expect( + await planner.admit(id: "a", promptTokenCount: 4, maxOutputTokens: 4) + == .rejected(requestID: "a", reason: .duplicateRequestID) + ) + #expect( + await planner.admit(id: "b", promptTokenCount: 1, maxOutputTokens: 1) + == .rejected(requestID: "b", reason: .queueFull) + ) +} + +@Test func plannerBuildsDeterministicContinuousBatches() async { + let planner = BatchQueuePlanner( + policy: BatchSchedulingPolicy( + maxConcurrentRequests: 3, + maxQueuedRequests: 10, + maxActiveTokenBudget: 100, + maxTokensPerBatch: 20 + ) + ) + + await planner.admit(id: "a", promptTokenCount: 4, maxOutputTokens: 4) + await planner.admit(id: "b", promptTokenCount: 3, maxOutputTokens: 4) + await planner.admit(id: "c", promptTokenCount: 2, maxOutputTokens: 4) + + let first = await planner.nextBatch() + #expect(first?.sequence == 1) + #expect(first?.prefill?.id == "a") + #expect(first?.decodes.isEmpty == true) + #expect(first?.tokenCost == 4) + + #expect(await planner.markPrefillComplete(requestID: "a")) + + let second = await planner.nextBatch() + #expect(second?.sequence == 2) + #expect(second?.decodes.map(\.id) == ["a"]) + #expect(second?.prefill?.id == "b") + #expect(second?.orderedRequests.map(\.id) == ["a", "b"]) + #expect(second?.tokenCost == 4) + + #expect(await planner.recordDecodeStep(requestID: "a") == .generated(remainingTokens: 3)) + #expect(await planner.markPrefillComplete(requestID: "b")) + + let third = await planner.nextBatch() + #expect(third?.sequence == 3) + #expect(third?.decodes.map(\.id) == ["a", "b"]) + #expect(third?.prefill?.id == "c") + #expect(third?.decodes.first?.generatedTokenCount == 1) + #expect(third?.tokenCost == 4) +} + +@Test func plannerCancellationRemovesPendingAndActiveRequests() async { + let planner = BatchQueuePlanner( + policy: BatchSchedulingPolicy( + maxConcurrentRequests: 1, + maxQueuedRequests: 10, + maxActiveTokenBudget: 100, + maxTokensPerBatch: 20 + ) + ) + + await planner.admit(id: "a", promptTokenCount: 4, maxOutputTokens: 4) + await planner.admit(id: "b", promptTokenCount: 4, maxOutputTokens: 4) + + let first = await planner.nextBatch() + #expect(first?.prefill?.id == "a") + + var snapshot = await planner.snapshot() + #expect(snapshot.activeRequestIDs == ["a"]) + #expect(snapshot.pendingRequestIDs == ["b"]) + + #expect(await planner.cancel(requestID: "b")) + snapshot = await planner.snapshot() + #expect(snapshot.pendingRequestIDs.isEmpty) + #expect(snapshot.activeRequestIDs == ["a"]) + + #expect(await planner.cancel(requestID: "a")) + snapshot = await planner.snapshot() + #expect(snapshot.pendingRequestIDs.isEmpty) + #expect(snapshot.activeRequestIDs.isEmpty) + + #expect(await planner.cancel(requestID: "missing") == false) + #expect( + await planner.admit(id: "c", promptTokenCount: 2, maxOutputTokens: 2) + == .queued(requestID: "c", position: 1) + ) + #expect(await planner.nextBatch()?.prefill?.id == "c") +} + +@Test func plannerDelaysPrefillUntilTokenBudgetIsAvailable() async { + let planner = BatchQueuePlanner( + policy: BatchSchedulingPolicy( + maxConcurrentRequests: 2, + maxQueuedRequests: 10, + maxActiveTokenBudget: 10, + maxTokensPerBatch: 20 + ) + ) + + await planner.admit(id: "a", promptTokenCount: 5, maxOutputTokens: 3) + await planner.admit(id: "b", promptTokenCount: 5, maxOutputTokens: 3) + + #expect(await planner.nextBatch()?.prefill?.id == "a") + #expect(await planner.markPrefillComplete(requestID: "a")) + + let blocked = await planner.nextBatch() + #expect(blocked?.decodes.map(\.id) == ["a"]) + #expect(blocked?.prefill == nil) + + #expect(await planner.complete(requestID: "a")) + let admittedAfterCompletion = await planner.nextBatch() + #expect(admittedAfterCompletion?.prefill?.id == "b") + #expect(admittedAfterCompletion?.decodes.isEmpty == true) +} + +@Test func plannerBatchTokenBudgetDefersLargePrefillsBehindDecodeSteps() async { + let planner = BatchQueuePlanner( + policy: BatchSchedulingPolicy( + maxConcurrentRequests: 2, + maxQueuedRequests: 10, + maxActiveTokenBudget: 100, + maxTokensPerBatch: 5 + ) + ) + + await planner.admit(id: "decode-first", promptTokenCount: 1, maxOutputTokens: 2) + await planner.admit(id: "large-prefill", promptTokenCount: 5, maxOutputTokens: 2) + + #expect(await planner.nextBatch()?.prefill?.id == "decode-first") + #expect(await planner.markPrefillComplete(requestID: "decode-first")) + + let decodeOnly = await planner.nextBatch() + #expect(decodeOnly?.decodes.map(\.id) == ["decode-first"]) + #expect(decodeOnly?.prefill == nil) + + #expect(await planner.complete(requestID: "decode-first")) + let prefillAfterDecodeCompletes = await planner.nextBatch() + #expect(prefillAfterDecodeCompletes?.prefill?.id == "large-prefill") + #expect(prefillAfterDecodeCompletes?.tokenCost == 5) +} diff --git a/provider-swift/Tests/ProviderCoreTests/CoordinatorClientTests.swift b/provider-swift/Tests/ProviderCoreTests/CoordinatorClientTests.swift new file mode 100644 index 00000000..57fb9553 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/CoordinatorClientTests.swift @@ -0,0 +1,202 @@ +import Foundation +import Testing +@testable import ProviderCore + +@Test func coordinatorRegistrationEncodingUsesProtocolCodec() throws { + let rawAttestation = #"{"signature":"sig","attestation":{"hardwareModel":"Mac16,5","sipEnabled":true}}"# + let config = CoordinatorClientConfig( + url: "wss://api.dev.darkbloom.xyz/v1/providers/ws", + hardware: clientSampleHardware(), + models: [clientSampleModel()], + backendName: "mlx_swift_lm", + publicKey: "cHVibGlj", + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + attestation: RawJSON(rawBytes: Data(rawAttestation.utf8)), + authToken: "device-token", + runtimeHashes: RuntimeHashes( + pythonHash: nil, + runtimeHash: "runtimehash", + templateHashes: ["chatml": "templatehash"] + ) + ) + + let data = try CoordinatorClientCodec.encodeRegistration( + from: config, + version: "0.4.0-swift-test", + privacyCapabilities: clientPrivacyCapabilities() + ) + let json = String(data: data, encoding: .utf8) ?? "" + let object = try clientJSONObject(data) + + #expect(object["type"] as? String == "register") + #expect(object["backend"] as? String == "mlx_swift_lm") + #expect(object["version"] as? String == "0.4.0-swift-test") + #expect(object["public_key"] as? String == "cHVibGlj") + #expect(object["auth_token"] as? String == "device-token") + #expect(object["encrypted_response_chunks"] as? Bool == true) + #expect(json.contains(#""attestation":\#(rawAttestation)"#)) + + let decoded = try ProviderProtocolCodec.decodeProviderMessage(from: data) + guard case .register(let register) = decoded else { + throw ClientTestFailure.unexpectedMessage + } + #expect(register.attestation?.rawBytes == Data(rawAttestation.utf8)) + #expect(register.runtimeHash == "runtimehash") + #expect(register.templateHashes["chatml"] == "templatehash") + #expect(register.privacyCapabilities?.textBackendInprocess == true) +} + +@Test func coordinatorOutboundMessagesUseProviderEnvelope() throws { + let accepted = try CoordinatorClientCodec.encodeOutboundMessageString( + .inferenceAccepted(requestId: "req-1") + ) + #expect(accepted.contains(#""type":"inference_accepted""#)) + #expect(accepted.contains(#""request_id":"req-1""#)) + + let chunk = try CoordinatorClientCodec.encodeOutboundMessageString( + .inferenceChunk( + requestId: "req-2", + data: "", + encryptedData: EncryptedPayload(ephemeralPublicKey: "ZXBo", ciphertext: "Y2lwaGVy") + ) + ) + #expect(chunk.contains(#""type":"inference_response_chunk""#)) + #expect(!chunk.contains(#""data""#)) + #expect(chunk.contains(#""encrypted_data""#)) + + let complete = try ProviderProtocolCodec.decodeProviderMessage( + from: try CoordinatorClientCodec.encodeOutboundMessage(.inferenceComplete( + requestId: "req-3", + usage: UsageInfo(promptTokens: 10, completionTokens: 20), + seSignature: "sig", + responseHash: "hash" + )) + ) + #expect(complete == .inferenceComplete(ProviderMessage.InferenceComplete( + requestId: "req-3", + usage: UsageInfo(promptTokens: 10, completionTokens: 20), + seSignature: "sig", + responseHash: "hash" + ))) +} + +@Test func coordinatorHeartbeatConstructionOmitRulesMatchProtocol() throws { + let idle = CoordinatorClientCodec.heartbeatMessage( + status: .idle, + activeModel: nil, + warmModels: [], + stats: ProviderStats(requestsServed: 0, tokensGenerated: 0), + systemMetrics: SystemMetrics(memoryPressure: 0, cpuUsage: 0, thermalState: .nominal), + backendCapacity: nil + ) + let idleJSON = String( + data: try ProviderProtocolCodec.encodeProviderMessage(idle), + encoding: .utf8 + ) ?? "" + + #expect(idleJSON.contains(#""type":"heartbeat""#)) + #expect(idleJSON.contains(#""status":"idle""#)) + #expect(!idleJSON.contains("active_model")) + #expect(!idleJSON.contains("warm_models")) + + let serving = CoordinatorClientCodec.heartbeatMessage( + status: .serving, + activeModel: "model-a", + warmModels: ["model-a"], + stats: ProviderStats(requestsServed: 7, tokensGenerated: 800), + systemMetrics: SystemMetrics(memoryPressure: 0.7, cpuUsage: 0.4, thermalState: .fair), + backendCapacity: nil + ) + let servingData = try ProviderProtocolCodec.encodeProviderMessage(serving) + let servingObject = try clientJSONObject(servingData) + #expect(servingObject["status"] as? String == "serving") + #expect(servingObject["active_model"] as? String == "model-a") + #expect(servingObject["warm_models"] as? [String] == ["model-a"]) +} + +@Test func coordinatorIncomingMessagesDecodeForDispatch() throws { + let challenge = try CoordinatorClientCodec.decodeIncomingMessage( + from: #"{"type":"attestation_challenge","nonce":"bm9uY2U=","timestamp":"2026-04-03T12:00:00Z"}"# + ) + #expect(challenge == .attestationChallenge(CoordinatorMessage.AttestationChallenge( + nonce: "bm9uY2U=", + timestamp: "2026-04-03T12:00:00Z" + ))) + + let cancel = try CoordinatorClientCodec.decodeIncomingMessage( + from: #"{"type":"cancel","request_id":"req-cancel"}"# + ) + #expect(cancel == .cancel(CoordinatorMessage.Cancel(requestId: "req-cancel"))) + + let runtimeStatus = try CoordinatorClientCodec.decodeIncomingMessage( + from: #"{"type":"runtime_status","verified":false,"mismatches":[{"component":"runtime","expected":"a","got":"b"}]}"# + ) + guard case .runtimeStatus(let status) = runtimeStatus else { + throw ClientTestFailure.unexpectedMessage + } + #expect(status.verified == false) + #expect(status.mismatches.first?.component == "runtime") +} + +@Test func exponentialBackoffDoublesUntilMaximumAndResets() { + var backoff = ExponentialBackoff(base: 1, max: 4) + + #expect(backoff.nextDelay() == 1) + #expect(backoff.nextDelay() == 2) + #expect(backoff.nextDelay() == 4) + #expect(backoff.nextDelay() == 4) + + backoff.reset() + #expect(backoff.nextDelay() == 1) +} + +private func clientSampleHardware() -> HardwareInfo { + HardwareInfo( + machineModel: "Mac16,5", + chipName: "Apple M4 Max", + chipFamily: .m4, + chipTier: .max, + memoryGb: 128, + memoryAvailableGb: 124, + cpuCores: CpuCores(total: 16, performance: 12, efficiency: 4), + gpuCores: 40, + memoryBandwidthGbs: 546 + ) +} + +private func clientSampleModel() -> ModelInfo { + ModelInfo( + id: "mlx-community/Qwen2.5-7B-4bit", + modelType: "qwen2", + parameters: nil, + quantization: "4bit", + sizeBytes: 4_000_000_000, + estimatedMemoryGb: 4.5 + ) +} + +private func clientPrivacyCapabilities() -> PrivacyCapabilities { + PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: true, + antiDebugEnabled: true, + coreDumpsDisabled: true, + envScrubbed: true, + hypervisorActive: false + ) +} + +private func clientJSONObject(_ data: Data) throws -> [String: Any] { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ClientTestFailure.notJSONObject + } + return object +} + +private enum ClientTestFailure: Error { + case notJSONObject + case unexpectedMessage +} diff --git a/provider-swift/Tests/ProviderCoreTests/CryptoTests.swift b/provider-swift/Tests/ProviderCoreTests/CryptoTests.swift new file mode 100644 index 00000000..9afe117f --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/CryptoTests.swift @@ -0,0 +1,223 @@ +import CryptoKit +import Foundation +import Testing +@testable import ProviderCore + +// MARK: - Cross-Language NaCl Box Tests (Golden Vectors from Go) + +// These vectors were generated by Go's golang.org/x/crypto/nacl/box with fixed keys and nonce. +// If any of these tests fail, the Swift NaCl implementation is NOT wire-compatible with Go/Rust. + +private let goldenProviderPrivateKeyHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +private let goldenEphemeralPublicKeyB64 = "rUOL+uMfbAk9YdQzklXqeYCSyfrdB7l4J/Swrp3ufBw=" + +struct GoldenVector: Sendable { + let name: String + let ciphertextB64: String + let plaintext: String +} + +private let goldenVectors: [GoldenVector] = [ + GoldenVector( + name: "hello", + ciphertextB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXZGqVtM7CzvUzjuv8z5nMD2Sk+g7RhuEOvfk2Xns=", + plaintext: "hello from Go" + ), + GoldenVector( + name: "json", + ciphertextB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXBEh64PeX5/KPPTPaFAFkJHfj+w3aw+te6LZifGcJdS8d1/aSifvDne+pw2W4tRO2Hz6Nz8AbtNRurlE6LxNKPV+I/OEjMotRwpxKHQ==", + plaintext: #"{"model":"test","messages":[{"role":"user","content":"hi"}]}"# + ), + GoldenVector( + name: "empty", + ciphertextB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGd5WgKY2kgufhPrGUBWGig==", + plaintext: "" + ), + GoldenVector( + name: "unicode", + ciphertextB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXsrGyAb1PhMJKM/jWGp9d2O9ABYE8NWT9eXeXuPf8+OeHLHR0drpUZxAG", + plaintext: "こんにちは世界 🌍" + ), +] + +@Test("NaCl box: decrypt Go-generated golden vectors", arguments: goldenVectors) +func naclBoxDecryptsGoGoldenVector(_ vector: GoldenVector) throws { + let providerPriv = try Data(hexString: goldenProviderPrivateKeyHex) + let keyPair = try NodeKeyPair(rawSecret: providerPriv) + + let ephPubData = Data(base64Encoded: goldenEphemeralPublicKeyB64)! + let ciphertext = Data(base64Encoded: vector.ciphertextB64)! + + let decrypted = try keyPair.decrypt(senderPublicKey: ephPubData, ciphertext: ciphertext) + let decryptedString = String(data: decrypted, encoding: .utf8)! + + #expect(decryptedString == vector.plaintext, + "Vector '\(vector.name)': expected \(vector.plaintext), got \(decryptedString)") +} + +@Test("NaCl box: decrypt Go-generated reverse direction vector") +func naclBoxDecryptsGoReverseVector() throws { + let ephPriv = try Data(hexString: "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0") + let keyPair = try NodeKeyPair(rawSecret: ephPriv) + + let senderPubB64 = "B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9/AsrhtHHw=" + let ciphertextB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXnPRmM91+Fsv168WzsGr0SH6k5RLRyPQZ8vJkdnldJ3FQzPqFn+g=" + + let senderPub = Data(base64Encoded: senderPubB64)! + let ciphertext = Data(base64Encoded: ciphertextB64)! + + let decrypted = try keyPair.decrypt(senderPublicKey: senderPub, ciphertext: ciphertext) + let decryptedString = String(data: decrypted, encoding: .utf8)! + + #expect(decryptedString == "response from provider") +} + +@Test("NaCl box: Swift encrypt → Swift decrypt round-trip") +func naclBoxSwiftRoundTrip() throws { + let alice = NodeKeyPair.generate() + let bob = NodeKeyPair.generate() + + let plaintext = Data("cross-language round-trip test payload".utf8) + let encrypted = try alice.encrypt(recipientPublicKey: bob.publicKeyBytes, plaintext: plaintext) + let decrypted = try bob.decrypt(senderPublicKey: alice.publicKeyBytes, ciphertext: encrypted) + + #expect(decrypted == plaintext) +} + +@Test("NaCl box: EncryptedPayload wire format round-trip") +func naclBoxPayloadRoundTrip() throws { + let provider = NodeKeyPair.generate() + let coordinator = NodeKeyPair.generate() + + let message = Data(#"{"model":"qwen","messages":[{"role":"user","content":"test"}]}"#.utf8) + + let payload = try coordinator.encryptPayload(recipientPublicKey: provider.publicKeyBytes, plaintext: message) + + #expect(!payload.ephemeralPublicKey.isEmpty) + #expect(!payload.ciphertext.isEmpty) + + let decrypted = try provider.decryptPayload(payload) + #expect(decrypted == message) +} + +@Test("NaCl box: tampered ciphertext fails authentication") +func naclBoxRejectsTamperedCiphertext() throws { + let alice = NodeKeyPair.generate() + let bob = NodeKeyPair.generate() + + let plaintext = Data("sensitive data".utf8) + var encrypted = try alice.encrypt(recipientPublicKey: bob.publicKeyBytes, plaintext: plaintext) + + // Flip a byte in the ciphertext (after the 24-byte nonce) + encrypted[encrypted.count - 1] ^= 0xFF + + #expect(throws: CryptoError.self) { + _ = try bob.decrypt(senderPublicKey: alice.publicKeyBytes, ciphertext: encrypted) + } +} + +@Test("NaCl box: wrong key fails decryption") +func naclBoxRejectsWrongKey() throws { + let alice = NodeKeyPair.generate() + let bob = NodeKeyPair.generate() + let eve = NodeKeyPair.generate() + + let plaintext = Data("for bob only".utf8) + let encrypted = try alice.encrypt(recipientPublicKey: bob.publicKeyBytes, plaintext: plaintext) + + #expect(throws: CryptoError.self) { + _ = try eve.decrypt(senderPublicKey: alice.publicKeyBytes, ciphertext: encrypted) + } +} + +// MARK: - Hex helper + +extension Data { + init(hexString: String) throws { + var data = Data() + var hex = hexString + while !hex.isEmpty { + let byte = hex.prefix(2) + hex = String(hex.dropFirst(2)) + guard let b = UInt8(byte, radix: 16) else { + throw CryptoError.decryptionFailed + } + data.append(b) + } + self = data + } +} + +// MARK: - X25519ChaChaPoly Tests + +@Test func x25519ChaChaPolyRoundTripsWithDeterministicInputs() throws { + let recipient = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 7, count: 32)) + let sender = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 9, count: 32)) + let cipher = X25519ChaChaPoly( + salt: Data("test-salt".utf8), + sharedInfo: Data("test-info".utf8) + ) + let plaintext = Data("single request foundation".utf8) + let aad = Data("req-123".utf8) + let nonce = Data(0..<12) + + let sealed = try cipher.seal( + plaintext: plaintext, + recipientPublicKey: recipient.publicKey, + senderKeyPair: sender, + nonce: nonce, + authenticatedData: aad + ) + let opened = try cipher.open(sealed, recipientKeyPair: recipient, authenticatedData: aad) + + #expect(sealed.senderPublicKey == sender.publicKey) + #expect(sealed.nonce == nonce) + #expect(sealed.combinedCiphertext.count == nonce.count + sealed.ciphertext.count + sealed.tag.count) + #expect(opened == plaintext) +} + +@Test func x25519ChaChaPolyRejectsTamperedAuthenticatedData() throws { + let recipient = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 1, count: 32)) + let sender = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 2, count: 32)) + let cipher = X25519ChaChaPoly() + let sealed = try cipher.seal( + plaintext: Data("hello".utf8), + recipientPublicKey: recipient.publicKey, + senderKeyPair: sender, + nonce: Data(repeating: 3, count: 12), + authenticatedData: Data("req-good".utf8) + ) + + #expect(throws: CryptoKitError.self) { + _ = try cipher.open( + sealed, + recipientKeyPair: recipient, + authenticatedData: Data("req-bad".utf8) + ) + } +} + +@Test func x25519ChaChaPolyValidatesKeyAndNonceLengths() throws { + #expect(throws: X25519ChaChaPolyError.invalidPrivateKeyLength(31)) { + _ = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 0, count: 31)) + } + + let recipient = try X25519KeyAgreementKeyPair(rawPrivateKey: Data(repeating: 5, count: 32)) + let cipher = X25519ChaChaPoly() + + #expect(throws: X25519ChaChaPolyError.invalidPublicKeyLength(31)) { + _ = try cipher.seal( + plaintext: Data(), + recipientPublicKey: Data(repeating: 1, count: 31), + nonce: Data(repeating: 0, count: 12) + ) + } + + #expect(throws: X25519ChaChaPolyError.invalidNonceLength(11)) { + _ = try cipher.seal( + plaintext: Data(), + recipientPublicKey: recipient.publicKey, + nonce: Data(repeating: 0, count: 11) + ) + } +} diff --git a/provider-swift/Tests/ProviderCoreTests/FoundationTests.swift b/provider-swift/Tests/ProviderCoreTests/FoundationTests.swift new file mode 100644 index 00000000..58adf275 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/FoundationTests.swift @@ -0,0 +1,122 @@ +import Foundation +import MLXLMCommon +import Testing +@testable import ProviderCore + +@Test func localMLXReadinessAcceptsMinimalLocalDirectory() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let modelDirectory = root.appendingPathComponent("models--local--tiny/snapshots/abc", isDirectory: true) + try createFakeModelDirectory(at: modelDirectory) + + let configuration = LocalMLXModelConfiguration( + modelID: "local/tiny", + modelDirectory: modelDirectory + ) + let readiness = LocalMLXModelReadiness.inspect(configuration) + + #expect(readiness.canAttemptLoad) + #expect(readiness.issues.isEmpty) + #expect(readiness.configJSON == modelDirectory.appendingPathComponent("config.json").standardizedFileURL) + #expect(readiness.tokenizerFiles.map(\.url.lastPathComponent) == ["tokenizer.json"]) + #expect(readiness.weightFiles.map(\.url.lastPathComponent) == ["model.safetensors"]) + #expect(readiness.totalWeightBytes == 4) + + switch configuration.modelConfiguration.id { + case .directory(let url): + #expect(url == modelDirectory.standardizedFileURL) + case .id: + Issue.record("local configuration must not resolve through a remote model id") + } + #expect(configuration.modelConfiguration.tokenizerSource == nil) +} + +@Test func localMLXReadinessSupportsSeparateTokenizerDirectory() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let modelDirectory = root.appendingPathComponent("model", isDirectory: true) + let tokenizerDirectory = root.appendingPathComponent("tokenizer", isDirectory: true) + try createFakeModelDirectory(at: modelDirectory, includeTokenizer: false) + try FileManager.default.createDirectory(at: tokenizerDirectory, withIntermediateDirectories: true) + try "{}".write( + to: tokenizerDirectory.appendingPathComponent("tokenizer.json"), + atomically: true, + encoding: .utf8 + ) + + let configuration = LocalMLXModelConfiguration( + modelDirectory: modelDirectory, + tokenizerDirectory: tokenizerDirectory + ) + let readiness = LocalMLXModelReadiness.inspect(configuration) + + #expect(readiness.canAttemptLoad) + #expect(readiness.issues.isEmpty) + #expect(readiness.tokenizerFiles.map(\.url.lastPathComponent) == ["tokenizer.json"]) + #expect( + configuration.modelConfiguration.tokenizerSource + == TokenizerSource.directory(tokenizerDirectory.standardizedFileURL) + ) +} + +@Test func localMLXReadinessReportsMissingRequiredFiles() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let modelDirectory = root.appendingPathComponent("empty-model", isDirectory: true) + try FileManager.default.createDirectory(at: modelDirectory, withIntermediateDirectories: true) + + let configuration = LocalMLXModelConfiguration(modelDirectory: modelDirectory) + let readiness = LocalMLXModelReadiness.inspect(configuration) + let issueKinds = Set(readiness.issues.map(\.kind)) + + #expect(!readiness.canAttemptLoad) + #expect( + issueKinds == [ + .configJSONMissing, + .tokenizerFilesMissing, + .weightFilesMissing, + ] + ) +} + +@Test func localMLXReadinessReportsMissingModelDirectory() { + let missingDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-\(UUID().uuidString)", isDirectory: true) + let configuration = LocalMLXModelConfiguration(modelDirectory: missingDirectory) + let readiness = LocalMLXModelReadiness.inspect(configuration) + + #expect(!readiness.canAttemptLoad) + #expect(readiness.issues.map(\.kind) == [.modelDirectoryMissing]) + #expect(readiness.weightFiles.isEmpty) + #expect(readiness.tokenizerFiles.isEmpty) +} + +private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderCoreFoundationTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} + +private func createFakeModelDirectory( + at directory: URL, + includeTokenizer: Bool = true +) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try #"{"model_type":"llama"}"#.write( + to: directory.appendingPathComponent("config.json"), + atomically: true, + encoding: .utf8 + ) + if includeTokenizer { + try "{}".write( + to: directory.appendingPathComponent("tokenizer.json"), + atomically: true, + encoding: .utf8 + ) + } + try Data([0, 1, 2, 3]).write(to: directory.appendingPathComponent("model.safetensors")) +} diff --git a/provider-swift/Tests/ProviderCoreTests/InferenceTests.swift b/provider-swift/Tests/ProviderCoreTests/InferenceTests.swift new file mode 100644 index 00000000..ebeffcb7 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/InferenceTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import ProviderCore + +@Test func chatPromptFormatterPreservesOrderAndSampling() throws { + let request = ChatCompletionRequest( + model: "mlx-test", + messages: [ + ChatMessage(role: "system", content: "Be terse."), + ChatMessage(role: "user", content: "Hello"), + ], + temperature: 0.2, + top_p: 0.9, + max_tokens: 32, + stream: true + ) + + let prompt = try ChatPromptFormatter().format(request) + + #expect(prompt.model == "mlx-test") + #expect(prompt.messages.map(\.role) == [.system, .user]) + #expect(prompt.messages.map(\.content) == ["Be terse.", "Hello"]) + #expect(prompt.sampling.temperature == 0.2) + #expect(prompt.sampling.topP == 0.9) + #expect(prompt.sampling.maxTokens == 32) + #expect(prompt.stream) +} + +@Test func chatPromptFormatterRejectsUnsupportedRole() { + let request = ChatCompletionRequest( + model: "mlx-test", + messages: [ChatMessage(role: "developer", content: "No")] + ) + + #expect(throws: ChatPromptFormattingError.unsupportedRole("developer")) { + _ = try ChatPromptFormatter().format(request) + } +} + +@Test func sseFormatterBuildsDeterministicChunks() throws { + let formatter = ChatSSEFormatter() + + let role = try formatter.roleChunk(id: "chatcmpl-test", model: "mlx-test", created: 1) + #expect(role.formatted == #"data: {"choices":[{"delta":{"role":"assistant"},"index":0}],"created":1,"id":"chatcmpl-test","model":"mlx-test","object":"chat.completion.chunk"}"# + "\n\n") + + let usage = InferenceUsage(promptTokens: 3, completionTokens: 2) + let finish = try formatter.finishChunk( + id: "chatcmpl-test", + model: "mlx-test", + created: 1, + reason: .length, + usage: usage + ) + #expect(finish.formatted.contains(#""finish_reason":"length""#)) + #expect(finish.formatted.contains(#""prompt_tokens":3"#)) + #expect(finish.formatted.contains(#""completion_tokens":2"#)) + #expect(SSEChunk.done.formatted == "data: [DONE]\n\n") +} + +@Test func usageAccumulatorTracksAndBridgesUsage() { + var usage = UsageAccumulator(promptTokens: -10) + usage.setPromptTokens(8) + usage.recordCompletionChunk() + usage.recordCompletionChunk(tokenCount: 4) + usage.recordCompletionChunk(tokenCount: -10) + + let snapshot = usage.snapshot + #expect(snapshot.promptTokens == 8) + #expect(snapshot.completionTokens == 5) + #expect(snapshot.totalTokens == 13) + #expect(snapshot.openAIChunkUsage.total_tokens == 13) + #expect(snapshot.protocolUsageInfo.promptTokens == 8) + #expect(snapshot.protocolUsageInfo.completionTokens == 5) +} + +@Test func cancellationRegistryCancelsAndRemovesToken() async { + let registry = InferenceCancellationRegistry() + let token = await registry.register(requestId: "req-1") + + #expect(await registry.activeRequestIds == ["req-1"]) + #expect(!token.isCancelled) + #expect(await registry.cancel(requestId: "req-1")) + #expect(token.isCancelled) + #expect(await registry.activeRequestIds.isEmpty) + #expect(await !registry.cancel(requestId: "req-1")) +} + +@Test func singleRequestDriverStreamsFakeEngineOutput() async throws { + let engine = FakeSingleRequestEngine(events: [ + .text("hel", tokenCount: 1), + .text("lo", tokenCount: 1), + .usage(InferenceUsage(promptTokens: 4, completionTokens: 2)), + .finished(.stop), + ]) + let driver = SingleRequestInferenceDriver(engine: engine) + let request = ChatCompletionRequest( + model: "mlx-test", + messages: [ChatMessage(role: "user", content: "Say hello")] + ) + + var outputs: [SingleRequestInferenceOutput] = [] + for try await output in driver.stream(requestId: "chatcmpl-test", request: request, created: 1) { + outputs.append(output) + } + + #expect(outputs.count == 6) + #expect(outputs.first == .sse(try ChatSSEFormatter().roleChunk(id: "chatcmpl-test", model: "mlx-test", created: 1))) + #expect(outputs.contains(.sse(SSEChunk.done))) + #expect(outputs.last == .complete(InferenceUsage(promptTokens: 4, completionTokens: 2))) +} + +private struct FakeSingleRequestEngine: SingleRequestChatEngine { + let events: [SingleRequestGenerationEvent] + + func generate( + prompt: FormattedChatPrompt, + cancellation: InferenceCancellationToken + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for event in events { + if cancellation.isCancelled { + continuation.finish(throwing: CancellationError()) + return + } + continuation.yield(event) + } + continuation.finish() + } + } +} diff --git a/provider-swift/Tests/ProviderCoreTests/IntegrationPlanTests.swift b/provider-swift/Tests/ProviderCoreTests/IntegrationPlanTests.swift new file mode 100644 index 00000000..7f6efe06 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/IntegrationPlanTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import ProviderCore + +@Test func phase6RegistrationUsesCutoverBackendAndOmitsDeprecatedRuntimeHashes() throws { + let config = CoordinatorClientConfig( + url: "wss://api.darkbloom.dev/ws/provider", + hardware: phase6Hardware(), + models: [phase6Model()], + backendName: "mlx-swift", + publicKey: "cHVibGljLWtleS1wbGFjZWhvbGRlci0zMi1ieXRlcw==", + runtimeHashes: RuntimeHashes(templateHashes: ["qwen3.5": "templatehash"]) + ) + + let data = try CoordinatorClientCodec.encodeRegistration( + from: config, + version: "0.4.0-swift", + privacyCapabilities: phase6PrivacyCapabilities() + ) + let object = try phase6JSONObject(data) + + #expect(object["type"] as? String == "register") + #expect(object["backend"] as? String == "mlx-swift") + #expect(object["version"] as? String == "0.4.0-swift") + #expect(object["encrypted_response_chunks"] as? Bool == true) + #expect(object["python_hash"] == nil) + #expect(object["runtime_hash"] == nil) + #expect((object["template_hashes"] as? [String: String])?["qwen3.5"] == "templatehash") + + let decoded = try ProviderProtocolCodec.decodeProviderMessage(from: data) + guard case .register(let register) = decoded else { + throw Phase6TestFailure.unexpectedMessage + } + #expect(register.backend == "mlx-swift") + #expect(register.pythonHash == nil) + #expect(register.runtimeHash == nil) +} + +@Test func phase6ReleasePayloadForSwiftRuntimeOmitsDeprecatedRuntimeHashFields() throws { + let payload = SwiftReleaseRegistrationPayload( + version: "0.4.0-swift", + platform: "macos-arm64", + binaryHash: String(repeating: "a", count: 64), + bundleHash: String(repeating: "b", count: 64), + templateHashes: "qwen3.5=templatehash", + url: "https://pub.example/releases/v0.4.0-swift/eigeninference-bundle-macos-arm64.tar.gz", + changelog: "Swift provider cutover test payload" + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(payload) + let object = try phase6JSONObject(data) + + #expect(object["version"] as? String == "0.4.0-swift") + #expect(object["platform"] as? String == "macos-arm64") + #expect(object["binary_hash"] as? String == String(repeating: "a", count: 64)) + #expect(object["bundle_hash"] as? String == String(repeating: "b", count: 64)) + #expect(object["python_hash"] == nil) + #expect(object["runtime_hash"] == nil) +} + +private struct SwiftReleaseRegistrationPayload: Encodable { + var version: String + var platform: String + var binaryHash: String + var bundleHash: String + var templateHashes: String + var url: String + var changelog: String + + enum CodingKeys: String, CodingKey { + case version + case platform + case binaryHash = "binary_hash" + case bundleHash = "bundle_hash" + case templateHashes = "template_hashes" + case url + case changelog + } +} + +private func phase6Hardware() -> HardwareInfo { + HardwareInfo( + machineModel: "Mac16,5", + chipName: "Apple M4 Max", + chipFamily: .m4, + chipTier: .max, + memoryGb: 128, + memoryAvailableGb: 124, + cpuCores: CpuCores(total: 16, performance: 12, efficiency: 4), + gpuCores: 40, + memoryBandwidthGbs: 546 + ) +} + +private func phase6Model() -> ModelInfo { + ModelInfo( + id: "mlx-community/Qwen2.5-7B-4bit", + modelType: "qwen2", + quantization: "4bit", + sizeBytes: 4_000_000_000, + estimatedMemoryGb: 4.5 + ) +} + +private func phase6PrivacyCapabilities() -> PrivacyCapabilities { + PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: true, + antiDebugEnabled: true, + coreDumpsDisabled: true, + envScrubbed: true, + hypervisorActive: false + ) +} + +private func phase6JSONObject(_ data: Data) throws -> [String: Any] { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw Phase6TestFailure.notJSONObject + } + return object +} + +private enum Phase6TestFailure: Error { + case notJSONObject + case unexpectedMessage +} diff --git a/provider-swift/Tests/ProviderCoreTests/ProtocolTests.swift b/provider-swift/Tests/ProviderCoreTests/ProtocolTests.swift new file mode 100644 index 00000000..8be68b86 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/ProtocolTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing +@testable import ProviderCore + +@Test func registerEncodingUsesSnakeCaseAndPreservesRawAttestation() throws { + let rawAttestation = #"{"signature":"sig","attestation":{"z":1,"a":[true,false],"path":"a/b"}}"# + let rawData = Data(rawAttestation.utf8) + let message = ProviderMessage.register(ProviderMessage.Register( + hardware: sampleHardware(), + models: [sampleModel()], + backend: "mlx_swift_lm", + version: "0.4.0-swift", + publicKey: "cHVibGlj", + encryptedResponseChunks: true, + attestation: RawJSON(rawBytes: rawData), + prefillTps: 512.5, + decodeTps: 123.25, + templateHashes: ["chatml": "templatehash"], + privacyCapabilities: samplePrivacyCapabilities() + )) + + let data = try ProviderProtocolCodec.encodeProviderMessage(message) + let json = String(data: data, encoding: .utf8) ?? "" + let object = try jsonObject(data) + + #expect(object["type"] as? String == "register") + #expect(object["encrypted_response_chunks"] as? Bool == true) + #expect(object["public_key"] as? String == "cHVibGlj") + #expect(object["prefill_tps"] as? Double == 512.5) + #expect(object["decode_tps"] as? Double == 123.25) + #expect(object["wallet_address"] == nil) + #expect(object["auth_token"] == nil) + #expect(json.contains(#""attestation":\#(rawAttestation)"#)) + + let decoded = try ProviderProtocolCodec.decodeProviderMessage(from: data) + guard case .register(let register) = decoded else { + throw TestFailure.unexpectedMessage + } + #expect(register.attestation?.rawBytes == rawData) +} + +@Test func providerMessagesRoundTripThroughCodableEnvelope() throws { + let messages: [ProviderMessage] = [ + .register(ProviderMessage.Register( + hardware: sampleHardware(), + models: [sampleModel()], + backend: "mlx_swift_lm", + encryptedResponseChunks: true + )), + .heartbeat(ProviderMessage.Heartbeat( + status: .serving, + activeModel: "mlx-community/Qwen2.5-7B-4bit", + warmModels: ["mlx-community/Qwen2.5-7B-4bit"], + stats: ProviderStats(requestsServed: 4, tokensGenerated: 4096), + systemMetrics: SystemMetrics(memoryPressure: 0.2, cpuUsage: 0.3, thermalState: .nominal), + backendCapacity: BackendCapacity( + slots: [BackendSlotCapacity( + model: "mlx-community/Qwen2.5-7B-4bit", + state: "running", + numRunning: 1, + numWaiting: 0, + activeTokens: 512, + maxTokensPotential: 2048 + )], + gpuMemoryActiveGb: 8.5, + gpuMemoryPeakGb: 9.0, + gpuMemoryCacheGb: 1.25, + totalMemoryGb: 64.0 + ) + )), + .inferenceAccepted(ProviderMessage.InferenceAccepted(requestId: "req-accepted")), + .inferenceResponseChunk(ProviderMessage.InferenceResponseChunk( + requestId: "req-chunk", + data: "data: {\"choices\":[]}\n\n" + )), + .inferenceResponseChunk(ProviderMessage.InferenceResponseChunk( + requestId: "req-encrypted", + encryptedData: EncryptedPayload(ephemeralPublicKey: "ZXBoZW1lcmFs", ciphertext: "Y2lwaGVy") + )), + .inferenceComplete(ProviderMessage.InferenceComplete( + requestId: "req-complete", + usage: UsageInfo(promptTokens: 12, completionTokens: 34), + seSignature: "c2ln", + responseHash: "aGFzaA==" + )), + .inferenceError(ProviderMessage.InferenceError( + requestId: "req-error", + error: "model not loaded", + statusCode: 503 + )), + .attestationResponse(ProviderMessage.AttestationResponse( + nonce: "bm9uY2U=", + signature: "c2ln", + statusSignature: "c3RhdHVz", + publicKey: "cGs=", + hypervisorActive: true, + rdmaDisabled: true, + sipEnabled: true, + secureBootEnabled: true, + binaryHash: "binaryhash", + activeModelHash: "modelhash", + runtimeHash: "runtimehash", + templateHashes: ["chatml": "templatehash"], + modelHashes: ["model": "weighthash"] + )), + ] + + for message in messages { + let encoded = try ProviderProtocolCodec.encodeProviderMessage(message) + let decoded = try ProviderProtocolCodec.decodeProviderMessage(from: encoded) + #expect(decoded == message) + } +} + +@Test func coordinatorMessagesDecodeAndEncodeWithSnakeCaseKeys() throws { + let encryptedRequest = #"{"type":"inference_request","request_id":"go-enc-req-1","body":null,"encrypted_body":{"ephemeral_public_key":"ZXBoZW1lcmFs","ciphertext":"Y2lwaGVy"}}"# + let request = try ProviderProtocolCodec.decodeCoordinatorMessage(from: encryptedRequest) + guard case .inferenceRequest(let inferenceRequest) = request else { + throw TestFailure.unexpectedMessage + } + #expect(inferenceRequest.requestId == "go-enc-req-1") + #expect(inferenceRequest.body.isNull) + #expect(inferenceRequest.encryptedBody?.ephemeralPublicKey == "ZXBoZW1lcmFs") + + let status = CoordinatorMessage.runtimeStatus(CoordinatorMessage.RuntimeStatus( + verified: false, + mismatches: [RuntimeMismatch(component: "runtime", expected: "good", got: "bad")] + )) + let encodedStatus = try ProviderProtocolCodec.encodeCoordinatorMessage(status) + let object = try jsonObject(encodedStatus) + #expect(object["type"] as? String == "runtime_status") + #expect(object["verified"] as? Bool == false) + #expect(object["mismatches"] != nil) + #expect(try ProviderProtocolCodec.decodeCoordinatorMessage(from: encodedStatus) == status) +} + +@Test func emptyOptionalCollectionsAreOmitted() throws { + let heartbeat = ProviderMessage.heartbeat(ProviderMessage.Heartbeat( + status: .idle, + stats: ProviderStats(), + systemMetrics: SystemMetrics(memoryPressure: 0, cpuUsage: 0, thermalState: .nominal) + )) + let heartbeatJSON = String( + data: try ProviderProtocolCodec.encodeProviderMessage(heartbeat), + encoding: .utf8 + ) ?? "" + + #expect(!heartbeatJSON.contains("active_model")) + #expect(!heartbeatJSON.contains("warm_models")) + #expect(!heartbeatJSON.contains("backend_capacity")) + + let runtimeStatus = CoordinatorMessage.runtimeStatus(CoordinatorMessage.RuntimeStatus(verified: true)) + let runtimeJSON = String( + data: try ProviderProtocolCodec.encodeCoordinatorMessage(runtimeStatus), + encoding: .utf8 + ) ?? "" + #expect(!runtimeJSON.contains("mismatches")) +} + +private func sampleHardware() -> HardwareInfo { + HardwareInfo( + machineModel: "Mac16,5", + chipName: "Apple M4 Max", + chipFamily: .m4, + chipTier: .max, + memoryGb: 128, + memoryAvailableGb: 124, + cpuCores: CpuCores(total: 16, performance: 12, efficiency: 4), + gpuCores: 40, + memoryBandwidthGbs: 546 + ) +} + +private func sampleModel() -> ModelInfo { + ModelInfo( + id: "mlx-community/Qwen2.5-7B-4bit", + modelType: "qwen2", + parameters: nil, + quantization: "4bit", + sizeBytes: 4_000_000_000, + estimatedMemoryGb: 4.5 + ) +} + +private func samplePrivacyCapabilities() -> PrivacyCapabilities { + PrivacyCapabilities( + textBackendInprocess: true, + textProxyDisabled: true, + pythonRuntimeLocked: true, + dangerousModulesBlocked: true, + sipEnabled: true, + antiDebugEnabled: true, + coreDumpsDisabled: true, + envScrubbed: true, + hypervisorActive: false + ) +} + +private func jsonObject(_ data: Data) throws -> [String: Any] { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw TestFailure.notJSONObject + } + return object +} + +private enum TestFailure: Error { + case notJSONObject + case unexpectedMessage +} diff --git a/provider-swift/Tests/ProviderCoreTests/ProviderCoreTests.swift b/provider-swift/Tests/ProviderCoreTests/ProviderCoreTests.swift new file mode 100644 index 00000000..131fd5d8 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/ProviderCoreTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import ProviderCore + +@Test func versionExists() { + #expect(ProviderCore.version.contains("swift")) +} diff --git a/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift b/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift new file mode 100644 index 00000000..ee159f76 --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift @@ -0,0 +1,273 @@ +import Darwin +import Foundation +import Testing +@testable import ProviderCore + +@Test func sipStatusParserRecognizesEnabledDisabledAndCustomOutput() { + #expect(SIPStatusParser.parse("System Integrity Protection status: enabled.\n") == .enabled) + #expect(SIPStatusParser.parse("System Integrity Protection status: disabled.\n") == .disabled) + + let custom = """ + System Integrity Protection status: enabled (Custom Configuration). + + Configuration: + Kext Signing: disabled + Filesystem Protections: enabled + Debugging Restrictions: disabled + """ + + #expect( + SIPStatusParser.parse(custom) == .enabledWithCustomConfiguration( + disabledProtections: ["Kext Signing", "Debugging Restrictions"] + ) + ) +} + +@Test func sipStatusCheckerUsesInjectedRunner() { + let checker = SIPStatusChecker( + runner: SecurityCommandRunner { executablePath, arguments in + #expect(executablePath == "/usr/bin/csrutil") + #expect(arguments == ["status"]) + return SecurityCommandResult( + terminationStatus: 0, + stdout: "System Integrity Protection status: enabled.\n" + ) + } + ) + + #expect(checker.status() == .enabled) + #expect(checker.isFullyEnabled()) +} + +@Test func sipStatusParserReportsUnavailableOnCommandFailure() { + let status = SIPStatusParser.parse( + SecurityCommandResult( + terminationStatus: 1, + stdout: "", + stderr: "csrutil: failed" + ) + ) + + #expect(status == .unavailable(reason: "csrutil: failed")) +} + +@Test func binarySHA256HasherHashesDataAndFiles() throws { + let hasher = BinarySHA256Hasher(chunkSize: 2) + #expect( + hasher.hashData(Data("abc".utf8)) + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + + let tempDir = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fileURL = tempDir.appendingPathComponent("payload.bin") + try Data("abc".utf8).write(to: fileURL) + #expect(try hasher.hashFile(at: fileURL) == hasher.hashData(Data("abc".utf8))) +} + +@Test func hashFilesSortedIsStableAcrossInputOrderAndContentSensitive() throws { + let tempDir = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let first = tempDir.appendingPathComponent("a.txt") + let second = tempDir.appendingPathComponent("b.txt") + try Data("one".utf8).write(to: first) + try Data("two".utf8).write(to: second) + + let hasher = BinarySHA256Hasher() + let ordered = try hasher.hashFilesSorted([first, second]) + let reversed = try hasher.hashFilesSorted([second, first]) + #expect(ordered == reversed) + + try Data("changed".utf8).write(to: second) + #expect(try hasher.hashFilesSorted([first, second]) != ordered) +} + +@Test func runtimeHashReporterBuildsCoordinatorReadyReport() throws { + let tempDir = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let binaryURL = tempDir.appendingPathComponent("darkbloom") + let runtimeDir = tempDir.appendingPathComponent("runtime") + let templateDir = tempDir.appendingPathComponent("templates") + try FileManager.default.createDirectory(at: runtimeDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: templateDir, withIntermediateDirectories: true) + + try Data("binary".utf8).write(to: binaryURL) + try Data("runtime-a".utf8).write(to: runtimeDir.appendingPathComponent("a.swiftmodule")) + try FileManager.default.createDirectory( + at: runtimeDir.appendingPathComponent("__pycache__"), + withIntermediateDirectories: true + ) + try Data("ignored".utf8).write(to: runtimeDir.appendingPathComponent("__pycache__").appendingPathComponent("x.pyc")) + try Data("template".utf8).write(to: templateDir.appendingPathComponent("chatml.jinja")) + try Data("not a template".utf8).write(to: templateDir.appendingPathComponent("README.txt")) + + let reporter = RuntimeHashReporter() + let report = try reporter.report( + binaryURL: binaryURL, + runtimeDirectories: [runtimeDir], + templateDirectory: templateDir + ) + + let expectedBinaryHash = try BinarySHA256Hasher().hashFile(at: binaryURL) + #expect(report.binaryHash == expectedBinaryHash) + #expect(report.pythonHash == nil) + #expect(report.runtimeHash != nil) + #expect(report.templateHashes.keys.sorted() == ["chatml"]) + #expect(report.coordinatorRuntimeHashes.runtimeHash == report.runtimeHash) + #expect(report.coordinatorRuntimeHashes.templateHashes == report.templateHashes) +} + +@Test func statusCanonicalMatchesCoordinatorGoldenBytes() throws { + let data = try StatusCanonical.build(StatusCanonicalInput( + nonce: "test-nonce", + timestamp: "2026-04-16T12:00:00Z", + hypervisorActive: true, + rdmaDisabled: true, + sipEnabled: true, + secureBootEnabled: true, + binaryHash: "binhash", + activeModelHash: "activemodel", + pythonHash: "pyhash", + runtimeHash: "rthash", + templateHashes: [ + "chatml": "tmplhash1", + "gemma": "tmplhash2", + ], + modelHashes: [ + "qwen": "modelhash1", + "trinity": "modelhash2", + ] + )) + let expected = #"{"active_model_hash":"activemodel","binary_hash":"binhash","hypervisor_active":true,"model_hashes":{"qwen":"modelhash1","trinity":"modelhash2"},"nonce":"test-nonce","python_hash":"pyhash","rdma_disabled":true,"runtime_hash":"rthash","secure_boot_enabled":true,"sip_enabled":true,"template_hashes":{"chatml":"tmplhash1","gemma":"tmplhash2"},"timestamp":"2026-04-16T12:00:00Z"}"# + #expect(String(data: data, encoding: .utf8) == expected) +} + +@Test func statusCanonicalOmitsEmptyFieldsAndSerializesFalse() throws { + let minimal = try StatusCanonical.build(StatusCanonicalInput(nonce: "n", timestamp: "t")) + #expect(String(data: minimal, encoding: .utf8) == #"{"nonce":"n","timestamp":"t"}"#) + + let explicitFalse = try StatusCanonical.build(StatusCanonicalInput( + nonce: "n", + timestamp: "t", + sipEnabled: false + )) + #expect(String(data: explicitFalse, encoding: .utf8) == #"{"nonce":"n","sip_enabled":false,"timestamp":"t"}"#) +} + +@Test func securityPostureAllowsRDMAEnabledWhenSIPIsEnabled() { + let posture = SecurityPosture( + sipEnabled: true, + rdmaDisabled: false, + secureBootEnabled: true, + authenticatedRootEnabled: true, + hardenedRuntimeEnabled: true, + antiDebugEnabled: true, + coreDumpsDisabled: true, + envScrubbed: true, + mdmEnrolled: false, + bundleSignatureValid: true, + binaryHash: "hash" + ) + + #expect(posture.isSafeToServe) +} + +@Test func environmentScrubPlannerPlansWithoutMutatingEnvironment() { + let planner = EnvironmentScrubPlanner() + let plan = planner.plan(for: [ + "PATH": "/usr/bin", + "DYLD_INSERT_LIBRARIES": "/tmp/inject.dylib", + "PYTHONPATH": "/tmp/sitecustomize", + ]) + + #expect(plan.variableNames == ["DYLD_INSERT_LIBRARIES", "PYTHONPATH"]) + #expect(plan.removals.contains { $0.name == "DYLD_INSERT_LIBRARIES" }) + #expect(!plan.removals.contains { $0.name == "PATH" }) +} + +@Test func debugAttachmentProtectorUsesInjectedPtraceClient() throws { + let recorder = PtraceRecorder() + let protector = DebugAttachmentProtector( + client: PtraceClient( + ptrace: { request, pid, addr, data in + recorder.record(request: request, pid: pid, addrIsNil: addr == nil, data: data) + return 0 + }, + lastErrno: { 0 } + ) + ) + + #expect(try protector.denyDebuggerAttachment()) + #expect(recorder.calls == [ + PtraceCall( + request: DebugAttachmentProtector.ptDenyAttachRequest, + pid: 0, + addrIsNil: true, + data: 0 + ), + ]) +} + +@Test func debugAttachmentProtectorCanBeDisabledForTests() throws { + let protector = DebugAttachmentProtector.disabledForTests + #expect(try protector.denyDebuggerAttachment() == false) +} + +@Test func debugAttachmentProtectorReportsErrnoOnFailure() { + let protector = DebugAttachmentProtector( + client: PtraceClient( + ptrace: { _, _, _, _ in -1 }, + lastErrno: { EPERM } + ) + ) + + do { + _ = try protector.denyDebuggerAttachment() + Issue.record("Expected PT_DENY_ATTACH failure") + } catch let error as DebugAttachmentProtectionError { + #expect(error == .denyAttachFailed(errno: EPERM, message: String(cString: strerror(EPERM)))) + } catch { + Issue.record("Unexpected error: \(error)") + } +} + +private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderCoreSecurityTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private struct PtraceCall: Equatable { + let request: CInt + let pid: pid_t + let addrIsNil: Bool + let data: CInt +} + +private final class PtraceRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [PtraceCall] = [] + + var calls: [PtraceCall] { + lock.withLock { + storage + } + } + + func record(request: CInt, pid: pid_t, addrIsNil: Bool, data: CInt) { + lock.withLock { + storage.append( + PtraceCall( + request: request, + pid: pid, + addrIsNil: addrIsNil, + data: data + ) + ) + } + } +} diff --git a/provider-swift/Tests/ProviderCoreTests/StandaloneServerTests.swift b/provider-swift/Tests/ProviderCoreTests/StandaloneServerTests.swift new file mode 100644 index 00000000..a077c60e --- /dev/null +++ b/provider-swift/Tests/ProviderCoreTests/StandaloneServerTests.swift @@ -0,0 +1,94 @@ +import Hummingbird +import HummingbirdTesting +import Testing +@testable import ProviderCore + +@Test func standaloneServerHealthEndpointUsesHummingbirdRouter() async throws { + let app = standaloneTestServer().makeApplication() + + try await app.test(.router) { client in + try await client.execute(uri: "/health", method: .get) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "application/json") + #expect(response.headers[.accessControlAllowOrigin] == "*") + #expect(String(buffer: response.body).contains(#""status":"ok""#)) + #expect(String(buffer: response.body).contains(#""version":"#)) + } + } +} + +@Test func standaloneServerModelsEndpointReturnsOpenAIListShape() async throws { + let model = ModelInfo( + id: "mlx-community/Qwen2.5-7B-4bit", + modelType: "qwen2", + quantization: "4bit", + sizeBytes: 4_000_000_000, + estimatedMemoryGb: 4.5 + ) + let app = standaloneTestServer(models: [model]).makeApplication() + + try await app.test(.router) { client in + try await client.execute(uri: "/v1/models", method: .get) { response in + #expect(response.status == .ok) + let body = String(buffer: response.body) + #expect(body.contains(#""object":"list""#)) + #expect(body.contains(#""id":"mlx-community\/Qwen2.5-7B-4bit""#)) + #expect(body.contains(#""owned_by":"local""#)) + } + } +} + +@Test func standaloneServerRejectsUnsupportedChatContentType() async throws { + let app = standaloneTestServer().makeApplication() + + try await app.test(.router) { client in + try await client.execute( + uri: "/v1/chat/completions", + method: .post, + headers: [.contentType: "text/plain"], + body: ByteBuffer(string: "not json") + ) { response in + #expect(response.status == .unsupportedMediaType) + #expect(String(buffer: response.body).contains("Content-Type must be application")) + } + } +} + +@Test func standaloneServerRejectsMalformedChatJSON() async throws { + let app = standaloneTestServer().makeApplication() + + try await app.test(.router) { client in + try await client.execute( + uri: "/v1/chat/completions", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"model":"mlx-test","messages":"bad"}"#) + ) { response in + #expect(response.status == .badRequest) + #expect(String(buffer: response.body).contains("Invalid request body")) + } + } +} + +@Test func standaloneServerReportsNoModelLoadedForNonStreamingChat() async throws { + let app = standaloneTestServer().makeApplication() + + try await app.test(.router) { client in + try await client.execute( + uri: "/v1/chat/completions", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"model":"mlx-test","messages":[{"role":"user","content":"hello"}],"stream":false}"#) + ) { response in + #expect(response.status == .internalServerError) + #expect(String(buffer: response.body).contains("No model loaded")) + } + } +} + +private func standaloneTestServer(models: [ModelInfo] = []) -> StandaloneServer { + StandaloneServer( + scheduler: BatchScheduler(maxConcurrentRequests: 1), + models: models + ) +} From 2886806acd0f2fa955d7dd52f9189ebcc1267562 Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Fri, 1 May 2026 03:44:05 -0700 Subject: [PATCH 03/34] Remove unused e2e vector generator --- .../internal/e2e/testdata/gen-vectors/main.go | 91 ------------------- 1 file changed, 91 deletions(-) delete mode 100644 coordinator/internal/e2e/testdata/gen-vectors/main.go diff --git a/coordinator/internal/e2e/testdata/gen-vectors/main.go b/coordinator/internal/e2e/testdata/gen-vectors/main.go deleted file mode 100644 index 53d388d9..00000000 --- a/coordinator/internal/e2e/testdata/gen-vectors/main.go +++ /dev/null @@ -1,91 +0,0 @@ -// Generates deterministic NaCl box test vectors for cross-language validation. -// -// Usage: cd coordinator && go run ./internal/e2e/testdata/gen-vectors -package main - -import ( - "crypto/ecdh" - "encoding/base64" - "encoding/hex" - "fmt" - - "golang.org/x/crypto/nacl/box" -) - -func main() { - // Fixed provider key pair (recipient) - providerPrivBytes := [32]byte{ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, - 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, - } - - // Fixed ephemeral key pair (sender / coordinator) - ephPrivBytes := [32]byte{ - 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, - 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, - 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, - 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, - } - - providerPub := derivePub(providerPrivBytes[:]) - ephPub := derivePub(ephPrivBytes[:]) - - var providerPubArr, ephPubArr [32]byte - copy(providerPubArr[:], providerPub) - copy(ephPubArr[:], ephPub) - - // Fixed nonce - nonce := [24]byte{ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - } - - fmt.Println("// Golden NaCl box test vectors") - fmt.Println("// Provider private key:", hex.EncodeToString(providerPrivBytes[:])) - fmt.Println("// Provider public key: ", hex.EncodeToString(providerPub)) - fmt.Println("// Ephemeral private key:", hex.EncodeToString(ephPrivBytes[:])) - fmt.Println("// Ephemeral public key: ", hex.EncodeToString(ephPub)) - fmt.Println("// Nonce: ", hex.EncodeToString(nonce[:])) - fmt.Println() - - testCases := []struct { - name string - plaintext string - }{ - {"hello", "hello from Go"}, - {"json", `{"model":"test","messages":[{"role":"user","content":"hi"}]}`}, - {"empty", ""}, - {"unicode", "こんにちは世界 🌍"}, - } - - for _, tc := range testCases { - plainBytes := []byte(tc.plaintext) - encrypted := box.Seal(nonce[:], plainBytes, &nonce, &providerPubArr, &ephPrivBytes) - - fmt.Printf("Vector: %s\n", tc.name) - fmt.Printf(" ephemeral_pub_b64: %s\n", base64.StdEncoding.EncodeToString(ephPub)) - fmt.Printf(" ciphertext_b64: %s\n", base64.StdEncoding.EncodeToString(encrypted)) - fmt.Printf(" provider_priv_b64: %s\n", base64.StdEncoding.EncodeToString(providerPrivBytes[:])) - fmt.Printf(" plaintext: %q\n", tc.plaintext) - fmt.Println() - } - - // Reverse: provider encrypts → coordinator decrypts - responseEncrypted := box.Seal(nonce[:], []byte("response from provider"), &nonce, &ephPubArr, &providerPrivBytes) - fmt.Println("Vector: reverse") - fmt.Printf(" sender_pub_b64: %s\n", base64.StdEncoding.EncodeToString(providerPub)) - fmt.Printf(" ciphertext_b64: %s\n", base64.StdEncoding.EncodeToString(responseEncrypted)) - fmt.Printf(" recipient_priv_b64: %s\n", base64.StdEncoding.EncodeToString(ephPrivBytes[:])) - fmt.Printf(" plaintext: %q\n", "response from provider") -} - -func derivePub(privBytes []byte) []byte { - key, err := ecdh.X25519().NewPrivateKey(privBytes) - if err != nil { - panic(err) - } - return key.PublicKey().Bytes() -} From c511cd0dd19a1c31d86d7f8b8ac29505a27866f8 Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Fri, 1 May 2026 10:16:40 -0700 Subject: [PATCH 04/34] Continuous batching, GPU-only enforcement, rename to darkbloom, Layr-Labs forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the v0.5.0 cutover commit on the Swift provider PR. It lands true continuous batching as the production inference path, threads per-row sampling through the request, hard-fails on CPU-only hosts, renames the user-visible CLI surface from "eigeninference" to "darkbloom" with backward compatibility, and re-homes the mlx-swift / mlx-swift-lm submodules to Layr-Labs forks. Continuous batching (default, no parallel implementations) ---------------------------------------------------------- Replaces the per-request BatchScheduler with one shared BatchGenerator ported from upstream `mlx_lm.generate`. All concurrent requests are merged into one batched forward pass per step. Bit-identical against single-stream greedy on: - Qwen3 0.6B-8bit (dense), B=2 / B=4-ragged - Qwen3.5 0.8B-MLX-4bit (hybrid SSM + attention), B=2 - Gemma 4 26B-A4B-it-8bit (MoE, 26 GB), B=2 The mlx-swift-lm side of this work is at Layr-Labs/mlx-swift-lm@darkbloom-continuous-batching: - BatchKVCache + BatchedCache protocol - SequenceStateMachine, PromptProcessingBatch, GenerationBatch, BatchGenerator - RowSamplers (temperature / top-P / top-K / seed) - Gemma 4 MoE support + K=V branch fix in Gemma4Attention Production scheduler in provider-swift/Sources/ProviderCore/Inference/ BatchScheduler.swift wraps the engine in an actor; detached worker calls into the actor only for short critical sections so cancel/submit never queue behind a long-running step. submit() builds a per-row sampler from request.{temperature, top_p, top_k, seed}. Validation also covers eviction-and-admission: row 0 finishes mid-batch, row C is admitted into its slot, row C's tokens match a solo run, row B (running through the eviction) also matches its solo run. This locks in BatchKVCache.filterBatched + extendBatched correctness end-to-end. Sampler unit tests cover greedy passthrough, top-K=1 determinism, top-K masking, top-P collapse-to-dominant, top-P=1 identity, seeded reproducibility, and different-seed divergence. GPU-only enforcement -------------------- ProviderCore/Inference/GPUEnforcement.swift: - probeMetal(): non-throwing Metal device probe - requireMetal(): throws on missing GPU; pins Device.setDefault(.gpu); idempotent Wired into BatchScheduler.loadModel, StartCommand, BenchmarkCommand, and `darkbloom doctor`. Doctor surfaces a `[PASS] metal gpu: , GB working set` line; `[FAIL]` on Intel/Linux. CPU fallback for inference is rejected up-front with a descriptive error. Rename: eigeninference → darkbloom (Swift CLI surface) ------------------------------------------------------ Canonical names: - eigeninference-enclave → darkbloom-enclave (binary + struct) - Sources/eigeninference-enclave-cli/ → Sources/darkbloom-enclave-cli/ - SwiftPM target EigenInferenceEnclaveCLI → DarkbloomEnclaveCLI - eigeninference-bundle-macos-arm64.tar.gz → darkbloom-bundle-macos-arm64.tar.gz - ~/.config/eigeninference/ → ~/.config/darkbloom/ (preferred path) - Mobileconfig prefix: EigenInference-Enroll-* → Darkbloom-Enroll-* Backward compatibility: - install.sh creates a `eigeninference-enclave` symlink to `darkbloom-enclave` so existing install scripts keep resolving. - Config loader still reads ~/.config/eigeninference/ and the App Support legacy paths as fallbacks; new writes always go to ~/.config/darkbloom/. - LocalDataCleanup.purge() removes both directories. - release-swift.yml publishes the latest tarball under both canonical and legacy filenames. - NodeKeyPair.legacyDirNames and SecurityHardening MDM-profile-name matchers still accept the old name. - Coordinator/Rust/UI surfaces (R2 buckets, Stripe descriptors, Solana memos, telemetry source attribution) intentionally untouched. CLI subcommands shipped in v0.5.0 --------------------------------- darkbloom serve / start / stop, status, doctor, models {list, catalog, download, remove}, enroll, unenroll, login, logout, logs, autoupdate, benchmark, update, verify. start --foreground is the launchd entrypoint; start --local --port N runs a standalone OpenAI-compatible HTTP server. PID-file single-instance enforcement, caffeinate-based sleep prevention, panic-hook telemetry, and metallib hash in attestation are all wired in. Submodule re-homing ------------------- .gitmodules now points to Layr-Labs/mlx-swift and Layr-Labs/mlx-swift-lm. The mlx-swift pointer is unchanged (clean `main`). The mlx-swift-lm pointer advances from 3ec4b8a (codex/local-mlx-swift-dependency) to 91612d5 (darkbloom-continuous-batching) which carries the batching engine + Gemma 4 MoE fork on Layr-Labs/mlx-swift-lm. Tests ----- 135 / 135 tests pass in 16.5 s with DARKBLOOM_LIVE_MLX_TESTS=1 and DARKBLOOM_LIVE_MLX_GEMMA=1 (live MLX inference against real models plus the gated 27 GB Gemma generation test). --- .claude/continuous-batching-status.md | 97 +++ .claude/swift-migration-plan.md | 563 ++++++++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 5 +- .github/ISSUE_TEMPLATE/feature_request.yml | 5 +- .github/ISSUE_TEMPLATE/good_first_issue.yml | 5 +- .github/pull_request_template.md | 7 +- .github/workflows/codex.yml | 2 +- .github/workflows/release-swift.yml | 406 +++++++++++ .github/workflows/release.yml | 575 --------------- .gitmodules | 4 +- AGENTS.md | 107 +-- CLAUDE.md | 93 +-- CONTRIBUTING.md | 26 +- README.md | 35 +- app/EigenInference/Package.swift | 22 - .../Sources/EigenInference/CLIRunner.swift | 225 ------ .../EigenInference/ConfigManager.swift | 208 ------ .../EigenInference/DashboardView.swift | 424 ----------- .../Sources/EigenInference/DesignSystem.swift | 455 ------------ .../Sources/EigenInference/DoctorView.swift | 212 ------ .../EigenInference/EigenInferenceApp.swift | 230 ------ .../Sources/EigenInference/GuideAvatar.swift | 230 ------ .../Sources/EigenInference/IdleDetector.swift | 68 -- .../EigenInference/Illustrations.swift | 377 ---------- .../EigenInference/LaunchAgentManager.swift | 131 ---- .../EigenInference/LogViewerView.swift | 209 ------ .../Sources/EigenInference/MenuBarView.swift | 340 --------- .../Sources/EigenInference/ModelCatalog.swift | 47 -- .../EigenInference/ModelCatalogView.swift | 212 ------ .../Sources/EigenInference/ModelManager.swift | 214 ------ .../EigenInference/NotificationManager.swift | 131 ---- .../EigenInference/ProviderManager.swift | 283 -------- .../EigenInference/Resources/AppIcon-full.png | Bin 6770 -> 0 bytes .../EigenInference/Resources/AppIcon.png | Bin 7056 -> 0 bytes .../EigenInference/Resources/MenuBarIcon.png | Bin 412 -> 0 bytes .../Resources/MenuBarIcon@2x.png | Bin 520 -> 0 bytes .../Resources/guide-avatar-celebrating.png | Bin 1632685 -> 0 bytes .../Resources/guide-avatar-concerned.png | Bin 1523573 -> 0 bytes .../Resources/guide-avatar-excited.png | Bin 1557768 -> 0 bytes .../Resources/guide-avatar-explaining.png | Bin 1512519 -> 0 bytes .../Resources/guide-avatar-greeting.png | Bin 1262642 -> 0 bytes .../Resources/guide-avatar-thinking.png | Bin 1497767 -> 0 bytes .../EigenInference/Resources/guide-avatar.png | Bin 1493107 -> 0 bytes .../EigenInference/SecurityManager.swift | 147 ---- .../Sources/EigenInference/SettingsView.swift | 351 --------- .../EigenInference/SetupWizardView.swift | 664 ----------------- .../EigenInference/StatusViewModel.swift | 627 ---------------- .../EigenInference/TelemetryReporter.swift | 267 ------- .../EigenInference/UpdateManager.swift | 71 -- .../EigenInferenceTests/CLIRunnerTests.swift | 136 ---- .../ConfigManagerTests.swift | 282 ------- .../EigenInferenceAppTests.swift | 502 ------------- .../TelemetryReporterTests.swift | 54 -- .../UpdateManagerTests.swift | 131 ---- coordinator/internal/api/billing_handlers.go | 2 +- coordinator/internal/api/install.sh | 458 ++++-------- coordinator/internal/api/install_sh_test.go | 63 +- .../internal/api/model_catalog_filter.go | 25 + coordinator/internal/api/server.go | 38 +- .../internal/attestation/attestation.go | 4 - .../attestation/status_canonical_test.go | 5 +- coordinator/internal/store/interface.go | 8 +- coordinator/internal/store/postgres.go | 6 +- docs/ARCHITECTURE.md | 90 +-- docs/legal/privacy-policy.md | 13 +- docs/legal/terms-of-service.md | 6 +- docs/release-pipeline.md | 90 --- docs/release-runbook.md | 243 ------- libs/mlx-swift-lm | 2 +- provider-swift/Package.resolved | 68 +- provider-swift/Package.swift | 59 +- provider-swift/README.md | 216 +++--- .../ProviderCore/Auth/Enrollment.swift | 226 ++++++ .../ProviderCore/Config/ProviderConfig.swift | 74 +- .../Coordinator/CoordinatorClient.swift | 59 +- .../Inference/BatchScheduler.swift | 545 +++++--------- .../ProviderCore/Inference/ChatRequest.swift | 215 +++++- .../Inference/GPUEnforcement.swift | 91 +++ .../Inference/InferenceEngine.swift | 317 -------- .../Inference/LocalTokenizerLoader.swift | 74 ++ .../ProviderCore/Models/ModelCatalog.swift | 345 +++++++++ .../Sources/ProviderCore/ProviderCore.swift | 11 +- .../Sources/ProviderCore/ProviderLoop.swift | 147 ++-- .../ProviderCore/Security/BinaryHasher.swift | 48 ++ .../Service/ProcessLifecycle.swift | 116 +++ .../ProviderCore/Telemetry/PanicHook.swift | 114 +++ .../ProviderCore/Update/UpdateBanner.swift | 104 +++ .../darkbloom-enclave-cli/EnclaveCLI.swift | 136 ++++ .../Sources/darkbloom/AutoUpdateCommand.swift | 64 ++ .../Sources/darkbloom/BenchmarkCommand.swift | 7 + .../Sources/darkbloom/Darkbloom.swift | 26 + .../Sources/darkbloom/DoctorCommand.swift | 19 + .../Sources/darkbloom/EnrollCommand.swift | 78 ++ .../Sources/darkbloom/LogsCommand.swift | 70 ++ .../Sources/darkbloom/ModelsCommand.swift | 246 ++++++- .../Sources/darkbloom/StartCommand.swift | 110 ++- .../Sources/darkbloom/StatusCommand.swift | 4 + .../Sources/darkbloom/UnenrollCommand.swift | 62 ++ .../ProviderCoreTests/BatchKVCacheTests.swift | 327 +++++++++ .../ChatRequestExtraFieldsTests.swift | 113 +++ .../ContinuousBatchingLiveTests.swift | 428 +++++++++++ .../CoordinatorIntegrationTests.swift | 588 +++++++++++++++ .../ProviderCoreTests/EnrollmentTests.swift | 53 ++ .../GPUEnforcementTests.swift | 56 ++ .../Helpers/MockCoordinator.swift | 685 ++++++++++++++++++ .../InferenceLiveTests.swift | 410 +++++++++++ .../IntegrationPlanTests.swift | 2 +- .../LiveInferenceFixtures.swift | 310 ++++++++ .../ProviderCoreTests/MetallibHashTests.swift | 76 ++ .../ProviderCoreTests/ModelCatalogTests.swift | 113 +++ .../ProcessLifecycleTests.swift | 62 ++ .../ProviderCoreTests/ProviderCoreTests.swift | 13 +- .../ProviderCoreTests/RowSamplersTests.swift | 148 ++++ .../SequenceStateMachineTests.swift | 105 +++ .../TelemetryClientTests.swift | 48 ++ .../ProviderCoreTests/UpdateBannerTests.swift | 44 ++ provider/src/coordinator.rs | 16 +- resources/AppIcon.icns | Bin 47763 -> 0 bytes scripts/build-bridge-app.sh | 77 -- scripts/build-bundle.sh | 392 ---------- scripts/bundle-app.sh | 472 ------------ scripts/fetch-metallib.sh | 62 ++ scripts/install.sh | 457 ++++-------- scripts/sign-hardened.sh | 88 --- tests/integration_test.py | 684 ----------------- 125 files changed, 7938 insertions(+), 11775 deletions(-) create mode 100644 .claude/continuous-batching-status.md create mode 100644 .claude/swift-migration-plan.md create mode 100644 .github/workflows/release-swift.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 app/EigenInference/Package.swift delete mode 100644 app/EigenInference/Sources/EigenInference/CLIRunner.swift delete mode 100644 app/EigenInference/Sources/EigenInference/ConfigManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/DashboardView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/DesignSystem.swift delete mode 100644 app/EigenInference/Sources/EigenInference/DoctorView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/EigenInferenceApp.swift delete mode 100644 app/EigenInference/Sources/EigenInference/GuideAvatar.swift delete mode 100644 app/EigenInference/Sources/EigenInference/IdleDetector.swift delete mode 100644 app/EigenInference/Sources/EigenInference/Illustrations.swift delete mode 100644 app/EigenInference/Sources/EigenInference/LaunchAgentManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/LogViewerView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/MenuBarView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/ModelCatalog.swift delete mode 100644 app/EigenInference/Sources/EigenInference/ModelCatalogView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/ModelManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/NotificationManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/ProviderManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/AppIcon-full.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/AppIcon.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/MenuBarIcon.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/MenuBarIcon@2x.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-celebrating.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-concerned.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-excited.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-explaining.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-greeting.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar-thinking.png delete mode 100644 app/EigenInference/Sources/EigenInference/Resources/guide-avatar.png delete mode 100644 app/EigenInference/Sources/EigenInference/SecurityManager.swift delete mode 100644 app/EigenInference/Sources/EigenInference/SettingsView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/SetupWizardView.swift delete mode 100644 app/EigenInference/Sources/EigenInference/StatusViewModel.swift delete mode 100644 app/EigenInference/Sources/EigenInference/TelemetryReporter.swift delete mode 100644 app/EigenInference/Sources/EigenInference/UpdateManager.swift delete mode 100644 app/EigenInference/Tests/EigenInferenceTests/CLIRunnerTests.swift delete mode 100644 app/EigenInference/Tests/EigenInferenceTests/ConfigManagerTests.swift delete mode 100644 app/EigenInference/Tests/EigenInferenceTests/EigenInferenceAppTests.swift delete mode 100644 app/EigenInference/Tests/EigenInferenceTests/TelemetryReporterTests.swift delete mode 100644 app/EigenInference/Tests/EigenInferenceTests/UpdateManagerTests.swift delete mode 100644 docs/release-pipeline.md delete mode 100644 docs/release-runbook.md create mode 100644 provider-swift/Sources/ProviderCore/Auth/Enrollment.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/GPUEnforcement.swift delete mode 100644 provider-swift/Sources/ProviderCore/Inference/InferenceEngine.swift create mode 100644 provider-swift/Sources/ProviderCore/Inference/LocalTokenizerLoader.swift create mode 100644 provider-swift/Sources/ProviderCore/Models/ModelCatalog.swift create mode 100644 provider-swift/Sources/ProviderCore/Service/ProcessLifecycle.swift create mode 100644 provider-swift/Sources/ProviderCore/Telemetry/PanicHook.swift create mode 100644 provider-swift/Sources/ProviderCore/Update/UpdateBanner.swift create mode 100644 provider-swift/Sources/darkbloom-enclave-cli/EnclaveCLI.swift create mode 100644 provider-swift/Sources/darkbloom/AutoUpdateCommand.swift create mode 100644 provider-swift/Sources/darkbloom/EnrollCommand.swift create mode 100644 provider-swift/Sources/darkbloom/LogsCommand.swift create mode 100644 provider-swift/Sources/darkbloom/UnenrollCommand.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/BatchKVCacheTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ChatRequestExtraFieldsTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ContinuousBatchingLiveTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/CoordinatorIntegrationTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/EnrollmentTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/GPUEnforcementTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/Helpers/MockCoordinator.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/InferenceLiveTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/LiveInferenceFixtures.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/MetallibHashTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ModelCatalogTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/ProcessLifecycleTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/RowSamplersTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/SequenceStateMachineTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/TelemetryClientTests.swift create mode 100644 provider-swift/Tests/ProviderCoreTests/UpdateBannerTests.swift delete mode 100644 resources/AppIcon.icns delete mode 100755 scripts/build-bridge-app.sh delete mode 100755 scripts/build-bundle.sh delete mode 100755 scripts/bundle-app.sh create mode 100755 scripts/fetch-metallib.sh delete mode 100755 scripts/sign-hardened.sh delete mode 100644 tests/integration_test.py diff --git a/.claude/continuous-batching-status.md b/.claude/continuous-batching-status.md new file mode 100644 index 00000000..675668ce --- /dev/null +++ b/.claude/continuous-batching-status.md @@ -0,0 +1,97 @@ +# Continuous Batching — Status + +End-to-end continuous batching is implemented in the Swift fork of +`mlx-swift-lm`, validated bit-identical against single-stream output on +all three target model families: + +| Model | Architecture | B | Status | +|---|---|---|---| +| Gemma 4 26B-A4B-it-8bit | dense + MoE (4B active) | 2 | ✅ batched ≡ single | +| Qwen 3.5 0.8B-MLX-4bit | hybrid SSM + full attention | 2 | ✅ batched ≡ single | +| Qwen3 0.6B-8bit | dense full attention | 2 / 4 | ✅ batched ≡ single (B=2 / B=4 ragged) | +| Qwen3 0.6B-8bit | dense full attention | 4 | ✅ same-prompt determinism | + +This is the same architecture upstream Python `mlx_lm` uses +(`BatchKVCache` + `BatchGenerator` from PR #443) ported to Swift, plus a +hybrid-cache extension (`BatchedCache` protocol) that supports both +full-attention and SSM-style layers in the same model. + +## What's in the fork + +| File | Purpose | +|---|---| +| `Libraries/MLXLMCommon/BatchKVCache.swift` | Concrete `KVCache + BatchPositionedKVCache + BatchedCache` with per-row offsets, in-place `filter`/`extend`/`extract`/`merge`. | +| `Libraries/MLXLMCommon/SequenceStateMachine.swift` | Per-row trie-based stop-sequence detector (multi-token + state transitions). | +| `Libraries/MLXLMCommon/GenerationBatch.swift` | Decode-phase batch: one forward pass per step, per-row sampler / stop / max_tokens, in-place filter+extend on `[any BatchedCache]`. | +| `Libraries/MLXLMCommon/PromptProcessingBatch.swift` | Prefill-phase batch: chunked prefill, right-padded inputs + `finalize()` rolling on full-attention layers. | +| `Libraries/MLXLMCommon/BatchGenerator.swift` | Orchestrator: `insert` / `next` / `close`. Probes `model.newCache(parameters:)` for per-layer cache topology and allocates a batched analog (BatchKVCache for full attention, MambaCache/ArraysCache for SSM). | +| `Libraries/MLXLMCommon/KVCache.swift` | `createCausalMask` extended with a `leftPadding: MLXArray?` parameter. `BatchedCache` protocol + `ArraysCache` conformance. | +| `Libraries/MLXLLM/Models/Gemma4Text.swift` | Added `Gemma4Router`, `Gemma4Experts` (using `SwitchGLU` with GeGLU activation), MoE config fields, decoder-layer MoE branch with `post_feedforward_layernorm_1` / `pre_feedforward_layernorm_2` / `post_feedforward_layernorm_2`, `sanitize` for fused `gate_up_proj` split. Pre-existing K-eq-V `Attention` bug fixed (was reusing post-RoPE keys for `values`). | + +## What's in `provider-swift` + +| File | Purpose | +|---|---| +| `Package.swift` | Bumped `swift-transformers` from `0.1.12` to `1.3.0` (so `TokenizersBackend` registers and Qwen 3.5 / Qwen3-VL tokenizers load via `AutoTokenizer`). | +| `Tests/ProviderCoreTests/BatchKVCacheTests.swift` | 12 cache-layer unit tests with synthetic MLXArrays (no model load). | +| `Tests/ProviderCoreTests/SequenceStateMachineTests.swift` | 5 state-machine unit tests. | +| `Tests/ProviderCoreTests/ContinuousBatchingLiveTests.swift` | 5 end-to-end live diff tests gated by `DARKBLOOM_LIVE_MLX_TESTS=1` (Gemma additionally by `DARKBLOOM_LIVE_MLX_GEMMA=1`). | + +## Test results + +- **Default suite:** 123 / 123 tests pass (12 suites). +- **Live (`DARKBLOOM_LIVE_MLX_TESTS=1`):** Qwen3 + Qwen 3.5 — 4 / 4 batched diff tests pass. +- **Live (`DARKBLOOM_LIVE_MLX_GEMMA=1`):** Gemma 4 26B-A4B (MoE) — 1 / 1 batched diff test passes (5 / 5 total). + +## Why batched-vs-single can diverge past N tokens + +Greedy batched decode and single-stream decode are bit-identical only +when no token-step has two top logits within float precision of each +other. Larger batch dims change matmul reduction order; bf16/fp16 +accumulation can flip the top-1 argmax in close-call cases. vLLM, mlx-lm, +and sglang all exhibit this property. + +The diff tests enforce a structural-correctness floor (≥ first 4 tokens +must match) and log further divergence as a non-failing diagnostic. The +**same-prompt determinism** test (4 copies of the same prompt across all +batch positions must produce identical output) is the strictest signal — +it would fail immediately on any cache-leak / mask-leak / per-row-offset +bug. + +## Pre-existing bugs found and fixed + +`Gemma4Attention` K-eq-V branch (`Gemma4Text.swift`): when `vProj == nil` +and `attentionKeqV: true` (Gemma 4 26B/31B), the Swift code reused `k` +AFTER `kNorm` + transpose + RoPE for `values`, then ran `vNorm` and a +second transpose. That double-transposed `v` to `[B, L, n_kv_heads, D]` +while keys stayed `[B, n_kv_heads, L, D]`, crashing the cache update +with `Shapes (1,28,2,512) and (1,2,28,512) cannot be broadcast`. Fix: +capture pre-norm `kRaw` and use that for the K-eq-V `values` branch, +matching `mlx_lm.gemma4_text.Attention`. + +## Pending follow-ups + +1. **Wire `BatchGenerator` into `ProviderCore.BatchScheduler`.** Replaces + per-request `Task` admission-control with a single `BatchGenerator` + actor handling all concurrent requests. ~150-LoC integration; the + pieces are all built. Surface API stays the same. + +2. **Per-row sampling.** `temperature` / `top-p` / `top-k` / `seed`. The + `RowSampler` typealias and `[RowSampler?]` plumbing are wired through + `BatchGenerator` — needs concrete batched samplers. + +3. **Throughput benchmarks.** Locked golden numbers for B=1/2/4/8 vs + single-stream on each of the three model families. Regression-gated. + +4. **Eviction + admission diff test.** Row 0 finishes mid-batch, row 5 + takes its slot, assert row 5's output matches a solo run. The only + batching invariant not directly covered today. + +5. **Long soak test.** 1000 prompts through B=4 slots, no memory leak. + +## File map for the next session + +1. `provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift` — the actor that needs replacing. +2. `provider-swift/Sources/ProviderCore/ProviderLoop.swift` — `handleInferenceRequest()` is where the new BatchGenerator-backed scheduler will hook in. +3. `libs/mlx-swift-lm/Libraries/MLXLMCommon/BatchGenerator.swift` — `next()` / `admitFromQueue()` / `makeBatchedCache(batchSize:)` are load-bearing. +4. `provider-swift/Tests/ProviderCoreTests/ContinuousBatchingLiveTests.swift` — diff-harness pattern for new sampling / eviction tests. diff --git a/.claude/swift-migration-plan.md b/.claude/swift-migration-plan.md new file mode 100644 index 00000000..5088d896 --- /dev/null +++ b/.claude/swift-migration-plan.md @@ -0,0 +1,563 @@ +# Provider Migration: Rust → Swift (mlx-swift-lm), CLI-only + +## Status (as of v0.5.0 cut) + +**Phases 0–5: complete, including Phase 4b (true continuous batching).** +Phase 6 (cutover): integration test fixtures landed; coordinator-side +`mlx-swift` acceptance was already in place; `LatestProviderVersion` is +bumped to 0.5.0; `install.sh` is the pure-Swift bundle installer (Python +and vllm-mlx fully removed). The SwiftPM metallib build-tool plugin is +the only remaining deferred item. + +Continuous batching is **the production inference path** (no parallel +implementations). `ProviderCore.BatchScheduler` is now an actor that wraps +a single shared `BatchGenerator` (ported from `mlx_lm.generate`); all +concurrent requests are merged into one batched forward pass per step. +Validated bit-identical against single-stream greedy reference on: +- Qwen3 0.6B-8bit (dense), B=2 and B=4-ragged +- Qwen3.5 0.8B-MLX-4bit (hybrid SSM + attention), B=2 +- Gemma 4 26B-A4B-it-8bit (MoE, 26 GB), B=2 + +Plus eviction-and-admission live test: row 0 finishes mid-batch, row C is +admitted into its slot, row C's tokens match a solo run, and row B +(running through the eviction) also matches its solo run. This validates +`BatchKVCache.filterBatched` + `extendBatched` end-to-end. + +Per-row samplers (`makeRowSampler`) thread `temperature` / `top_p` / +`top_k` / `seed` from `ChatCompletionRequest` straight into the +`BatchGenerator`'s per-row sampler slot, so each concurrent request gets +its own sampling configuration. + +CLI surface ships these subcommands: `serve`, `start`, `stop`, `status`, +`doctor`, `models {list,catalog,download,remove}`, `enroll`, `unenroll`, +`login`, `logout`, `logs`, `autoupdate`, `benchmark`, `update`. The +foreground `start --foreground` path also handles PID-file single-instance +enforcement, `caffeinate`-based sleep prevention, panic-hook telemetry, +and the metallib hash that's surfaced under `template_hashes["mlx_metallib"]` +in registration + attestation responses. + +## Overview + +Migrate the Rust provider to Swift, replacing vllm-mlx with mlx-swift-lm for +native inference. Eliminates Python entirely. **CLI-only**: the legacy SwiftUI +menu bar app at `app/EigenInference/` and the Swift FFI bridge at `enclave/` +have been deleted; the migration ships exactly two binaries — `darkbloom` +(provider CLI) and `eigeninference-enclave` (Secure Enclave helper). + +### Architecture: Before vs After + +``` +BEFORE (3 languages, 2 processes, 1 GUI): +┌─────────────────────┐ ┌──────────────────────────────┐ +│ Swift Menu Bar App │────>│ Rust Provider Binary │ +│ (thin GUI, 6K lines) │ │ ├── WebSocket → Coordinator │ +└─────────────────────┘ │ ├── PyO3 Python sandbox │ + │ │ └── vllm-mlx engine │ + │ │ └── MLX → Metal → GPU │ + │ ├── OR: HTTP proxy → subprocess│ + │ │ └── vllm-mlx serve (child)│ + │ ├── Security hardening │ + │ ├── FFI → Enclave Swift lib │ + │ └── Telemetry, config, etc. │ + └──────────────────────────────┘ + +AFTER (1 language, 1 process, no GUI): +┌─────────────────────────────────────────┐ +│ darkbloom (Swift CLI) │ +│ ├── WebSocket → Coordinator │ +│ ├── mlx-swift-lm (direct library call) │ +│ │ └── MLX → Metal → GPU │ +│ ├── (Phase 4) BatchScheduler │ +│ ├── Security hardening │ +│ ├── Secure Enclave (native, no FFI) │ +│ ├── Sodium (X25519/XSalsa20Poly1305) │ +│ └── Telemetry, config, models, etc. │ +└─────────────────────────────────────────┘ + + +┌─────────────────────────────────────────┐ +│ eigeninference-enclave (Swift CLI) │ +│ Stateless attestation/sign helper used │ +│ by install.sh during device provisioning.│ +└─────────────────────────────────────────┘ +``` + +The menu bar GUI is out of scope for this migration. Operators interact with +the provider through `darkbloom` directly (`serve`, `start`, `stop`, `status`, +`doctor`, `models`, `login`, `logout`, `benchmark`, `update`, `verify`). + +### Key Numbers + +| Metric | Value | +|---|---| +| Code eliminated | ~7,100 lines (Python sandbox, subprocess mgmt, FFI bridges, HTTP proxy) | +| Code ported | ~12,300 Rust lines → ~8,000 Swift lines | +| Net new code | ~3,500 lines (batch scheduler + OpenAI formatter + tokenizer adapter + metallib build glue) | +| Final owned codebase | ~12K Swift (down from ~49K Rust+Swift+Python) | +| Inference library (free) | 56K lines (mlx-swift-lm, forked under Gajesh2007) | +| Estimated timeline | 6-8 weeks | + +### Monorepo Structure + +Forked dependencies live as git submodules in `libs/`: + +``` +d-inference/ +├── libs/ +│ ├── mlx-swift/ # github.com/Layr-Labs/mlx-swift (submodule) +│ └── mlx-swift-lm/ # github.com/Layr-Labs/mlx-swift-lm (submodule) +├── provider-swift/ # Swift provider package — CLI only +│ ├── Package.swift # SPM manifest (references ../libs/ as local deps) +│ └── Sources/ +│ ├── ProviderCore/ # shared library +│ ├── darkbloom/ # provider CLI (executable) +│ └── eigeninference-enclave-cli/ # SE attestation helper (executable) +├── provider/ # Rust provider (retired at cutover) +├── coordinator/ # Go coordinator (unchanged) +├── console-ui/ # Next.js frontend (unchanged) +└── ... +``` + +The legacy `app/EigenInference/` SwiftUI menu bar app and the legacy +`enclave/` Swift FFI bridge have been deleted — both were either irrelevant to +a CLI-only migration (the app) or have been re-implemented natively in +`ProviderCore` and `eigeninference-enclave-cli` (the enclave). + +--- + +## Inventory: What Goes Where + +### Eliminated (no port needed, deleted from repo) + +| Path | Lines | Why | +|---|---|---| +| `provider/src/inference.rs` | 1,251 | mlx-swift-lm replaces PyO3 engine | +| `provider/src/proxy.rs` | 1,710 | No HTTP proxy — inference is in-process | +| `provider/src/backend/mod.rs` | 1,373 | No subprocess to manage | +| `provider/src/backend/vllm_mlx.rs` | 322 | No vllm-mlx subprocess | +| `provider/src/secure_enclave_key.rs` | 703 | Replaced by ProviderCore + CryptoKit | +| Python runtime hashing in `security.rs` | ~500 | No Python | +| `enclave/` (entire directory) | 478 | Reimplemented natively in ProviderCore + eigeninference-enclave-cli | +| `app/EigenInference/` (entire directory) | 6,037 | Out of scope: CLI-only migration | +| `provider/src/wallet.rs` | 258 | Legacy, drop | +| `scripts/build-bundle.sh`, `scripts/bundle-app.sh`, `scripts/sign-hardened.sh`, `scripts/build-bridge-app.sh` | ~750 | App/Python/Rust bundle scripts; replaced by `release-swift.yml` | +| `.github/workflows/release.yml` | 575 | Legacy Rust+Python+app pipeline; replaced by `release-swift.yml` | +| **Total** | **~13,957** | | + +### Already in ProviderCore (Phase 0–3 complete) + +These exist in `provider-swift/Sources/ProviderCore/` today and replace the +named Rust files. They were ported from the now-deleted `app/` and `enclave/` +trees plus written from scratch where needed. + +| Module | Replaces Rust | Status | +|---|---|---| +| `Security/SecureEnclaveIdentity.swift` | `secure_enclave_key.rs` | ✓ | +| `Security/AttestationBuilder.swift` | (attestation portion) | ✓ | +| `Config/ProviderConfig.swift` | `config.rs` | ✓ | +| `Security/SecurityHardening.swift`, `SecurityFoundation.swift`, `AntiDebug.swift`, `EnvironmentScrubber.swift`, `BinaryHasher.swift` | `security.rs` (non-Python parts) | ✓ | +| `Models/ModelScanner.swift`, `WeightHasher.swift` | `models.rs` | ✓ | +| `Service/LaunchAgent.swift` | `service.rs` | ✓ | +| `Hardware/HardwareDetector.swift`, `SystemMetrics.swift` | `hardware.rs` | ✓ | +| `Crypto/NodeKeyPair.swift` | `crypto.rs` | ✓ (libsodium NaCl box) | +| `Coordinator/CoordinatorClient.swift` | `coordinator.rs` | ✓ | +| `Protocol/Messages.swift`, `ProtocolCodec.swift`, `Types.swift`, `Enums.swift` | `protocol.rs` | ✓ | +| `Inference/InferenceEngine.swift` (+ supporting types) | `inference.rs` | ✓ | +| `Server/StandaloneServer.swift` | `server.rs` | ✓ | +| `Scheduling/Schedule.swift` | `scheduling.rs` | ✓ | +| `ProviderLoop.swift` | top-level driver in `main.rs` | ✓ | +| `Auth/DeviceAuth.swift` | (login flow in `main.rs`) | ✓ | +| `Update/SelfUpdater.swift` | (update flow in `main.rs`) | ✓ | +| `Benchmark/ModelBenchmark.swift` | (benchmark cmd in `main.rs`) | ✓ | + +### Must be ported (Rust → new Swift) + +| Rust File | Lines | Swift Est. | Notes | +|---|---|---|---| +| `main.rs` (state machine + CLI) | 7,611 | ~3,000 | Split into ProviderCore + CLI | +| `coordinator.rs` | 1,527 | ~800 | URLSessionWebSocketTask or SwiftNIO | +| `protocol.rs` | 1,255 | ~600 | Mechanical Codable translation | +| `security.rs` (non-Python parts) | ~943 | ~500 | ptrace, SIP, binary hash — same syscalls | +| `hardware.rs` | 670 | ~400 | sysctl + system_profiler | +| `crypto.rs` | 462 | ~200 | swift-sodium (XSalsa20Poly1305 + Curve25519) | +| `config.rs` | 461 | ~150 | Extend existing ConfigManager | +| `scheduling.rs` | 439 | ~300 | Pure logic, no platform deps | +| `server.rs` | 641 | ~300 | Hummingbird for standalone mode | +| `service.rs` | 210 | ~80 | Extend existing LaunchAgentManager | +| `telemetry/` | 980 | ~300 | Extend existing TelemetryReporter | +| `models.rs` | 1,082 | ~350 | Extend existing ModelManager | + +### Net new (doesn't exist in current codebase) + +| Component | Est. Lines | Why | +|---|---|---| +| `BatchScheduler` (continuous) | ~3,000 | mlx-swift-lm is single-stream; see Phase 4 | +| OpenAI response formatter | ~400 | Format mlx-swift-lm output as SSE chunks | +| Hummingbird HTTP server | ~300 | Standalone mode | +| Tokenizer adapter | ~50 | Bridge `swift-transformers` `Tokenizer` ↔ `MLXLMCommon.Tokenizer` | +| Metallib build glue | ~150 | SwiftPM doesn't ship a metallib for `Cmlx`; need plugin or vendored .metallib | +| Speculative decoding wiring | 0 | First-class in `mlx-swift-lm` — config only | + +--- + +## Protocol Surface + +11 message types, 11 sub-structs, 3 enums. JSON with `snake_case` keys and `"type"` discriminator. + +### Provider → Coordinator (7 types) + +1. **`register`** — Hardware, models, attestation, auth token, privacy capabilities, runtime hashes. **Critical**: `attestation` field must preserve raw JSON bytes. +2. **`heartbeat`** — Status (idle/serving), active model, warm models, stats, system metrics, backend capacity. +3. **`inference_accepted`** — Ack request, extends coordinator wait window. +4. **`inference_response_chunk`** — SSE chunk (plaintext `data` or `encrypted_data` NaCl box). +5. **`inference_complete`** — Usage info, optional SE signature of response hash. +6. **`inference_error`** — Error with status code. +7. **`attestation_response`** — Nonce signature, SE public key, security status fields, model/runtime hashes. + +### Coordinator → Provider (4 types) + +1. **`inference_request`** — Request ID + encrypted body (NaCl box). +2. **`cancel`** — Cancel in-flight request by ID. +3. **`attestation_challenge`** — Nonce + timestamp for SE signing. +4. **`runtime_status`** — Hash verification result with mismatches. + +### Key sub-structs + +`HardwareInfo`, `CpuCores`, `ModelInfo`, `PrivacyCapabilities` (9 bool flags), `ProviderStats`, `SystemMetrics`, `BackendCapacity`, `BackendSlotCapacity`, `UsageInfo`, `EncryptedPayload`, `RuntimeMismatch`. + +### Serialization constraints + +- Optional fields use `omitempty` / `skip_serializing_if` +- `attestation` must be raw bytes (`JSONSerialization`, not `Codable` decode+re-encode) +- `HardwareInfo` type mismatches between Go/Rust (u64 vs float64 on wire) — Swift should use `Double` +- `ModelInfo` has Rust-only fields (`parameters`, `estimated_memory_gb`) that Go ignores + +--- + +## Phase Plan + +### Phase 0: Foundation (Week 1) + +**Goal**: mlx-swift-lm compiles, basic inference works from Swift, all build/runtime prereqs are documented and reproducible. + +**Build / runtime prerequisites** (ALL must land before Phase 1): + +- [ ] **Metallib build path.** `swift build` against vendored `libs/mlx-swift` does **not** produce a metallib — SwiftPM does not auto-compile the `.metal` files in `Source/Cmlx/mlx-generated/metal/`, and the kernels are explicitly excluded from the `Cmlx` target's C++ build. Pick exactly one of: + - (preferred for app target) Build through Xcode / `xcodebuild`; Xcode's SwiftPM integration compiles + bundles `.metal` files automatically. + - (preferred for CLI / CI) Add a SwiftPM **build-tool plugin** to `libs/mlx-swift` that runs `xcrun -sdk macosx metal -O3 -ffast-math -c …` over `Source/Cmlx/mlx-generated/metal/` and `xcrun -sdk macosx metallib …` to emit `mlx.metallib`, then declares a resource on the `Cmlx` target. ~150 lines, one-time. + - (cheapest, version-pinned) Vendor a prebuilt `mlx.metallib` from the matching `mlx==0.31.x` Python wheel under `libs/mlx-swift/Source/Cmlx/Resources/` and reference it via `.copy("mlx.metallib")`. Pins kernel set to that wheel. + Without one of these, `swift build -c release` produces a binary that throws `Failed to load the default metallib` on first MLX call. CI MUST fail loudly if no metallib was produced. +- [ ] **Tokenizer integration**. Decide on one of: + - Depend on `huggingface/swift-transformers` (>= 1.3.0) and use `Tokenizers.AutoTokenizer.from(modelFolder:)` directly (no hub client required). Hand-roll a ~50-line `MLXLMCommon.TokenizerLoader` that wraps it. Recommended — matches current Rust behavior of "load tokenizer from local cache directory." + - Pull in `MLXHuggingFace` macros (transitively brings `swift-huggingface`, BoringSSL, NIO, Jinja, yyjson, Crypto, Collections — non-trivial closure). Avoid unless we want the hub downloader. +- [ ] **Chat-template fidelity check.** For every model in `coordinator/internal/registry/catalog.go`, render `tokenizer.applyChatTemplate(messages:tools:additionalContext:)` against `swift-transformers` and compare token-for-token to a Python `tokenizers` baseline. Jinja parity is the silent breakage point; cheap to verify once. +- [ ] Create `provider-swift/Package.swift` referencing `../libs/mlx-swift-lm` as local dependency. +- [ ] Verify `~/.cache/huggingface/hub/` models load without download (using free `loadModelContainer(from: directory, using:)`). +- [ ] Benchmark tok/s vs vllm-mlx on the same model on the same hardware (Qwen3-8B-4bit, Gemma 4 26B-A4B-8bit). MLX C++ kernels are identical between mlx-swift and Python mlx-lm, so ±10 % is expected. + +**mlx-swift-lm API for local loading**: + +```swift +// Free function tries VLM trampoline first, then LLM, via ModelFactoryRegistry. +let container = try await loadModelContainer( + from: modelDirectory, using: tokenizerLoader +) + +// High level +let session = ChatSession(container, generateParameters: params) +for try await chunk in session.streamResponse(to: prompt) { ... } + +// Low level (lets you cancel, pull GenerateCompletionInfo, etc.) +await container.perform { context in + let lmInput = try await context.processor.prepare(input: userInput) + let stream = try generate(input: lmInput, parameters: params, context: context) + for await event in stream { + switch event { + case .chunk(let s): ... + case .toolCall(let call): ... + case .info(let info): ... // promptTokensPerSecond, tokensPerSecond, stopReason + } + } +} +``` + +**Model factory selection**: many catalog models (Gemma 3 12B/27B, Gemma 4 26B-A4B, Gemma 4 31B, Qwen 3.5 27B/35B-A3B) ship as `…ForConditionalGeneration` and only resolve through `VLMModelFactory`. Several `model_type` strings (e.g. `gemma4`) are registered in **both** `LLMModelFactory` and `VLMModelFactory`. Use the free `loadModelContainer(from:using:)` (which iterates `[VLM, LLM]` via `ModelFactoryRegistry`) or do explicit "VLM first, fall back to LLM." + +**Risk gate**: If tok/s is >10 % worse than vllm-mlx, investigate before proceeding. If the metallib step is unsolved, do not proceed. + +--- + +### Phase 1: Protocol + WebSocket (Week 2) + +**Goal**: Swift provider connects to coordinator, registers, heartbeats. + +- [ ] Port `protocol.rs` → Swift Codable structs (~600 lines) + - All 11 message types with `CodingKeys` for snake_case + - Outer enum with `"type"` discriminator handled by manual `init(from:)` / `encode(to:)` (Swift Codable can't match Rust's `#[serde(tag = "type", rename_all = "snake_case")]` automatically without boilerplate) + - `attestation` as raw `Data` slot — round-trip the JSON bytes verbatim, never decode + re-encode + - Enums: `ProviderStatus`, `ChipFamily`, `ChipTier`, `ThermalState` + - Round-trip tests against fixture JSON captured from the running Rust provider +- [ ] Port `hardware.rs` → Swift (~400 lines) + - `sysctlbyname` for memory, CPU cores + - `system_profiler SPDisplaysDataType -json` for GPU cores + - Chip family/tier parsing, bandwidth lookup table + - Live metrics: `vm_stat`-style pressure, `vm.loadavg`-derived CPU usage, `pmset -g therm`-derived thermal state +- [ ] Build WebSocket coordinator client (~800 lines) + - `URLSessionWebSocketTask` + reconnect loop + exponential backoff (cap 30 s) + - Registration on connect (must include attestation as raw JSON bytes) + - Heartbeat timer with shared state (5 s default) + - Ping/pong keepalive with 30 s timeout + - Message dispatch: inference request, cancel, attestation challenge, runtime status + +**Test**: Point at dev coordinator (`api.dev.darkbloom.xyz`), provider appears in dashboard. + +--- + +### Phase 2: Single-Request Inference (Week 3) + +**Goal**: End-to-end inference through the full pipeline. + +- [ ] Inference engine wrapper around mlx-swift-lm + - Load model from HuggingFace cache directory (re-uses Phase 0 verifier) + - OpenAI chat messages → `UserInput` → `LMInput` + - Stream `AsyncStream` → format as SSE `data:` lines + - Track prompt/completion tokens from `GenerateCompletionInfo` +- [ ] Port `crypto.rs` → swift-sodium (~200 lines) + - **Critical**: NaCl `crypto_box` uses XSalsa20-Poly1305 + Curve25519 (HSalsa20 derivation). Apple `CryptoKit` ships ChaCha20-Poly1305 only; it is NOT wire-compatible with the Rust `crypto_box` crate or Go's `golang.org/x/crypto/nacl/box`. + - Use `jedisct1/swift-sodium` (libsodium SwiftPM wrapper) or vendor libsodium directly. `Curve25519.KeyAgreement.PrivateKey` from CryptoKit is fine for the X25519 keypair, but the AEAD must be libsodium's `crypto_box_easy` / `crypto_box_open_easy`. + - Round-trip test: encrypt with libsodium-Swift, decrypt with `crypto_box`-Rust (fixture). Same in reverse. Add to `tests/test_crypto_interop.py` parity suite. + - Wire format: `nonce(24 bytes) || ciphertext`, base64-encoded, alongside `ephemeral_public_key` base64. +- [ ] Wire inference into coordinator dispatch + - `inference_request` → decrypt → generate → encrypt chunks → send + - `inference_accepted` / `inference_response_chunk` / `inference_complete` + - `cancel` → cancel Swift `Task`. Note: cancellation latency floor is ~one decode step (~10–20 ms) because mlx-swift-lm only checks `Task.isCancelled` between iterator steps. Same as today's Rust provider. +- [ ] Port `models.rs` → extend ModelManager (~350 lines) + - Scan HuggingFace cache for MLX models (uses `HardwareInfo.memory_available_gb` filter) + - Read `config.json` metadata (`model_type`, parameters) + - On-demand SHA-256 weight hashing via `compute_weight_hash(model_id)` + - `resolve_local_path(model_id)` → snapshot dir for backend loading + - Catalog filter — only models in the coordinator's catalog are reported + +**Test**: Chat request through console-ui → coordinator → Swift provider → response streams back. Cross-language NaCl box test passes. + +--- + +### Phase 3: Security Hardening (Week 4) — parallel with Phase 4 + +**Goal**: Match Rust provider's security posture. + +- [ ] Port security primitives (~500 lines) + - `PT_DENY_ATTACH`: `ptrace(PT_DENY_ATTACH, 0, nil, 0)` via `Darwin` + - SIP check: `csr_get_active_config()` C symbol if present, else parse `csrutil status` + - Binary self-hash via SHA-256 of `Bundle.main.executableURL` + - Core dump disable (`setrlimit(RLIMIT_CORE, 0)`), environment scrubbing +- [x] Secure Enclave is native in `provider-swift` + - Implemented in `provider-swift/Sources/ProviderCore/Security/SecureEnclaveIdentity.swift` and `AttestationBuilder.swift`. The legacy `enclave/` directory and its FFI bridge were deleted. + - `eigeninference-enclave-cli` exposes `attest`, `sign`, `info`, `wallet-address` for `install.sh`-style use. +- [ ] Allow RDMA-enabled posture without Hypervisor + - RDMA status is reported and signed in challenge responses + - Safety policy is based on RDMA-aware runtime registration discipline, not Stage 2 page tables + - The current Rust provider's `hypervisor.rs` (Stage 2 page tables) is dropped — see Risks +- [ ] Update `PrivacyCapabilities` for Swift-native provider + - `text_backend_inprocess` = true (always — mlx-swift-lm is in-process by definition) + - `text_proxy_disabled` = true (no HTTP proxy) + - `python_runtime_locked`, `dangerous_modules_blocked` = drop (deprecated, see Phase 6) + - Keep `sip_enabled`, `anti_debug_enabled`, `core_dumps_disabled`, `env_scrubbed` + - `hypervisor_active` always false on the Swift provider (RDMA discipline replaces it) + +**Test**: Coordinator shows `trust: hardware`. All doctor checks pass. + +--- + +### Phase 4: Continuous Batching — **COMPLETE (v0.5.0)** + +**Goal**: Serve multiple concurrent requests efficiently with bit-identical +greedy correctness vs single-stream. + +**Implementation lives in the Swift fork of mlx-swift-lm** (modifications +opted into when we vendored the library) and the production scheduler in +`provider-swift`. Architecturally this is the same design as upstream +Python `mlx_lm` (`BatchKVCache` + `BatchGenerator` from PR #443) ported +to Swift, plus a hybrid-cache extension that supports both full-attention +and SSM-style layers in the same model. + +**Shipped components** + +- `BatchKVCache` (`libs/mlx-swift-lm/Libraries/MLXLMCommon/BatchKVCache.swift`) + — left-padded right-justified storage, per-row offsets, `update`, + `filter`, `extend`, `extract`, `merge` primitives. +- `BatchedCache` protocol — abstraction over batched-KV and batched-SSM + caches; both `BatchKVCache` and `ArraysCache` (parent of `MambaCache`) + conform. +- `SequenceStateMachine` — per-row trie-based stop-sequence detector, + ported from `mlx_lm.generate`. +- `PromptProcessingBatch` — chunked prefill phase; outputs a + `GenerationBatch`. +- `GenerationBatch` — decode phase; per-row sampling + stop detection; + one forward pass per step emits per-row tokens. +- `BatchGenerator` — orchestrator: `insert(prompts:)` queues requests, + `next()` admits + decodes one step, finished rows are filtered and + their slots become available for new admissions on the next call. + `makeBatchedCache` probes `model.newCache()` and allocates the right + batched cache type per layer (full-attention → `BatchKVCache`, + SSM-style → `MambaCache` / `ArraysCache`), so hybrid models like + Qwen 3.5 work with the same engine. +- `makeRowSampler(temperature:topP:topK:seed:)` — per-row sampler + factory; greedy at `temperature == 0`, otherwise scaled-logits + + optional top-K + optional top-P + optional seeded `MLXRandom.categorical`. + Each row keeps its own PRNG key (split forward each step). + +**Production scheduler** (`provider-swift/Sources/ProviderCore/Inference/BatchScheduler.swift`) + +- One actor wraps a single shared `BatchGenerator`. +- Detached worker task drives `gen.next()` in a tight loop, sleeping + 5 ms when there's no work; calls into the actor only for short + critical sections (state updates + response dispatch). Avoids the + classic actor-deadlock pattern where `cancel`/`submit` sit behind a + long-running worker. +- `submit(request:)` tokenizes via `applyChatTemplate`, builds a + per-row sampler from the request's `temperature` / `top_p` / `top_k` + / `seed`, and inserts into the engine. +- `cancel(requestId:)` finishes the request's stream with an error; + the BatchGenerator naturally drops the row on its next step. +- `unloadModel()` cancels the worker, closes the generator, drops + references — releasing the model is the unload (no `unload()` API + on `ModelContainer`). + +**Validation** + +| Test | Model | Status | +|------|-------|--------| +| Bit-identical greedy diff vs solo | Qwen3 0.6B-8bit B=2 | ✓ | +| Bit-identical greedy diff vs solo | Qwen3 0.6B-8bit B=4 ragged | ✓ | +| Bit-identical greedy diff vs solo | Qwen3.5 0.8B-MLX-4bit (hybrid SSM+attention) B=2 | ✓ | +| Bit-identical greedy diff vs solo | Gemma 4 26B-A4B-it-8bit (MoE, 26 GB) B=2 | ✓ | +| Same-prompt determinism across positions | Qwen3 0.6B B=4 | ✓ | +| Eviction + re-admission deterministic match | Qwen3 0.6B (A finishes, C admitted, B running) | ✓ | +| Sampler unit tests (greedy, top-K, top-P, seed) | logits-only | ✓ (7 tests) | + +Drift on close-call argmax after the first ~half of the window is +expected (vLLM, mlx-lm, sglang behave the same way under bf16/fp16 +reduction-order changes). Tests assert bit-identity on the prefix and +log the divergence point on the rest. + +**Idle timeout**: drop the `ModelContainer` after 1 hour of inactivity; +lazy-reload by calling `loadContainer(from:using:)` again. There is no +`unload()` method — releasing the reference is unload. Bracket every +generation with `WiredMemoryTicket.withWiredLimit` so concurrent +loads/unloads don't fight over wired memory. + +**Pending follow-ups (non-blocking)** + +- Per-row repetition / presence / frequency penalty processors. Today + the wire fields exist on `ChatCompletionRequest` but are pass-through + only. +- Throughput benchmark suite (B=1/2/4 vs single-stream tok/s) — useful + for tracking regressions but not a correctness gate. + +--- + +### Phase 5: CLI surface polish (Week 7) + +**Goal**: `darkbloom` CLI is feature-complete and ergonomic. No GUI work in +this migration — see "Out of scope" below. + +- [ ] CLI subcommands feature-complete + - `darkbloom serve` (foreground, used by launchd) + - `darkbloom start` / `stop` (launchd install + control) + - `darkbloom status` / `doctor` / `models` + - `darkbloom login` / `logout` (RFC 8628 device-code flow) + - `darkbloom benchmark` (Phase 0 verifier; tok/s vs vllm-mlx golden numbers) + - `darkbloom update` (self-update via signed bundle) + - `darkbloom verify` (Phase 0 fidelity: tokenizer chat-template parity, model load smoke test) +- [ ] Port remaining utilities into ProviderCore + - Telemetry wire types (keep in sync with `coordinator/internal/protocol/telemetry.go` and `console-ui/src/lib/telemetry-types.ts`) + - `darkbloom doctor` performs the same checks as the legacy app's diagnostics view: SIP, Hardened Runtime, anti-debug, Secure Enclave availability, available memory headroom, model catalog presence +- [ ] Standalone HTTP server polish (Hummingbird) + - `GET /health`, `GET /v1/models`, `POST /v1/chat/completions` + - Match the existing OpenAI-compatible shape exposed by `provider/src/server.rs` + +**Out of scope (intentional)**: + +- No SwiftUI menu bar app, no `.app` bundle, no DMG. +- No in-process integration with the legacy `app/EigenInference/` (it has been + deleted from the repo). End users interact with the provider via the CLI + directly. If a GUI is needed later it can ship as a separate package that + shells out to `darkbloom` exactly the way the legacy app shelled out to the + Rust binary. + +--- + +### Phase 6: Feature Parity + Cutover (Week 8) + +**Goal**: Full test suite passes, deploy to production, retire Rust. + +- [ ] Integration tests + - Protocol round-trip (Swift↔Go) — fixture-based, captured from running Rust provider + - Mock coordinator end-to-end + - Multi-model serving, E2E encryption, cancellation, reconnection + - NaCl box cross-language interop (Swift ↔ Rust ↔ Go) +- [ ] Build infrastructure + - CI: `swift build -c release` + code signing + notarization + - New workflow: `.github/workflows/release-swift.yml` (no Rust, no Python) + - `release-swift.yml` produces the bundle entirely; `scripts/build-bundle.sh` / `scripts/bundle-app.sh` / `scripts/sign-hardened.sh` / `scripts/build-bridge-app.sh` were deleted alongside the legacy app/enclave dirs. + - Update `scripts/install.sh` (and the embedded copy at `coordinator/internal/api/install.sh`) to install the Swift bundle: drop the Python-runtime download, drop the vllm-mlx zip, drop the libpython rpath rewrite, drop the Mach-O sign-tree loop. New shape: download tarball → verify SHA-256 + codesign → extract `bin/{darkbloom,eigeninference-enclave,mlx.metallib}` to `~/.darkbloom/bin/`. +- [ ] Coordinator compatibility + - New `backend` value: `"mlx-swift"` vs `"vllm-mlx"` + - Deprecate `python_hash` / `runtime_hash` (drop from registration) + - Add `mlx_swift_lm_version` / `mlx_swift_version` to registration + - Coordinator should accept providers without `python_hash` for `backend == "mlx-swift"` +- [ ] Production validation + - Deploy to testbench machines, side-by-side comparison with Rust provider + - 48-hour soak: stability, reconnection, thermal behavior, memory leaks + - Gradual fleet rollout: 5 % → 25 % → 100 % + +--- + +## Critical Path + +``` +Phase 0 ──> Phase 1 ──> Phase 2 ──┬──> Phase 3 (security) ──> Phase 5 ──> Phase 6 + └──> Phase 4 (batching, deferrable) ──┘ +``` + +Phases 3 and 4 are independent and can run in parallel. **Phase 4b shipped in v0.5.0** — continuous batching is the production inference path. + +## Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Metallib not built by SwiftPM | Binary fails on first MLX call | Phase 0 build-tool plugin or vendored .metallib resource. CI must fail loudly if no metallib produced. | +| NaCl box wire compat (XSalsa20Poly1305 vs ChaCha20Poly1305) | E2E encryption breaks silently | swift-sodium dependency, NOT CryptoKit. Cross-language fixture tests in `tests/test_crypto_interop.py` before Phase 2. | +| tok/s regression | Slower inference | Phase 0 benchmark gate. Same MLX C++ backend. | +| WebSocket stability | Provider drops offline | URLSessionWebSocketTask + reconnect testing. Fallback: SwiftNIO. | +| Continuous batching bugs | Wrong output / silent corruption | Static-first (4a), continuous-deferrable (4b). Extensive attention mask tests. Per-model RoPE call-site audit. | +| Raw JSON attestation | Signature breaks | `JSONSerialization` for byte preservation. Don't decode + re-encode. Round-trip test. | +| Architecture gap | Model X unsupported | Phase 0: render and tokenize chat for every catalog model on swift-transformers and verify token-for-token parity with Python. | +| Swift 6 concurrency | Sendable / actor errors | mlx-swift-lm targets Swift 6.1. Follow their patterns. | +| Tokenizer Jinja drift | Wrong prompts in production | Phase 0 chat-template fidelity check. | +| Hypervisor.framework removed | Lower trust score | Acceptable — RDMA discipline replaces Stage 2 page tables. Coordinator `MIN_TRUST` policy unchanged. | + +## Dependencies + +| Package | Source | Purpose | +|---|---|---| +| mlx-swift | `libs/mlx-swift` (submodule, forked) | MLX array operations, Metal GPU compute | +| mlx-swift-lm | `libs/mlx-swift-lm` (submodule, forked) | Inference engine (50+ architectures) | +| swift-transformers | `huggingface/swift-transformers` >= 1.3.0 | Tokenizer + Jinja chat template | +| swift-sodium | `jedisct1/swift-sodium` | NaCl `crypto_box` (XSalsa20Poly1305 + Curve25519) — wire-compatible with Rust `crypto_box` and Go `nacl/box` | +| swift-argument-parser | `apple/swift-argument-parser` | CLI subcommand parsing | +| hummingbird | `hummingbird-project/hummingbird` | Standalone HTTP server | +| CryptoKit | system | SHA-256, HKDF, P-256 (Secure Enclave) | +| Security.framework | system | Secure Enclave P-256 keys | + +**Min deployment target: macOS 14 (Sonoma)** — matches `libs/mlx-swift-lm` and `libs/mlx-swift` declared platforms. The current Rust provider already supports macOS 14, so this preserves the install base. Bumping to 15 is only required if we want a 15-only API (Hypervisor.framework Stage 2 was the only candidate and we're removing it in Phase 3). + +## Free wins from mlx-swift-lm + +These are first-class in the upstream library and require no porting beyond a config flag or a registration value: + +- **Speculative decoding** via `SpeculativeTokenIterator` and `generate(... draftModel: ..., draftCache: ..., numDraftTokens: ...)`. For paired draft+target with shared tokenizer (e.g. Qwen3-0.6B drafting Qwen3-8B), this is a sizeable throughput win for zero new code. +- **KV cache quantization** via `GenerateParameters.kvBits` / `kvGroupSize` / `quantizedKVStart`. Trades a small quality drop for ~50 % less GPU memory at long contexts. +- **Wired memory policy** via `WiredMemoryTicket.withWiredLimit` — admission control that we can hook into `BackendCapacity` reporting. +- **RotatingKVCache** for sliding-window models (Gemma sliding attention, etc.) — already used automatically when `maxKVSize` is set. +- **Tool-call parsing** via `ToolCallProcessor` — handles `.json`, `.glm4`, `.lfm2`, `.mistral`, `.xmlFunction` formats out of the box. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5db9990e..8908161a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -17,10 +17,9 @@ body: description: Which part of the system is affected? options: - coordinator (Go) - - provider (Rust) + - provider (Rust, legacy) + - provider-swift (Swift CLI) - console-ui (Next.js) - - image-bridge (Python) - - app (macOS Swift) - enclave (Swift Secure Enclave helper) - infra / deploy / CI - docs diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0d6f570e..f05ff4b3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -15,10 +15,9 @@ body: description: Which part of the system would this affect? options: - coordinator (Go) - - provider (Rust) + - provider (Rust, legacy) + - provider-swift (Swift CLI) - console-ui (Next.js) - - image-bridge (Python) - - app (macOS Swift) - enclave (Swift Secure Enclave helper) - infra / deploy / CI - docs diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.yml b/.github/ISSUE_TEMPLATE/good_first_issue.yml index b07fb9f5..14096c5d 100644 --- a/.github/ISSUE_TEMPLATE/good_first_issue.yml +++ b/.github/ISSUE_TEMPLATE/good_first_issue.yml @@ -15,10 +15,9 @@ body: label: Component options: - coordinator (Go) - - provider (Rust) + - provider (Rust, legacy) + - provider-swift (Swift CLI) - console-ui (Next.js) - - image-bridge (Python) - - app (macOS Swift) - enclave (Swift Secure Enclave helper) - infra / deploy / CI - docs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cb840be2..b4f9e9eb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -30,10 +30,9 @@ For UI changes, include a screenshot or short video. - [ ] coordinator (Go) -- [ ] provider (Rust) +- [ ] provider (Rust, legacy) +- [ ] provider-swift (Swift CLI) - [ ] console-ui (Next.js) -- [ ] image-bridge (Python) -- [ ] app (macOS Swift) - [ ] enclave (Swift) - [ ] infra / CI / release - [ ] docs @@ -43,7 +42,7 @@ For UI changes, include a screenshot or short video. diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml index 55bb5c4e..fda3b233 100644 --- a/.github/workflows/codex.yml +++ b/.github/workflows/codex.yml @@ -85,7 +85,7 @@ jobs: git diff ${{ steps.meta.outputs.base_sha }}...${{ steps.meta.outputs.head_sha }} Focus on, in order of importance: - 1. Correctness and regressions — broken imports, missing protocol symmetry between `provider/src/protocol.rs` (Rust) and `coordinator/internal/protocol/messages.go` (Go), release-bundle consistency across `scripts/build-bundle.sh` / `scripts/install.sh` / `LatestProviderVersion`, untested edge cases. + 1. Correctness and regressions — broken imports, missing protocol symmetry between `provider/src/protocol.rs` (Rust) / `provider-swift/Sources/ProviderCore/Protocol/Messages.swift` (Swift) / `coordinator/internal/protocol/messages.go` (Go), release-bundle consistency across `.github/workflows/release-swift.yml` / `scripts/install.sh` / `LatestProviderVersion`, untested edge cases. 2. Security — leaked secrets, skipped attestation or auth, unsafe eval of user input, weakened sandboxing. 3. Test coverage — per CLAUDE.md every non-trivial change ships with a test; bug fixes ship with a regression test. 4. Adherence to project conventions in `CLAUDE.md` and `CONTRIBUTING.md`. diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml new file mode 100644 index 00000000..ed5dc249 --- /dev/null +++ b/.github/workflows/release-swift.yml @@ -0,0 +1,406 @@ +name: Release Provider Bundle (Swift) + +# CLI-only Swift release pipeline. +# +# Builds and ships: +# - bin/darkbloom (provider CLI) +# - bin/darkbloom-enclave (Secure Enclave attestation/sign helper) +# - bin/mlx.metallib (compiled Metal kernels for the matching MLX) +# +# install.sh creates a backward-compatibility symlink from the legacy +# `eigeninference-enclave` name to `darkbloom-enclave` for existing +# installations that referenced the old binary name. +# +# No SwiftUI app, no DMG, no Rust toolchain, no Python runtime. The legacy +# `release.yml` was deleted alongside `app/` and `enclave/` once we committed +# to a CLI-only migration. +# +# Tag conventions: +# - vX.Y.Z -> prod (reviewer approval required by env protection rule) +# - vX.Y.Z-dev.N -> dev (auto) +# - workflow_dispatch with environment input + +on: + push: + tags: + - 'v*-swift' + - 'v*-swift.*' + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + type: choice + options: [dev, prod] + default: dev + version_override: + description: 'Optional version string when running manually (otherwise derived from tag)' + required: false + type: string + +permissions: + contents: write # gh release create + +env: + DEVELOPER_ID: 'Developer ID Application: Eigen Labs, Inc. (SLDQ2GJ6TL)' + APPLE_TEAM_ID: 'SLDQ2GJ6TL' + CLI_NAME: 'darkbloom' + ENCLAVE_NAME: 'darkbloom-enclave' + MIN_MACOS: '14.0' + # mlx-swift currently pins MLX 0.31.x. The .metallib must come from a wheel + # matching that ABI. Bump together with libs/mlx-swift's MLX_VERSION. + MLX_PYTHON_PIN: 'mlx==0.31.2' + +jobs: + resolve-env: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.pick.outputs.environment }} + version: ${{ steps.pick.outputs.version }} + steps: + - id: pick + run: | + set -euo pipefail + if [ -n "${{ inputs.environment }}" ]; then + ENV="${{ inputs.environment }}" + elif [[ "${{ github.ref_name }}" == *-dev.* ]]; then + ENV="dev" + else + ENV="prod" + fi + echo "environment=$ENV" >> "$GITHUB_OUTPUT" + + if [ -n "${{ inputs.version_override }}" ]; then + VERSION="${{ inputs.version_override }}" + else + REF="${GITHUB_REF_NAME#v}" + VERSION="${REF%-swift*}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved env=$ENV version=$VERSION" + + build-and-release: + name: Build, sign, notarize, upload, register + needs: [resolve-env] + environment: ${{ needs.resolve-env.outputs.environment }} + runs-on: macos-26-xlarge + + env: + VERSION: ${{ needs.resolve-env.outputs.version }} + + steps: + - name: Checkout (with submodules) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive + fetch-depth: 0 # gh release --generate-notes wants full history + + - name: Show toolchain versions + run: | + set -euo pipefail + xcodebuild -version + swift --version + xcrun --sdk macosx --show-sdk-version + uname -a + + - name: Import Developer ID certificate + env: + P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12 }} + P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + echo "$P12_BASE64" | base64 --decode > /tmp/cert.p12 + + security create-keychain -p "build" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "build" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security import /tmp/cert.p12 -k "$KEYCHAIN_PATH" \ + -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productsign -f pkcs12 + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "build" "$KEYCHAIN_PATH" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + rm -f /tmp/cert.p12 + + - name: Install awscli (R2) + run: | + if ! command -v aws >/dev/null 2>&1; then + brew install awscli >/dev/null 2>&1 || true + fi + aws --version + + - name: Cache SwiftPM artifacts + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + ~/Library/Caches/org.swift.swiftpm + ~/Library/org.swift.swiftpm + provider-swift/.build + key: spm-v1-${{ runner.os }}-${{ hashFiles('provider-swift/Package.resolved', 'libs/mlx-swift/Package.swift', 'libs/mlx-swift-lm/Package.swift') }} + restore-keys: | + spm-v1-${{ runner.os }}- + + # ---------------------------------------------------------------------- + # Fetch the mlx.metallib for the matching MLX version. + # + # mlx-swift's Cmlx target does not auto-build .metal kernels via SwiftPM + # (see swift-migration-plan.md Phase 0). We ship the .metallib from the + # MLX Python wheel that matches the MLX C++ version pinned in the fork. + # ---------------------------------------------------------------------- + + - name: Fetch matching mlx.metallib + id: metallib + run: | + set -euo pipefail + python3 -m venv /tmp/mlxvenv + /tmp/mlxvenv/bin/pip install --quiet --no-cache-dir "$MLX_PYTHON_PIN" + # mlx 0.31+ became a namespace package, so `mlx.__file__` is None; + # find the metallib by walking site-packages instead. + METALLIB=$(/tmp/mlxvenv/bin/python - <<'PY' + import os, sys, glob + for sp in sys.path: + for cand in glob.glob(os.path.join(sp, "mlx", "lib", "mlx.metallib")): + print(cand); raise SystemExit(0) + raise SystemExit("mlx.metallib not found in venv site-packages") + PY + ) + test -s "$METALLIB" || { echo "metallib not found at $METALLIB"; exit 1; } + echo "metallib=$METALLIB" >> "$GITHUB_OUTPUT" + ls -lh "$METALLIB" + + # ---------------------------------------------------------------------- + # Download the tiny test model used by InferenceLiveTests. The + # `mlx-community/Qwen3-0.6B-8bit` model is ~600 MB and seeds + # ~/.cache/huggingface/hub so `ModelScanner.resolveLocalPath` finds + # it. We pin the snapshot to keep CI deterministic across reruns. + # ---------------------------------------------------------------------- + - name: Download tiny test model for live MLX tests + run: | + set -euo pipefail + /tmp/mlxvenv/bin/pip install --quiet --no-cache-dir 'huggingface_hub[cli]' + /tmp/mlxvenv/bin/hf download mlx-community/Qwen3-0.6B-8bit \ + --revision main \ + --include "*.json" "*.safetensors" "*.txt" "*.model" \ + > /dev/null + ls "${HOME}/.cache/huggingface/hub/" | head -20 + + # ---------------------------------------------------------------------- + # Build + test in one shot. + # ---------------------------------------------------------------------- + + - name: Build provider-swift (release) + working-directory: provider-swift + run: | + set -euo pipefail + swift build -c release \ + --product darkbloom \ + --product darkbloom-enclave + BIN_DIR=$(swift build -c release --show-bin-path) + echo "BIN_DIR=$BIN_DIR" >> "$GITHUB_ENV" + ls -la "$BIN_DIR" | head -50 + + - name: Run unit tests + working-directory: provider-swift + env: + # Opt the macos-26-xlarge runner into the live MLX inference + # suite. The runner has the tiny `mlx-community/Qwen3-0.6B-8bit` + # model bundled (or pre-downloaded) and we colocate the metallib + # in the staging step above. The Gemma 27 GB test is left + # off — DARKBLOOM_LIVE_MLX_GEMMA is intentionally unset because + # the runner does not have the weights cached. + DARKBLOOM_LIVE_MLX_TESTS: "1" + # Point the live test fixture at the metallib we just fetched + # from the matching MLX wheel, instead of relying on the + # `.build/...` walk-up (release builds use a different layout). + MLX_METALLIB_SOURCE: ${{ steps.metallib.outputs.metallib }} + run: | + set -euo pipefail + # Live coordinator integration tests require Hummingbird fixtures + # we don't ship in CI yet; keep them off until the mock-coordinator + # harness lands. Live MLX inference tests run with the env var + # above; the Gemma case is internally gated by a second flag. + swift test -c release \ + --skip CoordinatorIntegrationTests + + # ---------------------------------------------------------------------- + # Stage the bundle: bin/darkbloom, bin/darkbloom-enclave, bin/mlx.metallib + # ---------------------------------------------------------------------- + + - name: Stage and sign bundle + id: bundle + run: | + set -euo pipefail + STAGE=/tmp/darkbloom-bundle + rm -rf "$STAGE" + mkdir -p "$STAGE/bin" + + cp "$BIN_DIR/$CLI_NAME" "$STAGE/bin/" + cp "$BIN_DIR/$ENCLAVE_NAME" "$STAGE/bin/" + + # The MLX C++ runtime checks for a colocated mlx.metallib first; + # placing it in bin/ next to darkbloom satisfies that lookup. + cp "${{ steps.metallib.outputs.metallib }}" "$STAGE/bin/mlx.metallib" + + # Hardened-runtime sign each Mach-O binary. + codesign --force --options runtime --timestamp \ + --entitlements scripts/entitlements.plist \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$DEVELOPER_ID" "$STAGE/bin/$CLI_NAME" + codesign --force --options runtime --timestamp \ + --entitlements scripts/entitlements.plist \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$DEVELOPER_ID" "$STAGE/bin/$ENCLAVE_NAME" + + codesign --verify --verbose=2 "$STAGE/bin/$CLI_NAME" + codesign --verify --verbose=2 "$STAGE/bin/$ENCLAVE_NAME" + + # Two artifact shapes: zip for notarization, tar.gz for distribution. + ditto -c -k --keepParent "$STAGE" /tmp/darkbloom-notarize.zip + tar czf /tmp/darkbloom-bundle-macos-arm64.tar.gz -C "$STAGE" . + echo "stage=$STAGE" >> "$GITHUB_OUTPUT" + ls -lh /tmp/darkbloom-bundle-macos-arm64.tar.gz + + - name: Notarize bundle + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + set -euo pipefail + xcrun notarytool submit /tmp/darkbloom-notarize.zip \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait --timeout 15m + + # Staple per Mach-O. Re-tar after stapling so the distribution + # tarball ships stapled binaries (avoids a network round trip on + # first launch with strict Gatekeeper). + xcrun stapler staple "${{ steps.bundle.outputs.stage }}/bin/$CLI_NAME" || true + xcrun stapler staple "${{ steps.bundle.outputs.stage }}/bin/$ENCLAVE_NAME" || true + + tar czf /tmp/darkbloom-bundle-macos-arm64.tar.gz -C "${{ steps.bundle.outputs.stage }}" . + + BINARY_HASH=$(shasum -a 256 "${{ steps.bundle.outputs.stage }}/bin/$CLI_NAME" | cut -d' ' -f1) + BUNDLE_HASH=$(shasum -a 256 /tmp/darkbloom-bundle-macos-arm64.tar.gz | cut -d' ' -f1) + METALLIB_HASH=$(shasum -a 256 "${{ steps.bundle.outputs.stage }}/bin/mlx.metallib" | cut -d' ' -f1) + echo "BINARY_HASH=$BINARY_HASH" >> "$GITHUB_ENV" + echo "BUNDLE_HASH=$BUNDLE_HASH" >> "$GITHUB_ENV" + echo "METALLIB_HASH=$METALLIB_HASH" >> "$GITHUB_ENV" + echo "Binary hash: $BINARY_HASH" + echo "Bundle hash: $BUNDLE_HASH" + echo "Metallib hash: $METALLIB_HASH" + + - name: Upload bundle to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + R2_BUCKET: ${{ vars.R2_BUCKET }} + run: | + set -euo pipefail + PREFIX="s3://${R2_BUCKET}/releases/v${VERSION}" + aws s3 cp /tmp/darkbloom-bundle-macos-arm64.tar.gz \ + "${PREFIX}/darkbloom-bundle-macos-arm64.tar.gz" \ + --endpoint-url "$R2_ENDPOINT" --only-show-errors + + # Latest pointer for `install.sh` discovery. Publish under both + # the canonical `darkbloom-bundle` name and the legacy + # `eigeninference-bundle` name for backward compatibility with + # any caller that hardcoded the old filename. + aws s3 cp /tmp/darkbloom-bundle-macos-arm64.tar.gz \ + "s3://${R2_BUCKET}/releases/latest/darkbloom-bundle-macos-arm64.tar.gz" \ + --endpoint-url "$R2_ENDPOINT" --only-show-errors + aws s3 cp /tmp/darkbloom-bundle-macos-arm64.tar.gz \ + "s3://${R2_BUCKET}/releases/latest/eigeninference-bundle-macos-arm64.tar.gz" \ + --endpoint-url "$R2_ENDPOINT" --only-show-errors + + - name: Compute template hashes + run: | + set -euo pipefail + MODELS_CDN="https://pub-7cbee059c80c46ec9c071dbee2726f8a.r2.dev" + TEMPLATE_HASHES="" + for name in qwen3.5 trinity gemma4 minimax; do + HASH=$(curl -fsSL "$MODELS_CDN/templates/${name}.jinja" 2>/dev/null | shasum -a 256 | cut -d' ' -f1 || true) + # ignore the empty-string SHA (which means the file 404'd) + if [ -n "$HASH" ] && [ "$HASH" != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ]; then + [ -n "$TEMPLATE_HASHES" ] && TEMPLATE_HASHES+="," + TEMPLATE_HASHES+="${name}=${HASH}" + echo "Template $name: ${HASH:0:16}…" + fi + done + echo "TEMPLATE_HASHES=$TEMPLATE_HASHES" >> "$GITHUB_ENV" + + - name: Register release with coordinator + env: + COORDINATOR_URL: ${{ secrets.COORDINATOR_URL }} + RELEASE_KEY: ${{ secrets.RELEASE_KEY }} + R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} + run: | + set -euo pipefail + BUNDLE_URL="${R2_PUBLIC_URL}/releases/v${VERSION}/darkbloom-bundle-macos-arm64.tar.gz" + + TAG_MSG=$(git tag -l --format='%(contents:subject)%0a%(contents:body)' "$GITHUB_REF_NAME" 2>/dev/null || echo "") + if [ -z "$TAG_MSG" ] || [ "$TAG_MSG" = $'\n' ]; then + TAG_MSG="Release v${VERSION}" + fi + + # The Swift provider does NOT expose python_hash / runtime_hash — + # those fields are dropped post-cutover. Coordinator should accept + # releases without them when backend == "mlx-swift". + python3 - < /tmp/release-payload.json + import json, os + payload = { + "version": os.environ["VERSION"], + "platform": "macos-arm64", + "backend": "mlx-swift", + "binary_hash": os.environ["BINARY_HASH"], + "bundle_hash": os.environ["BUNDLE_HASH"], + "metallib_hash": os.environ["METALLIB_HASH"], + "template_hashes": os.environ.get("TEMPLATE_HASHES", ""), + "url": "${BUNDLE_URL}", + "changelog": """${TAG_MSG}""".strip(), + } + print(json.dumps(payload)) + PY + curl -fsSL -X POST "${COORDINATOR_URL}/v1/releases" \ + -H "Authorization: Bearer ${RELEASE_KEY}" \ + -H "Content-Type: application/json" \ + -d @/tmp/release-payload.json + echo + echo "Release v${VERSION} registered with coordinator" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + cat > /tmp/release-notes.md </dev/null || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1ba05161..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,575 +0,0 @@ -name: Release Provider Bundle - -# Release paths: -# 1. tag push `vX.Y.Z` -> prod (reviewer approval required) -# 2. tag push `vX.Y.Z-dev.N` -> dev (auto) -# 3. manual workflow_dispatch with environment input -# -# Parallelism strategy (this is Phase-2 of the original refactor): -# -# ┌─────────────────────────────────────────────────────────────────┐ -# │ Parallel builds (~5 min wall) │ -# │ Rust provider ─┐ │ -# │ Swift enclave ─┤ │ -# │ Python runtime ─┤ (uv pip: ~30s vs pip: ~5 min) │ -# │ Swift app ─┘ │ -# ├─────────────────────────────────────────────────────────────────┤ -# │ Sign + test (~3 min, tests in background) │ -# │ Sign Python runtime once → copy to bundle + app (skip 2 passes)│ -# ├─────────────────────────────────────────────────────────────────┤ -# │ Notarize + app assembly (~8 min) │ -# │ Bundle notarize (bg) ──────────────────────┐ │ -# │ Assemble .app + DMG (fg) ──────────────────┤ overlap │ -# │ R2 uploads (bg) ──────────────────────────┤ │ -# │ DMG notarize (bg) ─────────────────────────┘ │ -# └─────────────────────────────────────────────────────────────────┘ -# -# Coordinator Go tests are NOT in this workflow — they run in CI on -# every push to master. The release is always from a CI-tested commit. - -on: - push: - tags: ['v*'] - workflow_dispatch: - inputs: - environment: - description: 'Target environment' - required: true - type: choice - options: [dev, prod] - default: dev - -env: - CARGO_TERM_COLOR: always - PYO3_USE_ABI3_FORWARD_COMPATIBILITY: '1' - DEVELOPER_ID: "Developer ID Application: Eigen Labs, Inc. (SLDQ2GJ6TL)" - PBS_TAG: "20260408" - PBS_PYTHON_VERSION: "3.12.13" - -jobs: - resolve-env: - runs-on: ubuntu-latest - outputs: - environment: ${{ steps.pick.outputs.environment }} - steps: - - id: pick - run: | - if [ -n "${{ inputs.environment }}" ]; then - echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref_name }}" == *-dev.* ]]; then - echo "environment=dev" >> $GITHUB_OUTPUT - else - echo "environment=prod" >> $GITHUB_OUTPUT - fi - - build-and-release: - name: Build, Sign, Upload, Register - needs: [resolve-env] - environment: ${{ needs.resolve-env.outputs.environment }} - runs-on: macos-26-xlarge - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - # --- Import signing certificate --- - - - name: Import Developer ID certificate - env: - P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12 }} - P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" - - echo "$P12_BASE64" | base64 --decode > /tmp/cert.p12 - - security create-keychain -p "build" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "build" "$KEYCHAIN_PATH" - security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') - security import /tmp/cert.p12 -k "$KEYCHAIN_PATH" \ - -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productsign -f pkcs12 - security set-key-partition-list -S apple-tool:,apple: \ - -s -k "build" "$KEYCHAIN_PATH" - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - rm /tmp/cert.p12 - - # --- Prepare tools and Python (single download, two extractions) --- - - - name: Prepare build tools and Python - run: | - # Install uv for ~10x faster Python package installs - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - # awscli for R2 uploads - command -v aws &>/dev/null || brew install awscli 2>/dev/null || true - - # Download PBS Python ONCE - curl -fsSL "https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_TAG}/cpython-${PBS_PYTHON_VERSION}+${PBS_TAG}-aarch64-apple-darwin-install_only.tar.gz" \ - -o /tmp/pbs-python.tar.gz - - # Extract for Rust linking (clean copy, no pip installs) - rm -rf /tmp/eigeninference-build-python - mkdir -p /tmp/eigeninference-build-python - tar xzf /tmp/pbs-python.tar.gz --strip-components=1 -C /tmp/eigeninference-build-python - - # Extract for Python runtime (will get pip installs) - tar xzf /tmp/pbs-python.tar.gz -C /tmp/ - mv /tmp/python /tmp/eigeninference-python - rm -f /tmp/pbs-python.tar.gz - - # Pre-download vllm-mlx source for pip install - curl -fsSL -o /tmp/vllm-mlx-source.zip \ - 'https://github.com/Gajesh2007/vllm-mlx/archive/refs/heads/main.zip' - - /tmp/eigeninference-build-python/bin/python3.12 --version - - - name: Cache cargo registry + target - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: | - ~/.cargo/registry/cache - ~/.cargo/registry/index - ~/.cargo/git/db - provider/target - key: cargo-v1-${{ runner.os }}-${{ hashFiles('provider/Cargo.lock') }} - restore-keys: | - cargo-v1-${{ runner.os }}- - - # --- Build all four components in parallel --- - - - name: Build all components (parallel) - env: - DARKBLOOM_COORDINATOR_URL: ${{ secrets.COORDINATOR_URL }} - run: | - set -e - - # Task A: Rust provider - ( - set -e - export PYO3_PYTHON=/tmp/eigeninference-build-python/bin/python3.12 - export LIBRARY_PATH=/tmp/eigeninference-build-python/lib - export DYLD_LIBRARY_PATH=/tmp/eigeninference-build-python/lib - export RUSTFLAGS="-L native=/tmp/eigeninference-build-python/lib" - cd provider && cargo build --release - ) > /tmp/rust-build.log 2>&1 & - RUST_PID=$! - echo "Started Rust build (pid $RUST_PID)" - - # Task B: Swift enclave - ( - set -e - cd enclave && swift build -c release - ) > /tmp/enclave-build.log 2>&1 & - ENCLAVE_PID=$! - echo "Started enclave build (pid $ENCLAVE_PID)" - - # Task C: Python runtime (uv is ~10x faster than pip) - ( - set -e - PYTHON=/tmp/eigeninference-python/bin/python3.12 - - "$HOME/.local/bin/uv" pip install --python "$PYTHON" --quiet --no-cache-dir \ - 'mlx-lm>=0.31.2' \ - /tmp/vllm-mlx-source.zip \ - tokenizers - "$HOME/.local/bin/uv" pip install --python "$PYTHON" --quiet --no-cache-dir --upgrade 'mlx-lm>=0.31.2' - - "$PYTHON" -c "import vllm_mlx; print(f'vllm-mlx {vllm_mlx.__version__}')" - "$PYTHON" -c "import mlx_lm; print(f'mlx-lm {mlx_lm.__version__}')" - "$PYTHON" -c "from vllm_mlx.server import app; print('vllm-mlx server imports OK')" - - # Trim bloat - cd /tmp/eigeninference-python/lib/python*/site-packages - rm -rf torch* gradio* opencv* cv2* pandas* pyarrow* \ - sympy* networkx* mcp* miniaudio* pydub* datasets* - find /tmp/eigeninference-python -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true - find /tmp/eigeninference-python -name "*.pyc" -delete 2>/dev/null || true - "$PYTHON" -m compileall -q /tmp/eigeninference-python/lib/python3.12/site-packages/ 2>/dev/null || true - rm -f /tmp/eigeninference-python/lib/python*/EXTERNALLY-MANAGED - ) > /tmp/python-build.log 2>&1 & - PYTHON_PID=$! - echo "Started Python runtime build (pid $PYTHON_PID)" - - # Task D: Swift macOS app (compile only — assembly happens after signing) - ( - set -e - cd app/EigenInference && swift build -c release - ) > /tmp/app-build.log 2>&1 & - APP_PID=$! - echo "Started Swift app build (pid $APP_PID)" - - # Wait for all builds, report failures - FAILED=0 - for entry in "$RUST_PID:Rust:rust" "$ENCLAVE_PID:Enclave:enclave" "$PYTHON_PID:Python:python" "$APP_PID:App:app"; do - PID="${entry%%:*}"; rest="${entry#*:}"; NAME="${rest%%:*}"; LOG="${rest##*:}" - if wait "$PID"; then - echo "✓ $NAME build complete" - tail -3 "/tmp/${LOG}-build.log" 2>/dev/null || true - else - echo "✗ $NAME build FAILED:" - cat "/tmp/${LOG}-build.log" - FAILED=1 - fi - done - [ $FAILED -eq 0 ] || exit 1 - - # --- Test (background) + Sign + Bundle + Hash --- - - - name: Test, sign, bundle, and hash - id: artifacts - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: "SLDQ2GJ6TL" - run: | - set -e - VERSION="${GITHUB_REF_NAME#v}" - - # ---- Provider tests in background ---- - ( - set -e - export PYO3_PYTHON=/tmp/eigeninference-build-python/bin/python3.12 - export LIBRARY_PATH=/tmp/eigeninference-build-python/lib - export DYLD_LIBRARY_PATH=/tmp/eigeninference-build-python/lib - export RUSTFLAGS="-L native=/tmp/eigeninference-build-python/lib" - cd provider && cargo test -- --skip proxy::tests --skip server::tests --skip coordinator::tests::test_coordinator_connect_register_and_receive - ) > /tmp/test.log 2>&1 & - TEST_PID=$! - echo "Started provider tests (pid $TEST_PID)" - - # ---- Sign Python runtime Mach-Os ONCE (covers bundle + app) ---- - sign_macho_tree() { - local dir="$1" - find "$dir" -type f | while read -r file; do - if file "$file" | grep -q "Mach-O"; then - codesign --force --sign "$DEVELOPER_ID" \ - --keychain "$KEYCHAIN_PATH" --options runtime --timestamp \ - "$file" || return 1 - fi - done - } - echo "Signing Python runtime Mach-Os..." - if ! sign_macho_tree /tmp/eigeninference-python; then - echo "Python runtime codesign failed — retrying in 30s" - sleep 30 - sign_macho_tree /tmp/eigeninference-python - fi - echo "✓ Python runtime signed" - - # ---- Assemble provider bundle ---- - mkdir -p /tmp/eigeninference-bundle/bin /tmp/eigeninference-bundle/python - - cp provider/target/release/darkbloom /tmp/eigeninference-bundle/bin/ - cp enclave/.build/release/eigeninference-enclave /tmp/eigeninference-bundle/bin/ - - # Copy SIGNED Python base to bundle (no site-packages). - # These files are already signed from the runtime pass above. - cp -R /tmp/eigeninference-python/bin /tmp/eigeninference-bundle/python/ - cp -R /tmp/eigeninference-python/lib /tmp/eigeninference-bundle/python/ - rm -rf /tmp/eigeninference-bundle/python/lib/python3.12/site-packages - - # Fix libpython linkage in darkbloom - PYTHON_LOAD_PATH=$(otool -L /tmp/eigeninference-bundle/bin/darkbloom | awk '/libpython3\.12\.dylib/ {print $1; exit}') - if [ -z "$PYTHON_LOAD_PATH" ]; then - echo "ERROR: could not find libpython linkage in darkbloom" - exit 1 - fi - install_name_tool -change \ - "$PYTHON_LOAD_PATH" \ - "@executable_path/../python/lib/libpython3.12.dylib" \ - /tmp/eigeninference-bundle/bin/darkbloom - - # Sign binaries (hardened runtime + entitlements) - codesign --force --sign "$DEVELOPER_ID" \ - --keychain "$KEYCHAIN_PATH" \ - --entitlements scripts/entitlements.plist \ - --options runtime \ - /tmp/eigeninference-bundle/bin/darkbloom - - codesign --force --sign "$DEVELOPER_ID" \ - --keychain "$KEYCHAIN_PATH" \ - --entitlements scripts/entitlements.plist \ - --options runtime \ - /tmp/eigeninference-bundle/bin/eigeninference-enclave - - # Verify key signatures - codesign --verify --verbose /tmp/eigeninference-bundle/bin/darkbloom - codesign --verify --verbose /tmp/eigeninference-bundle/bin/eigeninference-enclave - codesign --verify --verbose /tmp/eigeninference-bundle/python/bin/python3.12 - codesign --verify --verbose /tmp/eigeninference-bundle/python/lib/libpython3.12.dylib - - cd /tmp && tar czf eigeninference-bundle-macos-arm64.tar.gz -C eigeninference-bundle . - echo "Bundle created: $(du -h /tmp/eigeninference-bundle-macos-arm64.tar.gz | cut -f1)" - - # ---- Compute Python hashes (stable — not affected by stapling) ---- - # Binary and bundle hashes are computed AFTER notarization stapling - # in the next step, since stapling modifies the Mach-O binaries. - PYTHON_BIN="/tmp/eigeninference-python/bin/python3.12" - PYTHON_HASH=$(shasum -a 256 "$PYTHON_BIN" | cut -d' ' -f1) - echo "Python hash: $PYTHON_HASH" - echo "PYTHON_HASH=$PYTHON_HASH" >> "$GITHUB_ENV" - - PYTHON_LIB_DIR=$("$PYTHON_BIN" -c "import sysconfig; print(sysconfig.get_path('stdlib'))") - SITE_PACKAGES_DIR=$("$PYTHON_BIN" -c "import site; print(site.getsitepackages()[0])") - # Use the same Rust hashing as the provider binary to guarantee - # identical results. The bundle copy was signed above and its - # libpython rpath points at @executable_path/../python/lib/ which - # doesn't exist in the bundle (site-packages stripped). Set - # DYLD_LIBRARY_PATH so the binary can load libpython. - RUNTIME_HASH=$(DYLD_LIBRARY_PATH="/tmp/eigeninference-python/lib:/tmp/eigeninference-build-python/lib" \ - /tmp/eigeninference-bundle/bin/darkbloom hash-runtime "$PYTHON_LIB_DIR") - echo "Runtime hash: $RUNTIME_HASH" - echo "RUNTIME_HASH=$RUNTIME_HASH" >> "$GITHUB_ENV" - - # Create runtime tarballs - cd /tmp && tar czf eigeninference-python-macos-arm64.tar.gz -C eigeninference-python . - cd /tmp && tar czf eigeninference-site-packages.tar.gz -C "$SITE_PACKAGES_DIR" . - - # ---- Wait for provider tests ---- - echo "Waiting for provider tests..." - if ! wait $TEST_PID; then - echo "Provider tests FAILED:" - cat /tmp/test.log - exit 1 - fi - echo "✓ Provider tests passed" - - # --- Notarize bundle + build app + upload (maximally parallel) --- - - - name: Notarize, build app, and upload - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: "SLDQ2GJ6TL" - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - R2_BUCKET: ${{ vars.R2_BUCKET }} - R2_CDN: ${{ vars.R2_CDN }} - run: | - set -e - VERSION="${GITHUB_REF_NAME#v}" - - # ---- Task A: Notarize provider bundle (background) ---- - ( - set -e - ditto -c -k --keepParent /tmp/eigeninference-bundle /tmp/eigeninference-notarize.zip - xcrun notarytool submit /tmp/eigeninference-notarize.zip \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait --timeout 10m - ) > /tmp/notarize-bundle.log 2>&1 & - NOTARIZE_BUNDLE_PID=$! - echo "Started bundle notarization (pid $NOTARIZE_BUNDLE_PID)" - - # ---- Task B: Build macOS app + DMG (foreground, overlaps with notarize) ---- - APP_BIN=$(cd app/EigenInference && swift build -c release --show-bin-path)/EigenInference - - APP_DIR="/tmp/EigenInference.app" - CONTENTS="$APP_DIR/Contents" - MACOS="$CONTENTS/MacOS" - RESOURCES="$CONTENTS/Resources" - PYTHON_ROOT="$CONTENTS/python" - rm -rf "$APP_DIR" - mkdir -p "$MACOS" "$RESOURCES" "$PYTHON_ROOT" - - cat > "$CONTENTS/Info.plist" << PLIST - - - - - CFBundleNameEigenInference - CFBundleDisplayNameEigenInference - CFBundleIdentifierio.darkbloom.provider - CFBundleVersion${VERSION} - CFBundleShortVersionString${VERSION} - CFBundleExecutableEigenInference - CFBundlePackageTypeAPPL - CFBundleIconFileAppIcon - LSMinimumSystemVersion14.0 - LSUIElement - NSHighResolutionCapable - - - PLIST - - cp "$APP_BIN" "$MACOS/EigenInference" - cp /tmp/eigeninference-bundle/bin/darkbloom "$MACOS/darkbloom" - cp /tmp/eigeninference-bundle/bin/eigeninference-enclave "$MACOS/eigeninference-enclave" 2>/dev/null || true - - # Copy already-signed Python runtime into app (skip re-signing individual files) - cp -a /tmp/eigeninference-python/. "$PYTHON_ROOT/" - - # Sign app binaries (entitlements) - for bin in "$MACOS"/*; do - codesign --force --options runtime \ - --entitlements scripts/entitlements.plist \ - --sign "$DEVELOPER_ID" --keychain "$KEYCHAIN_PATH" "$bin" - done - # Sign the .app bundle itself - codesign --force --options runtime --no-strict \ - --entitlements scripts/entitlements.plist \ - --sign "$DEVELOPER_ID" --keychain "$KEYCHAIN_PATH" "$APP_DIR" - - # Create DMG - DMG_TMP="/tmp/dmg-staging" - rm -rf "$DMG_TMP" - mkdir -p "$DMG_TMP" - cp -a "$APP_DIR" "$DMG_TMP/" - ln -s /Applications "$DMG_TMP/Applications" - hdiutil create -volname "Darkbloom" -srcfolder "$DMG_TMP" \ - -ov -format UDZO "/tmp/Darkbloom.dmg" >/dev/null - rm -rf "$DMG_TMP" - echo "DMG built: $(du -h /tmp/Darkbloom.dmg | cut -f1)" - - # ---- Wait for bundle notarization ---- - echo "Waiting for bundle notarization..." - if ! wait "$NOTARIZE_BUNDLE_PID"; then - echo "!! Bundle notarization failed:" - tail -80 /tmp/notarize-bundle.log - exit 1 - fi - echo "Bundle notarization complete" - tail -5 /tmp/notarize-bundle.log - - # Staple notarization tickets to bundle binaries - xcrun stapler staple /tmp/eigeninference-bundle/bin/darkbloom || true - xcrun stapler staple /tmp/eigeninference-bundle/bin/eigeninference-enclave || true - - # Recreate bundle tarball with stapled binaries - cd /tmp && tar czf eigeninference-bundle-macos-arm64.tar.gz -C eigeninference-bundle . - - # Compute final hashes AFTER stapling (stapling modifies the binaries) - BINARY_HASH=$(shasum -a 256 /tmp/eigeninference-bundle/bin/darkbloom | cut -d' ' -f1) - BUNDLE_HASH=$(shasum -a 256 /tmp/eigeninference-bundle-macos-arm64.tar.gz | cut -d' ' -f1) - echo "Binary hash (signed+stapled): $BINARY_HASH" - echo "Bundle hash: $BUNDLE_HASH" - echo "BINARY_HASH=$BINARY_HASH" >> "$GITHUB_ENV" - echo "BUNDLE_HASH=$BUNDLE_HASH" >> "$GITHUB_ENV" - - # ---- Task C: Notarize DMG (background) ---- - ( - xcrun notarytool submit "/tmp/Darkbloom.dmg" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait - xcrun stapler staple "/tmp/Darkbloom.dmg" - ) > /tmp/notarize-dmg.log 2>&1 & - NOTARIZE_DMG_PID=$! - echo "Started DMG notarization (pid $NOTARIZE_DMG_PID)" - - # ---- Task D: Upload bundles to R2 (overlaps with DMG notarize) ---- - # Wait on explicit PIDs — bare `wait` would also block on DMG notarize. - PREFIX="s3://${R2_BUCKET}/releases/v${VERSION}" - aws s3 cp /tmp/eigeninference-bundle-macos-arm64.tar.gz "${PREFIX}/eigeninference-bundle-macos-arm64.tar.gz" --endpoint-url "$R2_ENDPOINT" --only-show-errors & UL1=$! - aws s3 cp /tmp/eigeninference-python-macos-arm64.tar.gz "${PREFIX}/eigeninference-python-macos-arm64.tar.gz" --endpoint-url "$R2_ENDPOINT" --only-show-errors & UL2=$! - aws s3 cp /tmp/eigeninference-site-packages.tar.gz "${PREFIX}/eigeninference-site-packages.tar.gz" --endpoint-url "$R2_ENDPOINT" --only-show-errors & UL3=$! - aws s3 cp /tmp/vllm-mlx-source.zip "${PREFIX}/vllm-mlx-source.zip" --endpoint-url "$R2_ENDPOINT" --only-show-errors & UL4=$! - wait $UL1 $UL2 $UL3 $UL4 - echo "All bundle uploads complete" - - # Compute template hashes while DMG notarizes. - # Templates live on the models bucket, not the releases bucket. - MODELS_CDN="https://pub-7cbee059c80c46ec9c071dbee2726f8a.r2.dev" - TEMPLATE_HASHES="" - for name in qwen3.5 trinity gemma4 minimax; do - HASH=$(curl -fsSL "$MODELS_CDN/templates/${name}.jinja" 2>/dev/null | shasum -a 256 | cut -d' ' -f1) - if [ -n "$HASH" ] && [ "$HASH" != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ]; then - [ -n "$TEMPLATE_HASHES" ] && TEMPLATE_HASHES+="," - TEMPLATE_HASHES+="${name}=${HASH}" - echo "Template $name: ${HASH:0:16}..." - fi - done - echo "TEMPLATE_HASHES=$TEMPLATE_HASHES" >> "$GITHUB_ENV" - - # ---- Wait for DMG notarization (best-effort) ---- - echo "Waiting for DMG notarization..." - if wait "$NOTARIZE_DMG_PID" 2>/dev/null; then - echo "DMG notarization complete" - else - echo "DMG notarization failed (non-fatal):" - tail -20 /tmp/notarize-dmg.log 2>/dev/null || true - fi - - # Upload DMG (after notarize+staple completes) - aws s3 cp /tmp/Darkbloom.dmg "${PREFIX}/Darkbloom.dmg" --endpoint-url "$R2_ENDPOINT" --only-show-errors & - aws s3 cp /tmp/Darkbloom.dmg "s3://${R2_BUCKET}/releases/latest/Darkbloom.dmg" --endpoint-url "$R2_ENDPOINT" --only-show-errors & - wait - echo "DMG uploaded to R2" - - # --- Register with coordinator --- - - - name: Register release with coordinator - env: - COORDINATOR_URL: ${{ secrets.COORDINATOR_URL }} - RELEASE_KEY: ${{ secrets.RELEASE_KEY }} - R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - BUNDLE_URL="${R2_PUBLIC_URL}/releases/v${VERSION}/eigeninference-bundle-macos-arm64.tar.gz" - - TAG_MSG=$(git tag -l --format='%(contents:subject)%0a%(contents:body)' "$GITHUB_REF_NAME" 2>/dev/null || echo "") - if [ -z "$TAG_MSG" ] || [ "$TAG_MSG" = $'\n' ]; then - TAG_MSG="Release v${VERSION}" - fi - - python3 -c " - import json, subprocess, sys - payload = { - 'version': '${VERSION}', - 'platform': 'macos-arm64', - 'binary_hash': '${{ env.BINARY_HASH }}', - 'bundle_hash': '${{ env.BUNDLE_HASH }}', - 'python_hash': '${{ env.PYTHON_HASH }}', - 'runtime_hash': '${{ env.RUNTIME_HASH }}', - 'template_hashes': '${{ env.TEMPLATE_HASHES }}', - 'url': '${BUNDLE_URL}', - 'changelog': '''${TAG_MSG}'''.strip() - } - print(json.dumps(payload)) - " > /tmp/release-payload.json - - curl -fsSL -X POST "${COORDINATOR_URL}/v1/releases" \ - -H "Authorization: Bearer ${RELEASE_KEY}" \ - -H "Content-Type: application/json" \ - -d @/tmp/release-payload.json - echo "" - echo "Release v${VERSION} registered with coordinator" - - # --- Cleanup keychain --- - - - name: Cleanup - if: always() - run: | - security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true - - # --- GitHub Release --- - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - cat > /tmp/release-notes.md <<'NOTES' - ## Provider v${{ github.ref_name }} - - **Binary hash (signed):** `${{ env.BINARY_HASH }}` - **Bundle hash:** `${{ env.BUNDLE_HASH }}` - **Runtime hash (vllm-mlx):** `${{ env.RUNTIME_HASH }}` - **Signed by:** Developer ID Application: Eigen Labs, Inc. (SLDQ2GJ6TL) - **Notarized:** Yes - - ### Install - ```bash - curl -fsSL ${{ secrets.COORDINATOR_URL }}/install.sh | bash - ``` - NOTES - gh release create "${{ github.ref_name }}" \ - /tmp/eigeninference-bundle-macos-arm64.tar.gz \ - /tmp/Darkbloom.dmg \ - --title "${{ github.ref_name }}" \ - --notes-file /tmp/release-notes.md \ - --generate-notes diff --git a/.gitmodules b/.gitmodules index fb84ecd1..b5d4f05b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "libs/mlx-swift"] path = libs/mlx-swift - url = https://github.com/Gajesh2007/mlx-swift.git + url = https://github.com/Layr-Labs/mlx-swift.git [submodule "libs/mlx-swift-lm"] path = libs/mlx-swift-lm - url = https://github.com/Gajesh2007/mlx-swift-lm.git + url = https://github.com/Layr-Labs/mlx-swift-lm.git diff --git a/AGENTS.md b/AGENTS.md index 43f1ba8e..82996dbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # EigenInference - Decentralized Private Inference -EigenInference is a decentralized/private inference stack for Apple Silicon Macs. Consumers use OpenAI-compatible APIs, the coordinator handles routing/auth/billing/attestation, and providers run local text, transcription, and image workloads on macOS hardware. +EigenInference is a decentralized/private inference stack for Apple Silicon Macs. Consumers use OpenAI-compatible APIs, the coordinator handles routing/auth/billing/attestation, and providers run local text-inference workloads on macOS hardware. ## Project Structure @@ -11,7 +11,7 @@ coordinator/ Go control plane │ └── main.go verifies attestation blobs from /tmp/eigeninference_attestation.json └── internal/ ├── api/ HTTP + WebSocket handlers - │ ├── consumer.go OpenAI-compatible chat/completions/messages/transcriptions/images + │ ├── consumer.go OpenAI-compatible chat/completions/messages/responses │ ├── provider.go provider registration, heartbeats, attestation, relay │ ├── billing_handlers.go Stripe/Solana/referral/pricing endpoints │ ├── device_auth.go device code flow for linking providers to user accounts @@ -35,7 +35,7 @@ provider/ Rust provider agent for Apple Silicon Macs ├── src/ │ ├── main.rs CLI (`serve`, `start`, `stop`, `models`, `benchmark`, `status`, `doctor`, `login`, etc.) │ ├── coordinator.rs WebSocket client, registration, heartbeats, request handling -│ ├── proxy.rs text, transcription, and image proxying to local backends +│ ├── proxy.rs text proxying to local backends │ ├── backend/ vllm-mlx backend process management │ ├── service.rs launchd install/start/stop helpers │ ├── server.rs local-only HTTP server mode @@ -52,44 +52,17 @@ provider/ Rust provider agent for Apple Silicon Macs ├── stt_server.py local speech-to-text server script used by bundles └── Cargo.toml default `python` feature enables in-process PyO3 inference -image-bridge/ Python FastAPI image generation bridge -├── eigeninference_image_bridge/ -│ ├── __main__.py -│ ├── server.py OpenAI-compatible `/v1/images/generations` -│ ├── drawthings_backend.py Draw Things gRPC backend adapter -│ ├── generated/ generated protobuf/FlatBuffers glue -│ └── proto/ -├── requirements.txt -└── tests/ pytest coverage for server/backend/integration - -app/EigenInference/ SwiftUI macOS menu bar app -├── Sources/EigenInference/ -│ ├── EigenInferenceApp.swift -│ ├── StatusViewModel.swift -│ ├── ProviderManager.swift -│ ├── CLIRunner.swift -│ ├── ConfigManager.swift -│ ├── LaunchAgentManager.swift -│ ├── SecurityManager.swift -│ ├── ModelManager.swift / ModelCatalog.swift -│ ├── IdleDetector.swift -│ ├── NotificationManager.swift / UpdateManager.swift -│ ├── DesignSystem.swift / GuideAvatar.swift / Illustrations.swift -│ ├── DashboardView.swift / SettingsView.swift -│ ├── MenuBarView.swift / SetupWizardView.swift -│ ├── DoctorView.swift / LogViewerView.swift / ModelCatalogView.swift -│ └── Resources/ -└── Tests/EigenInferenceTests/ - -enclave/ Swift Secure Enclave helper + bridge binary -├── Sources/EigenInferenceEnclave/ enclave key + attestation library + FFI bridge -├── Sources/EigenInferenceEnclaveCLI/ `eigeninference-enclave` CLI (attest, sign, info) -├── Tests/EigenInferenceEnclaveTests/ -└── include/eigeninference_enclave.h +provider-swift/ Swift CLI port of the provider (replacing `provider/` at cutover) +├── Package.swift SwiftPM manifest, depends on libs/mlx-swift{,-lm} +├── Sources/ +│ ├── ProviderCore/ shared library: protocol, hardware, crypto (libsodium NaCl box), security, attestation, inference, coordinator client, scheduling, server, telemetry, models +│ ├── darkbloom/ CLI executable (serve, start, stop, status, doctor, models, login, logout, benchmark, update, verify) +│ └── darkbloom-enclave-cli/ Secure Enclave attestation/sign helper +└── Tests/ProviderCoreTests/ console-ui/ Next.js 16 / React 19 frontend ├── src/app/ chat, billing, images, models, stats, providers, settings, link, api-console, earn -├── src/app/api/ chat, images, transcribe, auth/keys, payments/*, invite, models, health, pricing +├── src/app/api/ chat, auth/keys, payments/*, invite, models, health, pricing ├── src/components/ chat UI, sidebar, top bar, trust badge, verification panel, invite banner ├── src/components/providers/ │ ├── PrivyClientProvider.tsx @@ -98,27 +71,25 @@ console-ui/ Next.js 16 / React 19 frontend ├── src/hooks/ auth (useAuth.ts) + toast (useToast.ts) └── proxy.ts Next.js 16 proxy (replaces middleware.ts) -scripts/ build, signing, install, and deploy helpers -├── build-bundle.sh provider/enclave/python/ffmpeg bundle builder (+ optional upload) -├── bundle-app.sh build EigenInference.app + DMG +scripts/ install + deploy helpers ├── install.sh end-user installer served from coordinator (hash + codesign verification) -├── sign-hardened.sh hardened runtime signing helper ├── admin.sh admin CLI (Privy auth, release mgmt, API calls) ├── deploy-acme.sh nginx/step-ca helper -├── test-stt-e2e.sh speech-to-text smoke test +├── smoke-dev.sh dev-coordinator smoke test +├── benchmark-*.py benchmark utilities └── entitlements.plist hardened runtime entitlements (hypervisor, network) docs/ architecture, deploy runbooks, MDM/ACME notes, image/video research -.github/workflows/ CI (ci.yml) and release automation (release.yml) with code signing + notarization +.github/workflows/ CI (ci.yml) and Swift release automation (release-swift.yml) with code signing + notarization ``` ## Current Surface Area -- Coordinator HTTP routes include `POST /v1/chat/completions`, `POST /v1/completions`, `POST /v1/messages`, `POST /v1/audio/transcriptions`, `POST /v1/images/generations`, `GET /v1/models`, billing/pricing endpoints, invite flows, stats, enrollment, device authorization, and release registration endpoints. +- Coordinator HTTP routes include `POST /v1/chat/completions`, `POST /v1/completions`, `POST /v1/messages`, `POST /v1/responses`, `GET /v1/models`, billing/pricing endpoints, invite flows, stats, enrollment, device authorization, and release registration endpoints. Image generation and audio transcription are not part of the platform. - Coordinator auth is split between Privy JWTs, API keys, and device-code login (RFC 8628) for provider machines. - Billing logic is split between `coordinator/internal/payments` (ledger + pricing) and `coordinator/internal/billing` (Stripe, Solana USDC, referrals). Coordinator wallet derived from BIP39 mnemonic via SLIP-0010. -- Providers can serve text models, transcription, and optional image models. Image generation goes through the separate `image-bridge/` process and uploads PNGs back to the coordinator over HTTP. -- The macOS app is a real operational client, not just a wrapper. It manages installation, onboarding, launchd integration, diagnostics, and subprocess supervision for `darkbloom`. +- Providers serve text models only. Audio transcription and image generation are not part of the platform. +- The Swift provider is **CLI-only**. There is no menu bar app and no SwiftUI surface — the legacy `app/EigenInference/` and `enclave/` directories were deleted as part of the CLI-only migration. Operators interact with `darkbloom` directly: `darkbloom serve` for foreground, `darkbloom start`/`stop` for launchd, plus `status`/`doctor`/`models`/`login`/`logout`/`benchmark`/`update`/`verify`. ## Building And Testing @@ -133,7 +104,7 @@ go build ./cmd/verify-attestation GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o eigeninference-coordinator-linux ./cmd/coordinator ``` -### Provider (Rust) +### Provider (Rust, legacy) ```bash cd provider PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 cargo test @@ -145,29 +116,22 @@ cargo build --release --no-default-features The `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` env var is still the safe default when local Python is newer than the PyO3 support window. -### Image Bridge (Python) +### Provider (Swift, replacing Rust at cutover) ```bash -cd image-bridge -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt pytest httpx -PYTHONPATH=. pytest -``` - -### macOS App (Swift) -```bash -cd app/EigenInference -swift build -c release +cd provider-swift swift test -``` - -### Enclave Helper (Swift) -```bash -cd enclave swift build -c release -swift test +# Outputs: +# .build/release/darkbloom provider CLI +# .build/release/darkbloom-enclave Secure Enclave helper ``` +The package depends on `../libs/mlx-swift` and `../libs/mlx-swift-lm` (both +git submodules). Ensure they are checked out (`git submodule update --init +--recursive`). For local runs, the matching `mlx.metallib` must sit next to +the binary; the release CI handles this automatically by extracting it from +the matching `mlx==0.31.x` Python wheel. + ### Console UI (Next.js 16) ```bash cd console-ui @@ -189,8 +153,7 @@ Canonical runbook: `docs/coordinator-deploy-runbook.md` Current release-sensitive pieces: - Prod coordinator runs on EigenCloud (TEE) as app `d-inference` at `api.darkbloom.dev`. Build target: `coordinator/Dockerfile`. Dev coordinator runs on Google Cloud (see `docs/dev-environment.md`). -- Provider bundle creation lives in `scripts/build-bundle.sh`. -- App bundle + DMG creation lives in `scripts/bundle-app.sh`. +- Provider bundle creation lives entirely in `.github/workflows/release-swift.yml` (no shell-script equivalent post-cutover). - Installer flow lives in `scripts/install.sh`. - Provider update checks use `LatestProviderVersion` in `coordinator/internal/api/server.go`, so bundle uploads and version bumps need to stay coordinated. - CI release workflow (`release.yml`) signs binaries with Developer ID Application cert, notarizes with Apple, computes SHA-256 hashes after signing. @@ -218,9 +181,9 @@ Dev coordinator deploy (Google Cloud): see `docs/dev-environment.md`. Field allowlist additions need parallel updates in `coordinator/internal/api/telemetry_handlers.go`, `provider/src/telemetry/layer.rs`, and the TS set above. -- If you change provider bundle semantics, keep `scripts/build-bundle.sh`, `scripts/install.sh`, the app launcher code, and `LatestProviderVersion` in sync. +- If you change provider bundle semantics, keep `.github/workflows/release-swift.yml`, `scripts/install.sh`, and `LatestProviderVersion` in sync. - If you change install paths or process invocation, update both the CLI/install flow and the Swift app's `CLIRunner` / `ProviderManager`. -- Image generation changes often span three places: coordinator consumer/provider handlers, provider proxying, and `image-bridge/`. +- Image generation and audio transcription are not supported. The platform serves only text inference; the model catalog filter (`coordinator/internal/api/model_catalog_filter.go`) rejects any `ModelType` other than `text`. - Device linking changes often span both coordinator device auth endpoints and the provider `login` / `logout` commands. - Model catalog changes must be reflected in coordinator's catalog, provider's `MODEL_CATALOG` in main.rs, and the Swift app's `ModelCatalog.swift`. @@ -250,5 +213,5 @@ git config core.hooksPath .githooks | Go (`coordinator/`) | `gofmt -l` | `gofmt -w ` | | Rust (`provider/`) | `cargo fmt --check` | `cd provider && cargo fmt` | | TypeScript (`console-ui/`) | `npx eslint src/` | `cd console-ui && npx eslint src/ --fix` | -| Swift (`app/`, `enclave/`) | skipped | no enforced formatter | -| Python (`image-bridge/`, `tests/`) | no hook today | run `pytest` manually as needed | +| Swift (`provider-swift/`) | skipped | no enforced formatter | +| Python (`tests/`) | no hook today | run `pytest tests/test_crypto_interop.py` manually as needed | diff --git a/CLAUDE.md b/CLAUDE.md index c258e614..be564e08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ provider/ Rust — runs on Apple Silicon Macs ├── src/ │ ├── main.rs CLI entry (serve, start, stop, models, benchmark, status, doctor, login, etc.) │ ├── coordinator.rs WebSocket client with auto-reconnect -│ ├── proxy.rs Forwards text/transcription/image requests to local backends +│ ├── proxy.rs Forwards text requests to local backends │ ├── hardware.rs Apple Silicon detection, system metrics (memory/CPU/thermal) │ ├── protocol.rs Message types (mirrors coordinator/internal/protocol) │ ├── backend/ Backend process management (vllm_mlx.rs, health checks) @@ -40,67 +40,33 @@ provider/ Rust — runs on Apple Silicon Macs │ └── wallet.rs Legacy provider wallet (secp256k1) ├── stt_server.py Local speech-to-text server script -image-bridge/ Python FastAPI image generation bridge -├── eigeninference_image_bridge/ -│ ├── server.py OpenAI-compatible /v1/images/generations -│ └── drawthings_backend.py Draw Things gRPC backend adapter -├── requirements.txt -└── tests/ - -app/EigenInference/ Swift — macOS menu bar app (SwiftUI) -├── Sources/EigenInference/ -│ ├── EigenInferenceApp.swift App entry, menu bar setup -│ ├── StatusViewModel.swift Core state management -│ ├── ProviderManager.swift Provider subprocess lifecycle -│ ├── CLIRunner.swift Launches eigeninference-provider -│ ├── ConfigManager.swift TOML config read/write -│ ├── SecurityManager.swift Trust level checks (SIP, SE, MDM, Secure Boot) -│ ├── ModelManager.swift HuggingFace model scanning -│ ├── ModelCatalog.swift Static model catalog -│ ├── LaunchAgentManager.swift macOS launch agent -│ ├── NotificationManager.swift System notifications -│ ├── UpdateManager.swift Version checking -│ ├── IdleDetector.swift User idle detection -│ ├── DesignSystem.swift Colors, typography, UI primitives -│ ├── DashboardView.swift Main dashboard -│ ├── SettingsView.swift Preferences (General, Availability, Model, Security tabs) -│ ├── MenuBarView.swift Menu bar dropdown -│ ├── SetupWizardView.swift 6-step onboarding wizard -│ ├── DoctorView.swift Diagnostics display -│ ├── LogViewerView.swift Log viewer with live streaming -│ ├── ModelCatalogView.swift Model browser with RAM fit indicators -│ ├── GuideAvatar.swift Animated mascot (mood-based PNGs) -│ └── Illustrations.swift Procedural Mac illustration -├── Tests/EigenInferenceTests/ - -enclave/ Swift — Secure Enclave attestation CLI helper +provider-swift/ Swift — CLI replacement for the Rust provider (in progress) +├── Package.swift SwiftPM manifest, depends on libs/mlx-swift{,-lm} ├── Sources/ -│ ├── EigenInferenceEnclave/ Library (P-256 key gen, attestation blob, FFI bridge for Rust) -│ └── EigenInferenceEnclaveCLI/ CLI tool (attest, sign, info, wallet-address) -├── Tests/EigenInferenceEnclaveTests/ -└── include/eigeninference_enclave.h +│ ├── ProviderCore/ shared library (protocol, hardware, crypto, security, inference, coordinator client, scheduling, server, telemetry, models, attestation) +│ ├── darkbloom/ CLI executable (serve, start, stop, status, doctor, models, login, logout, benchmark, update, verify) +│ └── darkbloom-enclave-cli/ Secure Enclave attestation/sign helper (replaces the legacy enclave/ FFI bridge) +└── Tests/ProviderCoreTests/ console-ui/ Next.js 16 / React 19 frontend (chat, billing, models, images) ├── src/app/ Pages: chat (/), billing, images, models, stats, providers, settings, link, api-console, earn -├── src/app/api/ Proxy routes: chat, models, images, transcribe, auth/keys, payments/*, invite, health, pricing +├── src/app/api/ Proxy routes: chat, models, auth/keys, payments/*, invite, health, pricing ├── src/components/ Chat UI, sidebar, top bar, trust badges, invite banner, verification panel ├── src/lib/ API client (api.ts), Zustand store (store.ts) ├── src/hooks/ Auth (useAuth.ts), toast notifications (useToast.ts) └── proxy.ts Next.js 16 proxy (replaces middleware.ts) scripts/ -├── build-bundle.sh Provider/enclave/python/ffmpeg bundle builder (+ optional upload) -├── bundle-app.sh macOS .app bundle + DMG ├── install.sh curl one-liner installer (fetches release, verifies SHA-256 + code signature) -├── sign-hardened.sh Hardened runtime signing helper ├── admin.sh Admin CLI (Privy auth, release mgmt, API calls) ├── deploy-acme.sh nginx/step-ca helper -├── test-stt-e2e.sh Speech-to-text smoke test +├── smoke-dev.sh Dev-coordinator smoke test +├── benchmark-*.py Benchmark utilities └── entitlements.plist Hardened Runtime entitlements (hypervisor, network) docs/ Architecture docs, deploy runbook, MDM/ACME notes landing/ Static landing page (index.html) -.github/workflows/ CI (ci.yml) and release automation (release.yml) +.github/workflows/ CI (ci.yml) and Swift release automation (release-swift.yml) .external/ Git-ignored; holds external forks used by the project (NOT part of this repo) └── vllm-mlx/ Our fork of vllm-mlx (github.com/Gajesh2007/vllm-mlx) @@ -120,7 +86,7 @@ go test ./... GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o eigeninference-coordinator-linux ./cmd/coordinator ``` -### Provider (Rust) +### Provider, Rust (legacy, in production) ```bash cd provider PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 cargo test @@ -133,20 +99,17 @@ To build without the Python in-process inference feature (needed for the distrib cargo build --release --no-default-features ``` -### macOS App (Swift) +### Provider, Swift (new CLI, replacing Rust at cutover) ```bash -cd app/EigenInference -swift build -c release +cd provider-swift swift test -``` - -### Enclave Helper (Swift) -```bash -cd enclave swift build -c release -swift test +# Outputs: .build/release/darkbloom and .build/release/darkbloom-enclave ``` +The Swift package depends on `../libs/mlx-swift` and `../libs/mlx-swift-lm` +(both submodules). The cutover plan is in [.claude/swift-migration-plan.md](.claude/swift-migration-plan.md). + ### Console UI (Next.js 16) ```bash cd console-ui @@ -156,14 +119,6 @@ npx eslint src/ # lint check npm test # vitest ``` -### Image Bridge (Python) -```bash -cd image-bridge -python3 -m venv .venv && source .venv/bin/activate -pip install -r requirements.txt pytest httpx -PYTHONPATH=. pytest -``` - ## Releases **Never create a release unless explicitly asked by the user.** When asked: @@ -179,13 +134,13 @@ PYTHONPATH=. pytest - ..." ``` 4. **Push** the commit and tag: `git push origin master --tags` -5. The CI release workflow (`.github/workflows/release.yml`) is triggered by the tag push. +5. The Swift release workflow (`.github/workflows/release-swift.yml`) is triggered by tags shaped `vX.Y.Z-swift[.N]`. While the Rust provider is still in production, ship Rust releases via a separate workflow re-introduced from git history. ## Deploying Full deploy runbook: **[docs/coordinator-deploy-runbook.md](docs/coordinator-deploy-runbook.md)** -Covers coordinator deploy, provider CLI bundling, macOS app distribution, and install.sh updates. +Covers coordinator deploy, provider CLI bundling, and install.sh updates. ### Coordinator (prod, EigenCloud) @@ -217,7 +172,7 @@ Shape: GCE Ubuntu VM + Docker + systemd (coordinator + step-ca + MicroMDM need p ### Provider bundle -CI (`.github/workflows/release.yml`) builds, signs, notarizes, and uploads bundles to Cloudflare R2 (`s3://d-inf-app/releases/v{VERSION}/`), then registers the release with the coordinator via `POST /v1/releases`. Providers fetch via `install.sh` served by the coordinator. There is no SSH-to-a-VM step. +CI (`.github/workflows/release-swift.yml`) builds, signs, notarizes, and uploads the Swift CLI bundle to Cloudflare R2 (`s3://d-inf-app/releases/v{VERSION}/eigeninference-bundle-macos-arm64.tar.gz`), then registers the release with the coordinator via `POST /v1/releases`. Providers fetch via `install.sh` served by the coordinator. There is no SSH-to-a-VM step. ## Infrastructure @@ -277,8 +232,8 @@ Always think from first principles. When fixing a bug or designing a feature: - The coordinator uses in-memory store by default. Provider state is lost on restart. Postgres store exists but is not used in production yet. - Binary files like `coordinator/eigeninference-coordinator` and `coordinator/eigeninference-coordinator-linux` should NOT be committed to git (15MB+ each). - CI release workflow must compute binary SHA-256 hashes AFTER code signing, not before. Providers verify hashes of the signed binary. -- Provider bundle semantics span multiple files: `scripts/build-bundle.sh`, `scripts/install.sh`, the Swift app launcher, and `LatestProviderVersion` in `coordinator/internal/api/server.go`. Keep them in sync. -- Image generation changes span three places: coordinator consumer/provider handlers, provider proxying, and `image-bridge/`. +- Provider bundle semantics span multiple files: `.github/workflows/release-swift.yml`, `scripts/install.sh` (and the embedded copy at `coordinator/internal/api/install.sh`), and `LatestProviderVersion` in `coordinator/internal/api/server.go`. Keep them in sync. +- Image generation and audio transcription are not supported. The platform serves only text inference; the model catalog filter (`coordinator/internal/api/model_catalog_filter.go`) rejects any `ModelType` other than `text`. - Device linking changes span coordinator device auth endpoints and provider `login`/`logout` commands. - The repo contains mixed payment language: current code implements Privy + Solana + Stripe, but some provider comments still reference Tempo/pathUSD. @@ -324,7 +279,7 @@ Hooks live in `.githooks/` and are enabled via `git config core.hooksPath .githo | Go (coordinator/) | `gofmt -l` | `gofmt -w ` | | Rust (provider/) | `cargo fmt --check` | `cd provider && cargo fmt` | | TypeScript (console-ui/) | `npx eslint src/` | `cd console-ui && npx eslint src/ --fix` | -| Swift (app/, enclave/) | skipped | no enforced formatter | +| Swift (provider-swift/) | skipped | no enforced formatter | If you clone fresh, activate the hook with: ```bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c184f23..a70cad7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,11 +25,9 @@ See [CLAUDE.md](CLAUDE.md) for the full layout and architectural decisions. The | Directory | Stack | What it is | |-----------|-------|------------| | `coordinator/` | Go | Central matchmaking server (runs on EigenCloud / GCP) | -| `provider/` | Rust | Hardened daemon on Apple Silicon Macs | +| `provider/` | Rust | Hardened daemon on Apple Silicon Macs (legacy, retired at Swift cutover) | +| `provider-swift/` | Swift | CLI replacement for `provider/` (`darkbloom` + `darkbloom-enclave`) | | `console-ui/` | Next.js 16 / React 19 | Web app (chat, billing, models) | -| `app/EigenInference/` | Swift / SwiftUI | macOS menu bar app for providers | -| `enclave/` | Swift | Secure Enclave attestation helper | -| `image-bridge/` | Python / FastAPI | Image generation backend adapter | ## Development setup @@ -53,21 +51,17 @@ git config core.hooksPath .githooks # enables pre-commit + pre-push checks # Coordinator cd coordinator && go test ./... -# Provider +# Legacy Rust provider cd provider && PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 cargo test +# Swift provider (CLI replacement) +cd provider-swift && swift test + # Console UI cd console-ui && npm install && npm test && npx eslint src/ -# macOS app -cd app/EigenInference && swift test - -# Enclave helper -cd enclave && swift test - -# Image bridge -cd image-bridge && python3 -m venv .venv && source .venv/bin/activate \ - && pip install -r requirements.txt pytest httpx && PYTHONPATH=. pytest +# Cross-language NaCl box parity test (PyNaCl ↔ Rust crypto_box ↔ Swift libsodium) +python3 -m pytest tests/test_crypto_interop.py ``` ## Workflow @@ -113,8 +107,8 @@ Comments: explain *why*, not *what*. Don't add comments that just restate what t Several surfaces have to stay in sync. If you touch one, check the others: - **WebSocket protocol**: `provider/src/protocol.rs` (Rust) ↔ `coordinator/internal/protocol/messages.go` (Go). -- **Provider bundle**: `scripts/build-bundle.sh`, `scripts/install.sh`, the Swift app launcher, and `LatestProviderVersion` in `coordinator/internal/api/server.go`. -- **Image generation**: coordinator consumer/provider handlers + provider proxying + `image-bridge/`. +- **Provider bundle**: `.github/workflows/release-swift.yml`, `scripts/install.sh` (and the embedded copy at `coordinator/internal/api/install.sh`), and `LatestProviderVersion` in `coordinator/internal/api/server.go`. +- **Image generation**: coordinator consumer/provider handlers route to the standalone image-generation service; `provider-swift` does not handle images. - **Device linking**: coordinator device auth endpoints + provider `login`/`logout` commands. The PR template will prompt you about this. diff --git a/README.md b/README.md index e34bdf5d..19f7716e 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ Coordinator (Go, Confidential VM) | | WebSocket (outbound from provider) v -Provider (Rust, hardened process) +Provider (Swift CLI, hardened process) | - | vllm-mlx + | mlx-swift-lm v Apple Silicon GPU (Metal) ``` @@ -76,7 +76,10 @@ Earn by serving inference on your idle Mac. curl -fsSL https://api.darkbloom.dev/install.sh | bash ``` -Zero prerequisites. The installer bundles the provider binary, Python 3.12 runtime, vllm-mlx, and Secure Enclave tooling. You pick a model from the catalog, link your account, and you're serving within minutes. +Zero prerequisites. The installer downloads a single signed bundle containing the +provider CLI, the Secure Enclave attestation helper, and the matching MLX +metallib. You pick a model from the catalog, link your account, and you're +serving within minutes. ### Provider CLI @@ -88,23 +91,13 @@ darkbloom status # Hardware and connection info darkbloom doctor # Diagnose issues darkbloom models list # Downloaded models darkbloom earnings # Earnings and usage +darkbloom benchmark # Local tok/s benchmark darkbloom update # Check for updates ``` -### macOS Menu Bar App - -A native SwiftUI app is also available: - -- One-click start/stop -- Real-time throughput display -- Idle detection (pauses when you're using your Mac) -- Provider scheduling (set hours your Mac serves, GPU memory freed between windows) -- Earnings dashboard -- Auto-start at login - ### Scheduling -Providers can configure time-based availability windows. Outside scheduled hours, the provider disconnects and shuts down the backend to free GPU memory. Configured in the app's Settings or directly in `~/.config/eigeninference/provider.toml`: +Providers can configure time-based availability windows. Outside scheduled hours, the provider disconnects and shuts down the backend to free GPU memory. Configure them in `~/.config/eigeninference/provider.toml`: ```toml [schedule] @@ -156,10 +149,9 @@ Attestation data is publicly verifiable at `GET /v1/providers/attestation`. | Component | Language | Role | |-----------|----------|------| | Coordinator (`coordinator/`) | Go | Control plane: routing, attestation, billing, API | -| Provider (`provider/`) | Rust | Inference agent: security, attestation, WebSocket client | +| Provider, legacy (`provider/`) | Rust | Inference agent in production today; retired at the Swift cutover | +| Provider, Swift (`provider-swift/`) | Swift | CLI-only Swift port; replaces `provider/` after cutover | | Console (`console-ui/`) | Next.js 16 | Web dashboard: chat, billing, provider verification | -| macOS App (`app/EigenInference/`) | Swift | Menu bar app: status, scheduling, earnings | -| Secure Enclave (`enclave/`) | Swift | Hardware-bound P-256 identity | | Landing (`landing/`) | HTML | Static landing page | ## Development @@ -168,11 +160,12 @@ Attestation data is publicly verifiable at `GET /v1/providers/attestation`. # Coordinator cd coordinator && go test ./... -# Provider (set PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 on Python 3.14+) +# Legacy Rust provider (set PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 on Python 3.14+) cd provider && cargo test -# macOS App -cd app/EigenInference && swift build -c release +# Swift provider (CLI) +cd provider-swift && swift test +cd provider-swift && swift build -c release # Console UI cd console-ui && npm install && npm run dev diff --git a/app/EigenInference/Package.swift b/app/EigenInference/Package.swift deleted file mode 100644 index b540a283..00000000 --- a/app/EigenInference/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "EigenInference", - platforms: [.macOS(.v14)], - dependencies: [], - targets: [ - .executableTarget( - name: "EigenInference", - path: "Sources/EigenInference", - resources: [ - .process("Resources"), - ] - ), - .testTarget( - name: "EigenInferenceTests", - dependencies: ["EigenInference"], - path: "Tests/EigenInferenceTests" - ), - ] -) diff --git a/app/EigenInference/Sources/EigenInference/CLIRunner.swift b/app/EigenInference/Sources/EigenInference/CLIRunner.swift deleted file mode 100644 index 0f7c6ff0..00000000 --- a/app/EigenInference/Sources/EigenInference/CLIRunner.swift +++ /dev/null @@ -1,225 +0,0 @@ -/// CLIRunner — Centralized utility for running darkbloom subcommands. -/// -/// Every feature in the app (doctor, wallet, earnings, enroll, models, status) -/// shells out to the Rust `darkbloom` binary. This class centralizes -/// Process management, binary resolution, and output capture. - -import Foundation - -/// Result of a CLI command execution. -struct CLIResult { - let exitCode: Int32 - let stdout: String - let stderr: String - - var success: Bool { exitCode == 0 } - - /// Combined stdout + stderr output. - var output: String { - [stdout, stderr].filter { !$0.isEmpty }.joined(separator: "\n") - } -} - -/// Runs darkbloom subcommands and captures output. -final class CLIRunner { - - /// Resolve the path to the darkbloom binary. - /// - /// Searches in order: - /// 1. `~/.darkbloom/bin/darkbloom` (shared install, preferred) - /// 2. Inside the app bundle (fallback for first-run before CLI install) - /// 3. PATH lookup - static func resolveBinaryPath() -> String? { - let fm = FileManager.default - - // 1. ~/.darkbloom/bin/darkbloom (shared with CLI — single source of truth) - let home = fm.homeDirectoryForCurrentUser - let homeBin = home.appendingPathComponent(".darkbloom/bin/darkbloom").path - if fm.isExecutableFile(atPath: homeBin) { - return homeBin - } - - // 2. Inside app bundle (fallback) - if let bundlePath = Bundle.main.executablePath { - let bundleDir = (bundlePath as NSString).deletingLastPathComponent - let adjacent = (bundleDir as NSString).appendingPathComponent("darkbloom") - if fm.isExecutableFile(atPath: adjacent) { - return adjacent - } - } - - // 3. PATH lookup - let whichProcess = Process() - let whichPipe = Pipe() - whichProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which") - whichProcess.arguments = ["darkbloom"] - whichProcess.standardOutput = whichPipe - whichProcess.standardError = Pipe() - do { - try whichProcess.run() - whichProcess.waitUntilExit() - if whichProcess.terminationStatus == 0 { - let data = whichPipe.fileHandleForReading.readDataToEndOfFile() - if let path = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty { - return path - } - } - } catch {} - - return nil - } - - /// Run a darkbloom subcommand and wait for completion. - /// - /// - Parameter args: Arguments to pass (e.g., `["doctor", "--coordinator", url]`) - /// - Returns: CLIResult with exit code, stdout, and stderr - static func run(_ args: [String]) async throws -> CLIResult { - guard let binaryPath = resolveBinaryPath() else { - return CLIResult( - exitCode: -1, - stdout: "", - stderr: "darkbloom binary not found" - ) - } - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: binaryPath) - proc.arguments = args - - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardOutput = outPipe - proc.standardError = errPipe - - // Inherit the user's PATH for finding python, vllm-mlx, etc. - var env = ProcessInfo.processInfo.environment - let home = FileManager.default.homeDirectoryForCurrentUser.path - let extraPaths = [ - "\(home)/.darkbloom/bin", - "\(home)/.darkbloom/python/bin", - "/opt/homebrew/bin", - "/usr/local/bin", - ] - let existingPath = env["PATH"] ?? "/usr/bin:/bin" - env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") - proc.environment = env - - do { - try proc.run() - proc.waitUntilExit() - - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - - let result = CLIResult( - exitCode: proc.terminationStatus, - stdout: String(data: outData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - stderr: String(data: errData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - ) - continuation.resume(returning: result) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Run a subcommand with streaming line-by-line output. - /// - /// - Parameters: - /// - args: Arguments for darkbloom - /// - onLine: Called for each line of combined stdout/stderr output - /// - Returns: The process for lifecycle management (caller should retain) - static func stream( - _ args: [String], - onLine: @escaping @Sendable (String) -> Void - ) throws -> Process { - guard let binaryPath = resolveBinaryPath() else { - throw CLIError.binaryNotFound - } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: binaryPath) - proc.arguments = args - - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardOutput = outPipe - proc.standardError = errPipe - - var env = ProcessInfo.processInfo.environment - let home = FileManager.default.homeDirectoryForCurrentUser.path - let extraPaths = ["\(home)/.darkbloom/bin", "\(home)/.darkbloom/python/bin", "/opt/homebrew/bin"] - let existingPath = env["PATH"] ?? "/usr/bin:/bin" - env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") - proc.environment = env - - let handleData: @Sendable (FileHandle) -> Void = { handle in - let data = handle.availableData - guard !data.isEmpty, - let text = String(data: data, encoding: .utf8) else { return } - for line in text.components(separatedBy: .newlines) where !line.isEmpty { - onLine(line) - } - } - - outPipe.fileHandleForReading.readabilityHandler = handleData - errPipe.fileHandleForReading.readabilityHandler = handleData - - try proc.run() - return proc - } - - /// Run a simple shell command (not darkbloom). - static func shell(_ command: String) async -> CLIResult { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/zsh") - proc.arguments = ["-c", command] - - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardOutput = outPipe - proc.standardError = errPipe - - do { - try proc.run() - proc.waitUntilExit() - - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - - continuation.resume(returning: CLIResult( - exitCode: proc.terminationStatus, - stdout: String(data: outData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - stderr: String(data: errData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - )) - } catch { - continuation.resume(returning: CLIResult( - exitCode: -1, stdout: "", stderr: error.localizedDescription - )) - } - } - } - } -} - -enum CLIError: LocalizedError { - case binaryNotFound - - var errorDescription: String? { - switch self { - case .binaryNotFound: - return "darkbloom binary not found. Run the installer first." - } - } -} diff --git a/app/EigenInference/Sources/EigenInference/ConfigManager.swift b/app/EigenInference/Sources/EigenInference/ConfigManager.swift deleted file mode 100644 index 77e64dc6..00000000 --- a/app/EigenInference/Sources/EigenInference/ConfigManager.swift +++ /dev/null @@ -1,208 +0,0 @@ -/// ConfigManager — Reads and writes the same provider.toml that the Rust CLI uses. -/// -/// The config file lives at `~/Library/Application Support/darkbloom/provider.toml` -/// (macOS `dirs::config_dir()` equivalent). Both the app and CLI read/write this -/// file, ensuring a single source of truth for all provider configuration. -/// -/// TOML structure: -/// [provider] -/// name = "eigeninference-mac16-1" -/// memory_reserve_gb = 4 -/// -/// [backend] -/// port = 8100 -/// model = "mlx-community/Qwen3.5-4B-4bit" -/// continuous_batching = true -/// enabled_models = [] -/// -/// [coordinator] -/// url = "wss://api.darkbloom.dev/ws/provider" -/// heartbeat_interval_secs = 30 - -import Foundation - -struct ProviderConfig: Equatable { - var providerName: String - var memoryReserveGB: Int - - var backendPort: Int - var backendModel: String? - var continuousBatching: Bool - var enabledModels: [String] - - var coordinatorURL: String - var heartbeatIntervalSecs: Int - - static let `default` = ProviderConfig( - providerName: "darkbloom", - memoryReserveGB: 4, - backendPort: 8100, - backendModel: nil, - continuousBatching: true, - enabledModels: [], - coordinatorURL: "wss://api.darkbloom.dev/ws/provider", - heartbeatIntervalSecs: 30 - ) -} - -enum ConfigManager { - - static var configPath: URL { - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first! - - let newPath = appSupport.appendingPathComponent("darkbloom").appendingPathComponent("provider.toml") - let legacyPath = appSupport.appendingPathComponent("eigeninference").appendingPathComponent("provider.toml") - - if FileManager.default.fileExists(atPath: newPath.path) { - return newPath - } - if FileManager.default.fileExists(atPath: legacyPath.path) { - return legacyPath - } - return newPath - } - - static var darkbloomDir: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".darkbloom") - } - - static func load() -> ProviderConfig { - guard FileManager.default.fileExists(atPath: configPath.path), - let content = try? String(contentsOf: configPath, encoding: .utf8) else { - return .default - } - return parse(content) - } - - static func save(_ config: ProviderConfig) throws { - let dir = configPath.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let toml = serialize(config) - try toml.write(to: configPath, atomically: true, encoding: .utf8) - } - - /// Update a single field in the config without touching others. - static func update(_ transform: (inout ProviderConfig) -> Void) throws { - var config = load() - transform(&config) - try save(config) - } - - // MARK: - TOML Parsing - - /// Parse the provider.toml format used by the Rust CLI. - /// Handles only the specific structure we use — not a general TOML parser. - static func parse(_ content: String) -> ProviderConfig { - var config = ProviderConfig.default - - var currentSection = "" - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentSection = String(trimmed.dropFirst().dropLast()) - .trimmingCharacters(in: .whitespaces) - continue - } - - guard let eqIndex = trimmed.firstIndex(of: "=") else { continue } - let key = String(trimmed[trimmed.startIndex.. String { - var lines: [String] = [] - - lines.append("[provider]") - lines.append("name = \(quote(config.providerName))") - lines.append("memory_reserve_gb = \(config.memoryReserveGB)") - lines.append("") - - lines.append("[backend]") - lines.append("port = \(config.backendPort)") - if let model = config.backendModel { - lines.append("model = \(quote(model))") - } - lines.append("continuous_batching = \(config.continuousBatching)") - let modelsArray = config.enabledModels.map { quote($0) }.joined(separator: ", ") - lines.append("enabled_models = [\(modelsArray)]") - lines.append("") - - lines.append("[coordinator]") - lines.append("url = \(quote(config.coordinatorURL))") - lines.append("heartbeat_interval_secs = \(config.heartbeatIntervalSecs)") - lines.append("") - - return lines.joined(separator: "\n") - } - - // MARK: - Helpers - - private static func quote(_ s: String) -> String { - "\"\(s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" - } - - private static func unquote(_ s: String) -> String { - var v = s - if v.hasPrefix("\"") && v.hasSuffix("\"") && v.count >= 2 { - v = String(v.dropFirst().dropLast()) - v = v.replacingOccurrences(of: "\\\"", with: "\"") - v = v.replacingOccurrences(of: "\\\\", with: "\\") - } - return v - } - - private static func parseArray(_ raw: String) -> [String] { - guard let open = raw.firstIndex(of: "["), - let close = raw.lastIndex(of: "]") else { return [] } - let inner = String(raw[raw.index(after: open).. 0 ? "\(viewModel.gpuCores)" : "--", - rotation: 0.4 - ) - hwCard( - icon: "arrow.left.arrow.right", color: .adaptiveTealAccent, - label: "Bandwidth", - value: viewModel.memoryBandwidthGBs > 0 ? "\(viewModel.memoryBandwidthGBs)" : "--", - detail: "GB/s", - rotation: -0.3 - ) - } - } - - private func hwCard( - icon: String, color: Color, - label: String, value: String, - detail: String? = nil, - rotation: Double = 0 - ) -> some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 36, height: 36) - .background(color, in: RoundedRectangle(cornerRadius: 10)) - .shadow(color: color.opacity(0.3), radius: 4, y: 2) - - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.labelWarm) - .foregroundStyle(color) - .textCase(.uppercase) - HStack(alignment: .firstTextBaseline, spacing: 3) { - Text(value) - .font(.system(size: 16, weight: .bold, design: .rounded)) - .foregroundStyle(Color.warmInk) - .lineLimit(1) - .minimumScaleFactor(0.7) - if let detail { - Text(detail) - .font(.captionWarm) - .foregroundStyle(Color.warmInkFaint) - } - } - } - Spacer(minLength: 0) - } - .warmCardAccent(color, padding: 12) - } - - // MARK: - Provider Status - - private var statusCard: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "server.rack") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 28, height: 28) - .background(Color.adaptiveCoral, in: RoundedRectangle(cornerRadius: 8)) - Text("Provider") - .font(.displaySmall) - .foregroundStyle(Color.warmInk) - Spacer() - } - - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 6) { - Text("MODEL") - .font(.labelWarm) - .foregroundStyle(Color.warmInkFaint) - Spacer() - } - Text(viewModel.currentModel.components(separatedBy: "/").last ?? viewModel.currentModel) - .font(.system(size: 14, weight: .bold, design: .rounded)) - .foregroundStyle(Color.warmInk) - .lineLimit(1) - - Divider() - - HStack(spacing: 6) { - Text("COORDINATOR") - .font(.labelWarm) - .foregroundStyle(Color.warmInkFaint) - Spacer() - } - HStack(spacing: 5) { - Circle() - .fill(viewModel.coordinatorConnected ? Color.adaptiveTealAccent : Color.adaptiveError) - .frame(width: 8, height: 8) - .shadow(color: (viewModel.coordinatorConnected ? Color.adaptiveTealAccent : Color.adaptiveError).opacity(0.5), radius: 4) - Text(viewModel.coordinatorConnected ? "Connected" : "Disconnected") - .font(.system(size: 14, weight: .bold, design: .rounded)) - .foregroundStyle(viewModel.coordinatorConnected ? Color.adaptiveTealAccent : Color.adaptiveError) - } - - if viewModel.isServing { - Divider() - HStack(spacing: 6) { - Text("THROUGHPUT") - .font(.labelWarm) - .foregroundStyle(Color.warmInkFaint) - Spacer() - } - Text(String(format: "%.1f tok/s", viewModel.tokensPerSecond)) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundStyle(Color.adaptiveTealAccent) - .monospacedDigit() - .contentTransition(.numericText()) - } - } - } - .warmCardAccent(.adaptiveCoral, padding: 16) - } - - // MARK: - Stats Row - - private var statsRow: some View { - HStack(spacing: 12) { - liveStatCard( - icon: "clock", color: .adaptiveBlueAccent, - label: "Uptime", - value: formatUptime(viewModel.uptimeSeconds) - ) - liveStatCard( - icon: "arrow.up.arrow.down", color: .adaptiveTealAccent, - label: "Requests", - value: "\(viewModel.requestsServed)" - ) - liveStatCard( - icon: "text.word.spacing", color: .adaptiveGold, - label: "Tokens", - value: formatTokenCount(viewModel.tokensGenerated) - ) - if !viewModel.earningsBalance.isEmpty { - liveStatCard( - icon: "dollarsign.circle", color: .adaptiveCoral, - label: "Earnings", - value: viewModel.earningsBalance - ) - } - } - } - - private func liveStatCard(icon: String, color: Color, label: String, value: String) -> some View { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .background(color, in: Circle()) - .shadow(color: color.opacity(0.3), radius: 4, y: 2) - - Text(value) - .font(.system(size: 18, weight: .bold, design: .rounded)) - .foregroundStyle(Color.warmInk) - .monospacedDigit() - .contentTransition(.numericText()) - .lineLimit(1) - .minimumScaleFactor(0.6) - Text(label) - .font(.labelWarm) - .foregroundStyle(color) - .textCase(.uppercase) - } - .frame(maxWidth: .infinity) - .warmCardAccent(color, padding: 14) - } - - // MARK: - Trust & Attestation - - private var trustCard: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "shield.checkered") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 28, height: 28) - .background(Color.adaptiveTealAccent, in: RoundedRectangle(cornerRadius: 8)) - Text("Trust & Attestation") - .font(.displaySmall) - .foregroundStyle(Color.warmInk) - Spacer() - trustBadge - if viewModel.securityManager.isChecking { - ProgressView() - .controlSize(.small) - } - Button { - Task { await viewModel.securityManager.refresh() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption) - .foregroundStyle(Color.warmInkFaint) - } - .buttonStyle(.borderless) - .pointerOnHover() - } - - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - ], spacing: 8) { - securityChip("Enclave", viewModel.securityManager.secureEnclaveAvailable) - securityChip("SIP", viewModel.securityManager.sipEnabled) - securityChip("Secure Boot", viewModel.securityManager.secureBootEnabled) - securityChip("MDM", viewModel.securityManager.mdmEnrolled) - securityChip("Node Key", viewModel.securityManager.nodeKeyExists) - securityChip("Binary", viewModel.securityManager.binaryFound) - } - } - .warmCardAccent(.adaptiveTealAccent, padding: 16) - } - - private var trustBadge: some View { - WarmBadge( - text: viewModel.securityManager.trustLevel.displayName, - color: trustColor, - icon: viewModel.securityManager.trustLevel.iconName - ) - } - - private func securityChip(_ label: String, _ enabled: Bool) -> some View { - HStack(spacing: 5) { - Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(enabled ? Color.adaptiveTealAccent : Color.adaptiveError) - Text(label) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(enabled ? Color.adaptiveInk : Color.adaptiveInkLight) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 7) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(enabled ? Color.adaptiveTealAccent.opacity(0.08) : Color.adaptiveError.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder((enabled ? Color.adaptiveTealAccent : Color.adaptiveError).opacity(0.15), lineWidth: 1) - ) - ) - } - - // MARK: - Action Bar - - private var actionBar: some View { - HStack(spacing: 10) { - actionButton("Diagnostics", icon: "stethoscope", color: .adaptiveBlueAccent, window: "doctor") - actionButton("Logs", icon: "doc.text", color: .adaptivePurpleAccent, window: "logs") - - if !viewModel.hasCompletedSetup { - Button { openWindow(id: "setup") } label: { - Label("Setup", systemImage: "wrench") - .font(.bodyWarm) - } - .buttonStyle(WarmButtonStyle(.adaptiveCoral)) - .pointerOnHover() - } - } - } - - private func actionButton(_ title: String, icon: String, color: Color, window: String) -> some View { - Button { openWindow(id: window) } label: { - Label(title, systemImage: icon) - .font(.bodyWarm) - } - .buttonStyle(WarmButtonStyle(color, filled: false)) - .pointerOnHover() - } - - // MARK: - Helpers - - private var statusIconName: String { - if viewModel.isPaused { return "pause.fill" } - if viewModel.isServing { return "bolt.fill" } - if viewModel.isOnline { return "checkmark" } - return "power" - } - - private var statusGradient: LinearGradient { - LinearGradient( - colors: statusGradientColors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - - private var statusGradientColors: [Color] { - if viewModel.isPaused { return [.adaptiveGold, .adaptiveGoldLight] } - if viewModel.isServing { return [.adaptiveCoral, .adaptiveGold] } - if viewModel.isOnline { return [.adaptiveCoral, .adaptiveCoralLight] } - return [.adaptiveInkFaint, .adaptiveInkFaint.opacity(0.7)] - } - - private var statusAccentColor: Color { - if viewModel.isPaused { return .adaptiveGold } - if viewModel.isServing { return .adaptiveTealAccent } - if viewModel.isOnline { return .adaptiveBlueAccent } - return .adaptiveInkFaint - } - - private var statusLabel: String { - if viewModel.isPaused { return "Paused" } - if viewModel.isServing { return "Serving" } - if viewModel.isOnline { return "Ready" } - return "Offline" - } - - private var providerStatusText: String { - if viewModel.isPaused { return "Paused" } - if viewModel.isServing { return "Serving inference" } - if viewModel.isOnline { return "Online, waiting" } - return "Offline" - } - - private var trustColor: Color { - switch viewModel.securityManager.trustLevel { - case .hardware: return .adaptiveTealAccent - case .none: return .adaptiveError - } - } - - private func formatUptime(_ seconds: Int) -> String { - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - if hours > 0 { return "\(hours)h \(minutes)m" } - if minutes > 0 { return "\(minutes)m" } - return "\(seconds)s" - } - - private func formatTokenCount(_ count: Int) -> String { - if count >= 1_000_000 { return String(format: "%.1fM", Double(count) / 1_000_000) } - if count >= 1_000 { return String(format: "%.1fK", Double(count) / 1_000) } - return "\(count)" - } -} diff --git a/app/EigenInference/Sources/EigenInference/DesignSystem.swift b/app/EigenInference/Sources/EigenInference/DesignSystem.swift deleted file mode 100644 index 789809ae..00000000 --- a/app/EigenInference/Sources/EigenInference/DesignSystem.swift +++ /dev/null @@ -1,455 +0,0 @@ -/// DesignSystem — Darkbloom design tokens matching console.darkbloom.dev. -/// -/// Light and dark mode palettes sourced from the EigenCloud Brand Book -/// via console-ui/src/app/globals.css. Provides unified colors, typography, -/// and reusable view modifiers. - -import SwiftUI - -// MARK: - Color Palette - -extension Color { - // Light mode backgrounds - static let bgPrimary = Color(red: 0.953, green: 0.953, blue: 0.953) // #F2F2F2 - static let bgSecondary = Color(red: 0.910, green: 0.910, blue: 0.910) // #E8E8E8 - static let bgTertiary = Color(red: 0.871, green: 0.871, blue: 0.871) // #DEDEDE - static let bgElevated = Color(red: 0.847, green: 0.863, blue: 0.898) // #D8DCE5 - static let bgHover = Color(red: 0.878, green: 0.878, blue: 0.894) // #E0E0E4 - static let bgWhite = Color.white // #FFFFFF - - // Light mode ink / text - static let inkPrimary = Color(red: 0.000, green: 0.000, blue: 0.000) // #000000 - static let inkLight = Color(red: 0.400, green: 0.435, blue: 0.486) // #666F7C - static let inkFaint = Color(red: 0.675, green: 0.694, blue: 0.737) // #ACB1BC - - // Light mode borders - static let borderDim = Color(red: 0.847, green: 0.863, blue: 0.898) // #D8DCE5 - static let borderSubtle = Color(red: 0.675, green: 0.694, blue: 0.737) // #ACB1BC - - // Brand accents (light mode) - static let brandAccent = Color(red: 0.102, green: 0.047, blue: 0.427) // #1A0C6D - static let brandAccentDim = Color(red: 0.102, green: 0.047, blue: 0.427).opacity(0.06) - static let brandAccentHover = Color(red: 0.075, green: 0.035, blue: 0.322) // #130952 - static let coral = Color(red: 0.102, green: 0.047, blue: 0.427) // #1A0C6D (brand = coral slot) - static let coralLight = Color(red: 0.102, green: 0.047, blue: 0.427).opacity(0.06) - static let tealAccent = Color(red: 0.176, green: 0.620, blue: 0.478) // #2D9E7A - static let tealLight = Color(red: 0.820, green: 0.980, blue: 0.898) // #D1FAE5 - static let tealDark = Color(red: 0.106, green: 0.420, blue: 0.314) // #1B6B50 - static let blueAccent = Color(red: 0.584, green: 0.749, blue: 1.000) // #95BFFF - static let blueLight = Color(red: 0.859, green: 0.918, blue: 0.996) // #DBEAFE - static let purpleAccent = Color(red: 0.486, green: 0.227, blue: 0.929) // #7C3AED - static let purpleLight = Color(red: 0.867, green: 0.839, blue: 0.996) // #DDD6FE - static let gold = Color(red: 0.851, green: 0.467, blue: 0.024) // #D97706 - static let goldLight = Color(red: 0.996, green: 0.953, blue: 0.780) // #FEF3C7 - - // Semantic (status) - static let warmSuccess = Color.tealAccent - static let warmWarning = Color.gold - static let warmError = Color(red: 0.863, green: 0.149, blue: 0.149) // #DC2626 - static let warmInfo = Color.blueAccent - - // Dark mode backgrounds - static let darkBgPrimary = Color(red: 0.059, green: 0.059, blue: 0.078) // #0F0F14 - static let darkBgSecondary = Color(red: 0.086, green: 0.086, blue: 0.122) // #16161F - static let darkBgTertiary = Color(red: 0.118, green: 0.118, blue: 0.165) // #1E1E2A - static let darkBgElevated = Color(red: 0.149, green: 0.149, blue: 0.212) // #262636 - static let darkBgHover = Color(red: 0.165, green: 0.165, blue: 0.235) // #2A2A3C - - // Dark mode ink / text - static let darkInkPrimary = Color(red: 0.910, green: 0.910, blue: 0.925) // #E8E8EC - static let darkInkLight = Color(red: 0.612, green: 0.639, blue: 0.686) // #9CA3AF - static let darkInkFaint = Color(red: 0.420, green: 0.447, blue: 0.502) // #6B7280 - - // Dark mode borders - static let darkBorderDim = Color(red: 0.149, green: 0.149, blue: 0.212) // #262636 - static let darkBorderSubtle = Color(red: 0.212, green: 0.212, blue: 0.282) // #363648 - - // Dark mode brand accents - static let darkBrandAccent = Color(red: 0.506, green: 0.549, blue: 0.973) // #818CF8 - static let darkBrandAccentDim = Color(red: 0.506, green: 0.549, blue: 0.973).opacity(0.10) - static let darkBrandAccentHover = Color(red: 0.388, green: 0.400, blue: 0.945) // #6366F1 - static let darkCoral = Color(red: 0.506, green: 0.549, blue: 0.973) // #818CF8 - static let darkCoralLight = Color(red: 0.506, green: 0.549, blue: 0.973).opacity(0.10) - static let darkTealAccent = Color(red: 0.204, green: 0.827, blue: 0.600) // #34D399 - static let darkTealLight = Color(red: 0.204, green: 0.827, blue: 0.600).opacity(0.12) - static let darkTealDark = Color(red: 0.431, green: 0.906, blue: 0.718) // #6EE7B7 - static let darkBlueAccent = Color(red: 0.576, green: 0.773, blue: 0.992) // #93C5FD - static let darkBlueLight = Color(red: 0.576, green: 0.773, blue: 0.992).opacity(0.12) - static let darkPurpleAccent = Color(red: 0.655, green: 0.545, blue: 0.980) // #A78BFA - static let darkPurpleLight = Color(red: 0.655, green: 0.545, blue: 0.980).opacity(0.12) - static let darkGold = Color(red: 0.984, green: 0.749, blue: 0.141) // #FBBF24 - static let darkGoldLight = Color(red: 0.984, green: 0.749, blue: 0.141).opacity(0.12) - static let darkError = Color(red: 0.973, green: 0.443, blue: 0.443) // #F87171 -} - -// MARK: - Adaptive Colors (respond to light/dark) - -extension Color { - static var adaptiveBgPrimary: Color { - Color(light: .bgPrimary, dark: .darkBgPrimary) - } - static var adaptiveBgSecondary: Color { - Color(light: .bgSecondary, dark: .darkBgSecondary) - } - static var adaptiveBgTertiary: Color { - Color(light: .bgTertiary, dark: .darkBgTertiary) - } - static var adaptiveBgElevated: Color { - Color(light: .bgElevated, dark: .darkBgElevated) - } - static var adaptiveBgHover: Color { - Color(light: .bgHover, dark: .darkBgHover) - } - static var adaptiveBgWhite: Color { - Color(light: .bgWhite, dark: .darkBgTertiary) - } - - static var adaptiveInk: Color { - Color(light: .inkPrimary, dark: .darkInkPrimary) - } - static var adaptiveInkLight: Color { - Color(light: .inkLight, dark: .darkInkLight) - } - static var adaptiveInkFaint: Color { - Color(light: .inkFaint, dark: .darkInkFaint) - } - - static var adaptiveBorderDim: Color { - Color(light: .borderDim, dark: .darkBorderDim) - } - static var adaptiveBorderSubtle: Color { - Color(light: .borderSubtle, dark: .darkBorderSubtle) - } - - static var adaptiveBrand: Color { - Color(light: .brandAccent, dark: .darkBrandAccent) - } - static var adaptiveBrandDim: Color { - Color(light: .brandAccentDim, dark: .darkBrandAccentDim) - } - static var adaptiveBrandHover: Color { - Color(light: .brandAccentHover, dark: .darkBrandAccentHover) - } - - static var adaptiveCoral: Color { - Color(light: .coral, dark: .darkCoral) - } - static var adaptiveCoralLight: Color { - Color(light: .coralLight, dark: .darkCoralLight) - } - static var adaptiveTealAccent: Color { - Color(light: .tealAccent, dark: .darkTealAccent) - } - static var adaptiveTealLight: Color { - Color(light: .tealLight, dark: .darkTealLight) - } - static var adaptiveTealDark: Color { - Color(light: .tealDark, dark: .darkTealDark) - } - static var adaptiveBlueAccent: Color { - Color(light: .blueAccent, dark: .darkBlueAccent) - } - static var adaptiveBlueLight: Color { - Color(light: .blueLight, dark: .darkBlueLight) - } - static var adaptivePurpleAccent: Color { - Color(light: .purpleAccent, dark: .darkPurpleAccent) - } - static var adaptivePurpleLight: Color { - Color(light: .purpleLight, dark: .darkPurpleLight) - } - static var adaptiveGold: Color { - Color(light: .gold, dark: .darkGold) - } - static var adaptiveGoldLight: Color { - Color(light: .goldLight, dark: .darkGoldLight) - } - - static var adaptiveError: Color { - Color(light: .warmError, dark: .darkError) - } -} - -// MARK: - Adaptive Color Helper - -/// Creates a Color that adapts to light/dark appearance using NSColor -/// with dynamic provider. This ensures the color updates live when the -/// system appearance changes. -extension Color { - init(light: Color, dark: Color) { - self.init(NSColor(name: nil, dynamicProvider: { appearance in - if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { - return NSColor(dark) - } else { - return NSColor(light) - } - })) - } -} - -// MARK: - ShapeStyle convenience - -extension ShapeStyle where Self == Color { - static var bgPrimary: Color { Color.bgPrimary } - static var bgSecondary: Color { Color.bgSecondary } - static var bgTertiary: Color { Color.bgTertiary } - static var bgElevated: Color { Color.bgElevated } - static var bgHover: Color { Color.bgHover } - static var bgWhite: Color { Color.bgWhite } - static var inkPrimary: Color { Color.inkPrimary } - static var inkLight: Color { Color.inkLight } - static var inkFaint: Color { Color.inkFaint } - static var borderDim: Color { Color.borderDim } - static var borderSubtle: Color { Color.borderSubtle } - static var brandAccent: Color { Color.brandAccent } - static var coral: Color { Color.coral } - static var coralLight: Color { Color.coralLight } - static var tealAccent: Color { Color.tealAccent } - static var tealLight: Color { Color.tealLight } - static var tealDark: Color { Color.tealDark } - static var blueAccent: Color { Color.blueAccent } - static var blueLight: Color { Color.blueLight } - static var purpleAccent: Color { Color.purpleAccent } - static var purpleLight: Color { Color.purpleLight } - static var gold: Color { Color.gold } - static var goldLight: Color { Color.goldLight } - static var warmSuccess: Color { Color.warmSuccess } - static var warmWarning: Color { Color.warmWarning } - static var warmError: Color { Color.warmError } - static var warmInfo: Color { Color.warmInfo } -} - -// MARK: - Backward-compatible aliases (warm* → adaptive*) - -extension Color { - static var warmBg: Color { adaptiveBgPrimary } - static var warmBgSecondary: Color { adaptiveBgSecondary } - static var warmBgTertiary: Color { adaptiveBgTertiary } - static var warmBgElevated: Color { adaptiveBgElevated } - static var warmInk: Color { adaptiveInk } - static var warmInkLight: Color { adaptiveInkLight } - static var warmInkFaint: Color { adaptiveInkFaint } -} - -// MARK: - Typography - -extension Font { - static func display(_ size: CGFloat, weight: Font.Weight = .bold) -> Font { - .system(size: size, weight: weight, design: .default) - } - - static let displayLarge = Font.display(28) - static let displayMedium = Font.display(22) - static let displaySmall = Font.display(18) - - static let bodyWarm = Font.system(size: 13, weight: .medium) - static let captionWarm = Font.system(size: 11, weight: .medium) - static let labelWarm = Font.system(size: 11, weight: .bold) - static let monoWarm = Font.system(size: 12, weight: .medium, design: .monospaced) -} - -// MARK: - Card Modifier (adapts to light/dark) - -struct WarmCardModifier: ViewModifier { - var padding: CGFloat - var borderColor: Color - var hasShadow: Bool - - init(padding: CGFloat = 14, borderColor: Color = .adaptiveBorderDim, hasShadow: Bool = true) { - self.padding = padding - self.borderColor = borderColor - self.hasShadow = hasShadow - } - - func body(content: Content) -> some View { - content - .padding(padding) - .background(Color.adaptiveBgSecondary, in: RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder(borderColor, lineWidth: 1.5) - ) - .shadow( - color: hasShadow ? Color.adaptiveInk.opacity(0.04) : .clear, - radius: 2, x: 0, y: 1 - ) - } -} - -extension View { - func warmCard(padding: CGFloat = 14, border: Color = .adaptiveBorderDim) -> some View { - modifier(WarmCardModifier(padding: padding, borderColor: border)) - } - - func warmCardAccent(_ accent: Color, padding: CGFloat = 14) -> some View { - modifier(WarmCardModifier(padding: padding, borderColor: accent.opacity(0.3))) - } -} - -// MARK: - Status Badge - -struct WarmBadge: View { - let text: String - let color: Color - var icon: String? = nil - - var body: some View { - HStack(spacing: 5) { - if let icon { - Image(systemName: icon) - .font(.system(size: 10, weight: .bold)) - } - Text(text) - .font(.system(size: 11, weight: .bold)) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .foregroundStyle(color) - .background(color.opacity(0.12), in: Capsule()) - .overlay(Capsule().strokeBorder(color.opacity(0.25), lineWidth: 1.5)) - } -} - -// MARK: - Button Style - -struct WarmButtonStyle: ButtonStyle { - var color: Color - var filled: Bool - - init(_ color: Color = .adaptiveCoral, filled: Bool = true) { - self.color = color - self.filled = filled - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.system(size: 13, weight: .bold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .foregroundStyle(filled ? .white : color) - .background(filled ? color : Color.clear, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(filled ? color : color.opacity(0.4), lineWidth: 2) - ) - .shadow( - color: configuration.isPressed ? .clear : Color.adaptiveInk.opacity(0.06), - radius: 0, x: 0, y: configuration.isPressed ? 0 : 1 - ) - .offset( - x: configuration.isPressed ? 0 : 0, - y: configuration.isPressed ? 1 : 0 - ) - .animation(.easeOut(duration: 0.1), value: configuration.isPressed) - } -} - -// MARK: - Stat Card - -struct WarmStatCard: View { - let icon: String - let label: String - let value: String - var detail: String? = nil - var iconColor: Color = .adaptiveCoral - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(iconColor) - .frame(width: 24, height: 24) - .background(iconColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) - Text(label) - .font(.captionWarm) - .foregroundStyle(Color.adaptiveInkLight) - } - Text(value) - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(Color.adaptiveInk) - .monospacedDigit() - .contentTransition(.numericText()) - if let detail { - Text(detail) - .font(.captionWarm) - .foregroundStyle(Color.adaptiveInkFaint) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .warmCard(padding: 12) - } -} - -// MARK: - Section Header - -struct WarmSectionHeader: View { - let title: String - var icon: String? = nil - var color: Color = .adaptiveInk - - var body: some View { - HStack(spacing: 6) { - if let icon { - Image(systemName: icon) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(color.opacity(0.6)) - } - Text(title) - .font(.displaySmall) - .foregroundStyle(color) - } - } -} - -// MARK: - Pointer Cursor on Hover - -struct PointerCursorModifier: ViewModifier { - func body(content: Content) -> some View { - content.onHover { hovering in - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } -} - -extension View { - func pointerOnHover() -> some View { - modifier(PointerCursorModifier()) - } -} - -// MARK: - DarkBloom Brand Name - -struct DarkBloomBrand: View { - var size: CGFloat = 18 - - var body: some View { - HStack(spacing: 0) { - Text("Dark") - .font(.display(size)) - .foregroundStyle(Color.adaptiveInk) - Text("bloom") - .font(.display(size)) - .foregroundStyle(Color.adaptiveBrand) - } - } -} - -// MARK: - Background Modifier - -struct WarmBackground: ViewModifier { - func body(content: Content) -> some View { - content - .background(Color.adaptiveBgPrimary) - } -} - -extension View { - func warmBackground() -> some View { - modifier(WarmBackground()) - } -} diff --git a/app/EigenInference/Sources/EigenInference/DoctorView.swift b/app/EigenInference/Sources/EigenInference/DoctorView.swift deleted file mode 100644 index 56ac5d6a..00000000 --- a/app/EigenInference/Sources/EigenInference/DoctorView.swift +++ /dev/null @@ -1,212 +0,0 @@ -/// DoctorView — Displays results from `darkbloom doctor`. -/// -/// Runs the 8-point diagnostic check and shows each result with -/// a status icon and detail text. Provides remediation hints. - -import SwiftUI - -struct DoctorView: View { - @ObservedObject var viewModel: StatusViewModel - @State private var checks: [DiagnosticCheck] = [] - @State private var isRunning = false - @State private var rawOutput = "" - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Provider Diagnostics") - .font(.display(22)) - .fontWeight(.bold) - - Spacer() - - Button { - Task { await runDoctor() } - } label: { - Label(isRunning ? "Running..." : "Run Again", systemImage: "arrow.clockwise") - } - .disabled(isRunning) - .buttonStyle(.bordered) - } - - if isRunning { - HStack { - ProgressView().controlSize(.small) - Text("Running diagnostics...") - .foregroundColor(.warmInkLight) - } - } - - if !checks.isEmpty { - VStack(spacing: 8) { - ForEach(checks) { check in - checkRow(check) - } - } - } - - if !rawOutput.isEmpty { - Divider() - - DisclosureGroup("Raw Output") { - Text(rawOutput) - .font(.monoWarm) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .background(Color.warmBgSecondary) - .cornerRadius(6) - .textSelection(.enabled) - } - } - - Spacer() - } - .padding(20) - } - .frame(minWidth: 500, minHeight: 400) - .task { - await runDoctor() - } - } - - private func checkRow(_ check: DiagnosticCheck) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: check.passed ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundColor(check.passed ? .adaptiveTealAccent : .adaptiveError) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 4) { - Text(check.name) - .fontWeight(.medium) - - Text(check.detail) - .font(.captionWarm) - .foregroundColor(.warmInkLight) - - if !check.passed, let hint = check.remediation { - Text(hint) - .font(.captionWarm) - .foregroundColor(.adaptiveGold) - } - } - - Spacer() - } - .padding(10) - .background(check.passed ? Color.adaptiveTealAccent.opacity(0.05) : Color.adaptiveError.opacity(0.05)) - .cornerRadius(8) - } - - private func runDoctor() async { - isRunning = true - checks = [] - rawOutput = "" - - do { - let result = try await CLIRunner.run(["doctor"]) - rawOutput = result.output - checks = parseDoctorOutput(result.output) - } catch { - rawOutput = "Failed to run doctor: \(error.localizedDescription)" - } - - isRunning = false - } - - /// Parse doctor output into structured checks. - /// - /// Expected format from the CLI: - /// 1. Hardware ............... Apple M4 Max, 64 GB, 40 GPU cores - /// 2. SIP ................... ✓ Enabled - /// 3. Secure Enclave ........ ✓ Available - /// etc. - private func parseDoctorOutput(_ output: String) -> [DiagnosticCheck] { - var result: [DiagnosticCheck] = [] - - for line in output.components(separatedBy: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // Match lines like "1. Check name .... status" - guard let dotIndex = trimmed.firstIndex(of: "."), - let stepNum = Int(String(trimmed[trimmed.startIndex..= 2 else { - // Try splitting on multiple spaces - let spaceParts = afterDot.components(separatedBy: " ") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - if spaceParts.count >= 2 { - let name = spaceParts[0] - let detail = spaceParts[1...].joined(separator: " ") - let passed = detail.contains("✓") || detail.contains("✅") || - detail.lowercased().contains("enabled") || - detail.lowercased().contains("available") || - detail.lowercased().contains("found") || - detail.lowercased().contains("ok") || - detail.lowercased().contains("connected") - result.append(DiagnosticCheck( - id: stepNum, - name: name, - detail: detail.replacingOccurrences(of: "✓ ", with: "").replacingOccurrences(of: "✗ ", with: ""), - passed: passed, - remediation: passed ? nil : remediationHint(for: stepNum) - )) - continue - } - continue - } - - let name = parts[0].trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let detail = parts.dropFirst().joined(separator: "").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: ". ")) - - let passed = detail.contains("✓") || detail.contains("✅") || - !detail.contains("✗") && !detail.contains("❌") && - !detail.lowercased().contains("not found") && - !detail.lowercased().contains("disabled") && - !detail.lowercased().contains("failed") && - !detail.lowercased().contains("not enrolled") && - !detail.lowercased().contains("error") - - result.append(DiagnosticCheck( - id: stepNum, - name: name, - detail: detail.replacingOccurrences(of: "✓ ", with: "").replacingOccurrences(of: "✗ ", with: ""), - passed: passed, - remediation: passed ? nil : remediationHint(for: stepNum) - )) - } - - return result - } - - private func remediationHint(for step: Int) -> String { - switch step { - case 1: return "Apple Silicon Mac required." - case 2: return "Reboot into Recovery Mode and run 'csrutil enable'." - case 3: return "Secure Enclave requires Apple Silicon hardware." - case 4: return "Run the setup wizard to enroll in MDM." - case 5: return "Run 'darkbloom install' to set up the inference runtime." - case 6: return "Download a model from the Model tab in Settings." - case 7: return "The node key is auto-generated on first run." - case 8: return "Check your internet connection and coordinator URL." - default: return "" - } - } -} - -struct DiagnosticCheck: Identifiable { - let id: Int - let name: String - let detail: String - let passed: Bool - let remediation: String? -} diff --git a/app/EigenInference/Sources/EigenInference/EigenInferenceApp.swift b/app/EigenInference/Sources/EigenInference/EigenInferenceApp.swift deleted file mode 100644 index 40a0f193..00000000 --- a/app/EigenInference/Sources/EigenInference/EigenInferenceApp.swift +++ /dev/null @@ -1,230 +0,0 @@ -/// DarkBloomApp — Main entry point for the Darkbloom macOS menu bar application. -/// -/// Menu-bar-only app (no dock icon) that wraps the Rust `darkbloom` -/// binary. Uses SwiftUI's MenuBarExtra (macOS 13+) for the status icon. -/// -/// Activation policy management: -/// When only the menu bar is showing → .accessory (no dock icon) -/// When any window is open → .regular (dock icon, full focus, text selectable) -/// When last window closes → back to .accessory -/// -/// Scenes: -/// - MenuBarExtra: Persistent menu bar icon and dropdown -/// - Settings: Standard macOS settings window (Cmd+,) -/// - Dashboard: Detailed statistics window -/// - Setup: First-run onboarding wizard -/// - Doctor: Diagnostic results -/// - Logs: Streaming log viewer -/// - Logs: Provider log viewer - -import SwiftUI - -@main -struct DarkBloomApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @StateObject private var viewModel = StatusViewModel() - - var body: some Scene { - MenuBarExtra { - MenuBarView(viewModel: viewModel) - } label: { - menuBarLabel - } - .menuBarExtraStyle(.window) - - Settings { - SettingsView(viewModel: viewModel) - } - - Window("Dashboard", id: "dashboard") { - DashboardView(viewModel: viewModel) - .textSelection(.enabled) - } - - Window("Setup", id: "setup") { - SetupWizardView(viewModel: viewModel) - .textSelection(.enabled) - } - - Window("Diagnostics", id: "doctor") { - DoctorView(viewModel: viewModel) - .textSelection(.enabled) - } - - Window("Logs", id: "logs") { - LogViewerView(viewModel: viewModel) - .textSelection(.enabled) - } - - } - - private var eigenLogo: NSImage? { - guard let url = Bundle.module.url(forResource: "MenuBarIcon@2x", withExtension: "png"), - let data = try? Data(contentsOf: url), - let bitmap = NSBitmapImageRep(data: data), - let cgImage = bitmap.cgImage else { return nil } - let icon = NSImage(cgImage: cgImage, size: NSSize(width: 18, height: 18)) - icon.isTemplate = true - return icon - } - - private var menuBarLabel: some View { - HStack(spacing: 4) { - if let logo = eigenLogo { - Image(nsImage: logo) - .frame(width: 18, height: 18) - .clipped() - } else { - Image(systemName: "circle") - .foregroundColor(menuBarColor) - } - if viewModel.isServing { - Text(formatThroughput(viewModel.tokensPerSecond)) - .font(.captionWarm) - .monospacedDigit() - .contentTransition(.numericText()) - } - if viewModel.updateManager.updateAvailable { - Circle() - .fill(Color.adaptiveGold) - .frame(width: 6, height: 6) - } - } - .animation(.smooth, value: viewModel.isServing) - .animation(.smooth, value: viewModel.tokensPerSecond) - } - - private var menuBarColor: Color { - if viewModel.isPaused { return .yellow } - if viewModel.isOnline { return .green } - return .gray - } - - private func formatThroughput(_ tps: Double) -> String { - if tps >= 1000 { return String(format: "%.1fK tok/s", tps / 1000) } - return String(format: "%.0f tok/s", tps) - } -} - -// MARK: - AppDelegate (activation policy management) - -/// Manages the app's activation policy so windows behave like a real app. -/// -/// Menu-bar-only SwiftUI apps run as `.accessory` by default, which means -/// windows don't receive focus, text isn't selectable, and windows layer -/// behind other apps. This delegate watches for window open/close events -/// and switches to `.regular` when any window is visible, giving the app -/// full focus, a dock icon, and proper window management. -final class AppDelegate: NSObject, NSApplicationDelegate { - - private var observers: [NSObjectProtocol] = [] - - func applicationDidFinishLaunching(_ notification: Notification) { - // Start as accessory (no dock icon, menu bar only) - NSApplication.shared.setActivationPolicy(.accessory) - - // Wire the telemetry reporter as early as possible so even - // initialization errors can be reported. - configureTelemetry() - - // Uncaught Objective-C exceptions — rare in Swift but still possible - // when bridging into AppKit. - NSSetUncaughtExceptionHandler { exception in - TelemetryReporter.shared.emit( - kind: .panic, - severity: .fatal, - message: "uncaught NSException: \(exception.name.rawValue)", - fields: [ - "component": "app", - "reason": "ns_exception", - "error": exception.reason ?? "", - ], - stack: exception.callStackSymbols.joined(separator: "\n") - ) - TelemetryReporter.shared.flushNow() - } - - // Watch for windows appearing/disappearing - let center = NotificationCenter.default - - observers.append( - center.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.activateIfNeeded() - } - ) - - observers.append( - center.addObserver( - forName: NSWindow.willCloseNotification, - object: nil, - queue: .main - ) { [weak self] _ in - // Delay slightly so the window has time to close - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self?.deactivateIfNoWindows() - } - } - ) - } - - /// Switch to .regular and activate when a real window appears. - private func activateIfNeeded() { - guard hasVisibleWindows() else { return } - if NSApplication.shared.activationPolicy() != .regular { - NSApplication.shared.setActivationPolicy(.regular) - } - NSApplication.shared.activate(ignoringOtherApps: true) - } - - /// Switch back to .accessory when all windows are closed. - private func deactivateIfNoWindows() { - guard !hasVisibleWindows() else { return } - NSApplication.shared.setActivationPolicy(.accessory) - } - - /// Check if any "real" windows are visible (excludes menu bar panels, status items, etc.) - private func hasVisibleWindows() -> Bool { - NSApplication.shared.windows.contains { window in - window.isVisible - && window.level == .normal - && !(window is NSPanel) - && window.styleMask.contains(.titled) - } - } - - deinit { - observers.forEach { NotificationCenter.default.removeObserver($0) } - } - - /// Configure TelemetryReporter with the coordinator URL derived from the - /// provider config. We intentionally accept a plain HTTPS base URL even - /// though the provider config uses `wss://` for the WebSocket — the - /// telemetry endpoint lives on a different scheme/path. - private func configureTelemetry() { - let cfg = ConfigManager.load() - // provider config holds something like "wss://api.darkbloom.dev/ws/provider". - // Convert to the HTTPS base the ingest endpoint lives under. - let httpsBase = Self.httpsBase(from: cfg.coordinatorURL) - TelemetryReporter.shared.coordinatorBaseURL = httpsBase - } - - /// Translate a `wss://host[:port]/...` URL into `https://host[:port]/`. - /// Falls back to the production endpoint if parsing fails. - static func httpsBase(from ws: String) -> URL? { - guard var comps = URLComponents(string: ws) else { - return URL(string: "https://api.darkbloom.dev") - } - comps.path = "" - comps.query = nil - switch comps.scheme { - case "wss": comps.scheme = "https" - case "ws": comps.scheme = "http" - default: break - } - return comps.url - } -} diff --git a/app/EigenInference/Sources/EigenInference/GuideAvatar.swift b/app/EigenInference/Sources/EigenInference/GuideAvatar.swift deleted file mode 100644 index 04ae890e..00000000 --- a/app/EigenInference/Sources/EigenInference/GuideAvatar.swift +++ /dev/null @@ -1,230 +0,0 @@ -/// GuideAvatar — Animated mascot that guides users through onboarding. -/// -/// Uses SF Symbols as a placeholder. Replace the avatar image with -/// AI-generated art by adding "guide-avatar" to the asset catalog -/// and updating the `avatarImage` computed property. -/// -/// The avatar has moods that change its expression and the speech -/// bubble color, making the onboarding feel alive and responsive. - -import SwiftUI - -// MARK: - Avatar Mood - -enum AvatarMood { - case greeting // Welcome, first introduction - case explaining // Neutral, giving information - case excited // Something good happened (check passed, model downloaded) - case thinking // Processing, waiting - case concerned // Warning or issue detected - case celebrating // All done, success! - - var color: Color { - switch self { - case .greeting: return .blueAccent - case .explaining: return .warmInkLight - case .excited: return .tealAccent - case .thinking: return .gold - case .concerned: return .warmError - case .celebrating: return .tealAccent - } - } - - var imageSuffix: String { - switch self { - case .greeting: return "greeting" - case .explaining: return "explaining" - case .excited: return "excited" - case .thinking: return "thinking" - case .concerned: return "concerned" - case .celebrating: return "celebrating" - } - } - - var symbol: String { - switch self { - case .greeting: return "face.smiling" - case .explaining: return "bubble.left.fill" - case .excited: return "hands.clap.fill" - case .thinking: return "ellipsis.circle.fill" - case .concerned: return "exclamationmark.triangle.fill" - case .celebrating: return "party.popper.fill" - } - } -} - -// MARK: - Guide Avatar View - -struct GuideAvatarView: View { - let mood: AvatarMood - let message: String - var detail: String? - - @State private var appeared = false - - var body: some View { - HStack(alignment: .top, spacing: 12) { - // Avatar - avatarImage - .scaleEffect(appeared ? 1.0 : 0.5) - .opacity(appeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared) - - // Speech bubble - VStack(alignment: .leading, spacing: 4) { - Text(message) - .font(.bodyWarm) - .fontWeight(.medium) - .fixedSize(horizontal: false, vertical: true) - - if let detail { - Text(detail) - .font(.captionWarm) - .foregroundColor(.warmInkLight) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(12) - .background(bubbleBackground) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .offset(y: appeared ? 0 : 10) - .opacity(appeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.8).delay(0.15), value: appeared) - } - .onAppear { appeared = true } - .onChange(of: message) { _, _ in - appeared = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - appeared = true - } - } - } - - // MARK: - Avatar Image - - @ViewBuilder - private var avatarImage: some View { - // Load mood-specific avatar from bundle resources - let moodImageName = "guide-avatar-\(mood.imageSuffix)" - let fallbackImageName = "guide-avatar" - - if let url = Bundle.module.url(forResource: moodImageName, withExtension: "png"), - let nsImage = NSImage(contentsOf: url) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(mood.color.opacity(0.3), lineWidth: 2)) - .shadow(color: mood.color.opacity(0.3), radius: 6) - } else if let url = Bundle.module.url(forResource: fallbackImageName, withExtension: "png"), - let nsImage = NSImage(contentsOf: url) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(mood.color.opacity(0.3), lineWidth: 2)) - .shadow(color: mood.color.opacity(0.3), radius: 6) - } else { - // Hand-drawn cartoon Mac character - CartoonMac(mood: mood, size: 64) - .shadow(color: mood.color.opacity(0.2), radius: 8) - } - } - - // MARK: - Bubble Background - - @ViewBuilder - private var bubbleBackground: some View { - if #available(macOS 26.0, *) { - Color.clear - .glassEffect(.regular.tint(mood.color.opacity(0.08)), in: .rect(cornerRadius: 12)) - } else { - mood.color.opacity(0.08) - } - } -} - -// MARK: - Guide Messages - -enum GuideMessages { - static func welcome(chipName: String, memoryGB: Int) -> (message: String, detail: String) { - ( - "Hey! Let's set up your Mac to earn money.", - "Your \(chipName) with \(memoryGB)GB is perfect for serving AI inference. This takes about 2 minutes." - ) - } - - static func security(allPassed: Bool) -> (message: String, detail: String) { - if allPassed { - return ( - "Security checks passed!", - "Your Mac has all the protections needed to safely process AI requests." - ) - } else { - return ( - "Let's check your security settings.", - "Darkbloom needs a few macOS security features to protect the prompts being processed." - ) - } - } - - static func mdm(enrolled: Bool) -> (message: String, detail: String) { - if enrolled { - return ( - "You're verified!", - "Your Mac is enrolled and the coordinator can verify it's genuine Apple hardware." - ) - } else { - return ( - "One quick step for hardware trust.", - "This installs a lightweight profile so we can verify your Mac is genuine. It's read-only and you can remove it anytime." - ) - } - } - - static func model(memoryGB: Int) -> (message: String, detail: String) { - let recommendation: String - if memoryGB >= 64 { - recommendation = "With \(memoryGB)GB, you can run the big models. More parameters = more earnings per request." - } else if memoryGB >= 32 { - recommendation = "With \(memoryGB)GB, you've got solid options. I'd go with the 14B — great balance of quality and speed." - } else if memoryGB >= 16 { - recommendation = "The 9B model is perfect for \(memoryGB)GB — fast, capable, and fits comfortably." - } else { - recommendation = "The 0.5B model is lightweight and quick. Great for getting started!" - } - return ( - "Pick a model to serve.", - recommendation - ) - } - - static func downloading(modelName: String) -> (message: String, detail: String) { - ( - "Downloading \(modelName)...", - "This is a one-time download. Grab a coffee — larger models take a few minutes." - ) - } - - static func verify(passed: Bool) -> (message: String, detail: String) { - if passed { - return ( - "Everything looks great!", - "All checks passed. You're ready to start earning." - ) - } else { - return ( - "Almost there!", - "A few things need attention, but you can still start serving. Check the details below." - ) - } - } - - static let ready = ( - message: "You're all set!", - detail: "Your Mac will start serving AI inference in the background. You'll earn while it's idle — and your Mac stays fully usable." - ) -} - diff --git a/app/EigenInference/Sources/EigenInference/IdleDetector.swift b/app/EigenInference/Sources/EigenInference/IdleDetector.swift deleted file mode 100644 index 49b76b7f..00000000 --- a/app/EigenInference/Sources/EigenInference/IdleDetector.swift +++ /dev/null @@ -1,68 +0,0 @@ -/// IdleDetector — Monitors user keyboard/mouse activity. -/// -/// Darkbloom should only serve inference when the user isn't actively using -/// their Mac. This class polls the system's idle time (seconds since last -/// keyboard/mouse event) on a 10-second interval and publishes whether -/// the user is idle. -/// -/// The idle timeout is configurable (default 5 minutes). When the user -/// transitions between active and idle, StatusViewModel observes the -/// change and pauses/resumes the provider accordingly. -/// -/// Implementation note: -/// Uses `CGEventSource.secondsSinceLastEventType(.hidSystemState, ...)` -/// which requires no special permissions (unlike accessibility APIs). -/// The `CGEventType(rawValue: ~0)!` value acts as a wildcard for any -/// input event type. - -import Combine -import CoreGraphics -import Foundation - -/// Polls system idle time and publishes user idle state. -/// -/// The timer fires every 10 seconds and checks seconds since last -/// keyboard/mouse input. When idle time exceeds `idleTimeoutSeconds`, -/// `isUserIdle` flips to true. -@MainActor -final class IdleDetector: ObservableObject { - - /// Whether the user is currently idle (no keyboard/mouse input - /// for longer than `idleTimeoutSeconds`). - @Published var isUserIdle = false - - /// How many seconds of inactivity before the user is considered idle. - /// Default: 300 seconds (5 minutes). - var idleTimeoutSeconds: TimeInterval = 300 - - private var timer: Timer? - - /// Start polling for idle state every 10 seconds. - func start() { - stop() - timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.checkIdleState() - } - } - // Check immediately - checkIdleState() - } - - /// Stop polling. - func stop() { - timer?.invalidate() - timer = nil - } - - /// Query the system for seconds since last input event and update state. - private func checkIdleState() { - // CGEventType(rawValue: ~0) matches any input event type (keyboard, mouse, etc.) - // .hidSystemState gives system-wide idle time without needing accessibility permissions - let idleTime = CGEventSource.secondsSinceLastEventType( - .hidSystemState, - eventType: CGEventType(rawValue: ~0)! - ) - isUserIdle = idleTime >= idleTimeoutSeconds - } -} diff --git a/app/EigenInference/Sources/EigenInference/Illustrations.swift b/app/EigenInference/Sources/EigenInference/Illustrations.swift deleted file mode 100644 index 84eedbd7..00000000 --- a/app/EigenInference/Sources/EigenInference/Illustrations.swift +++ /dev/null @@ -1,377 +0,0 @@ -/// Illustrations — Hand-drawn SwiftUI illustrations for Darkbloom. -/// -/// Cartoon Mac character with mood-based expressions, plus decorative -/// elements for onboarding, empty states, and dashboards. -/// All resolution-independent — built from SwiftUI Shapes and Paths. - -import SwiftUI - -// MARK: - Cartoon Mac Character - -/// A cute Mac laptop character with face and expressions. -struct CartoonMac: View { - var mood: AvatarMood = .greeting - var size: CGFloat = 64 - - private var scale: CGFloat { size / 64 } - - var body: some View { - ZStack { - // Shadow - Ellipse() - .fill(Color.adaptiveInk.opacity(0.06)) - .frame(width: 48 * scale, height: 8 * scale) - .offset(y: 28 * scale) - - // Mac body - macBody - .offset(y: -4 * scale) - } - .frame(width: size, height: size) - } - - private var macBody: some View { - ZStack { - // Screen (main body) - RoundedRectangle(cornerRadius: 6 * scale) - .fill(Color.adaptiveBgSecondary) - .frame(width: 48 * scale, height: 36 * scale) - .overlay( - RoundedRectangle(cornerRadius: 6 * scale) - .strokeBorder(Color.adaptiveInk, lineWidth: 2.5 * scale) - ) - .offset(y: -6 * scale) - - // Screen inner (colored based on mood) - RoundedRectangle(cornerRadius: 3 * scale) - .fill(mood.color.opacity(0.15)) - .frame(width: 40 * scale, height: 26 * scale) - .overlay( - RoundedRectangle(cornerRadius: 3 * scale) - .strokeBorder(mood.color.opacity(0.3), lineWidth: 1 * scale) - ) - .offset(y: -8 * scale) - - // Face on screen - face - .offset(y: -8 * scale) - - // Stand/chin - Path { path in - let w = 48 * scale - let chinY = 12 * scale - path.move(to: CGPoint(x: (size - w) / 2 + 14 * scale, y: chinY)) - path.addQuadCurve( - to: CGPoint(x: (size - w) / 2 + w - 14 * scale, y: chinY), - control: CGPoint(x: size / 2, y: chinY + 7 * scale) - ) - } - .stroke(Color.adaptiveInk, lineWidth: 2 * scale) - - // Base - RoundedRectangle(cornerRadius: 2 * scale) - .fill(Color.adaptiveBgElevated) - .frame(width: 30 * scale, height: 4 * scale) - .overlay( - RoundedRectangle(cornerRadius: 2 * scale) - .strokeBorder(Color.adaptiveInk, lineWidth: 1.5 * scale) - ) - .offset(y: 18 * scale) - - // Status light (on chin area) - Circle() - .fill(mood.color) - .frame(width: 3 * scale, height: 3 * scale) - .shadow(color: mood.color.opacity(0.6), radius: 3 * scale) - .offset(y: 12 * scale) - } - } - - @ViewBuilder - private var face: some View { - switch mood { - case .greeting: - // Happy face with open eyes - HStack(spacing: 8 * scale) { - eye(open: true) - eye(open: true) - } - .offset(y: -3 * scale) - // Smile - smile(wide: false) - .offset(y: 4 * scale) - - case .explaining: - // Neutral face - HStack(spacing: 8 * scale) { - eye(open: true) - eye(open: true) - } - .offset(y: -3 * scale) - // Small mouth - RoundedRectangle(cornerRadius: 1 * scale) - .fill(Color.adaptiveInk) - .frame(width: 6 * scale, height: 2 * scale) - .offset(y: 4 * scale) - - case .excited: - // Big happy eyes - HStack(spacing: 8 * scale) { - starEye - starEye - } - .offset(y: -3 * scale) - // Big smile - smile(wide: true) - .offset(y: 4 * scale) - - case .thinking: - // Looking up eyes - HStack(spacing: 8 * scale) { - eye(open: true, lookUp: true) - eye(open: true, lookUp: true) - } - .offset(y: -4 * scale) - // Wavy mouth - wavyMouth - .offset(y: 3 * scale) - - case .concerned: - // Worried eyes - HStack(spacing: 8 * scale) { - eye(open: true) - eye(open: true) - } - .offset(y: -3 * scale) - // Frown - frown - .offset(y: 5 * scale) - - case .celebrating: - // Closed happy eyes (^_^) - HStack(spacing: 8 * scale) { - closedHappyEye - closedHappyEye - } - .offset(y: -2 * scale) - // Big smile - smile(wide: true) - .offset(y: 4 * scale) - } - } - - // MARK: - Face Parts - - private func eye(open: Bool, lookUp: Bool = false) -> some View { - ZStack { - if open { - Circle() - .fill(Color.adaptiveInk) - .frame(width: 5 * scale, height: 5 * scale) - // Shine - Circle() - .fill(Color.white) - .frame(width: 1.5 * scale, height: 1.5 * scale) - .offset(x: 1 * scale, y: lookUp ? -2 * scale : -1 * scale) - } - } - } - - private var starEye: some View { - Image(systemName: "star.fill") - .font(.system(size: 5 * scale, weight: .bold)) - .foregroundStyle(Color.adaptiveGold) - } - - private var closedHappyEye: some View { - Path { path in - let w = 5 * scale - path.move(to: CGPoint(x: -w/2, y: 0)) - path.addQuadCurve( - to: CGPoint(x: w/2, y: 0), - control: CGPoint(x: 0, y: -3 * scale) - ) - } - .stroke(Color.adaptiveInk, lineWidth: 1.5 * scale) - .frame(width: 5 * scale, height: 4 * scale) - } - - private func smile(wide: Bool) -> some View { - Path { path in - let w = (wide ? 12 : 8) * scale - path.move(to: CGPoint(x: -w/2, y: 0)) - path.addQuadCurve( - to: CGPoint(x: w/2, y: 0), - control: CGPoint(x: 0, y: 4 * scale) - ) - } - .stroke(Color.adaptiveInk, lineWidth: 1.5 * scale) - .frame(width: 14 * scale, height: 6 * scale) - } - - private var frown: some View { - Path { path in - let w = 8 * scale - path.move(to: CGPoint(x: -w/2, y: 2 * scale)) - path.addQuadCurve( - to: CGPoint(x: w/2, y: 2 * scale), - control: CGPoint(x: 0, y: -2 * scale) - ) - } - .stroke(Color.adaptiveInk, lineWidth: 1.5 * scale) - .frame(width: 10 * scale, height: 5 * scale) - } - - private var wavyMouth: some View { - Path { path in - let w = 8 * scale - path.move(to: CGPoint(x: -w/2, y: 0)) - path.addQuadCurve(to: CGPoint(x: 0, y: 0), control: CGPoint(x: -w/4, y: -2 * scale)) - path.addQuadCurve(to: CGPoint(x: w/2, y: 0), control: CGPoint(x: w/4, y: 2 * scale)) - } - .stroke(Color.adaptiveInk, lineWidth: 1.5 * scale) - .frame(width: 10 * scale, height: 6 * scale) - } -} - -// MARK: - Decorative Sparkles - -struct Sparkles: View { - var color: Color = .gold - var count: Int = 3 - - var body: some View { - ZStack { - ForEach(0.. Path { - let cx = rect.midX - let cy = rect.midY - let r = min(rect.width, rect.height) / 2 - var path = Path() - path.move(to: CGPoint(x: cx, y: cy - r)) - path.addLine(to: CGPoint(x: cx + r * 0.3, y: cy - r * 0.3)) - path.addLine(to: CGPoint(x: cx + r, y: cy)) - path.addLine(to: CGPoint(x: cx + r * 0.3, y: cy + r * 0.3)) - path.addLine(to: CGPoint(x: cx, y: cy + r)) - path.addLine(to: CGPoint(x: cx - r * 0.3, y: cy + r * 0.3)) - path.addLine(to: CGPoint(x: cx - r, y: cy)) - path.addLine(to: CGPoint(x: cx - r * 0.3, y: cy - r * 0.3)) - path.closeSubpath() - return path - } -} - -// MARK: - Shield Illustration - -struct ShieldIllustration: View { - var passed: Bool = true - var size: CGFloat = 48 - - var body: some View { - ZStack { - // Shield shape - Image(systemName: passed ? "shield.checkered" : "shield.slash") - .font(.system(size: size * 0.6, weight: .bold)) - .foregroundStyle(passed ? Color.adaptiveTealAccent : Color.adaptiveError) - .shadow(color: (passed ? Color.adaptiveTealAccent : Color.adaptiveError).opacity(0.3), radius: 6) - - if passed { - // Sparkles around shield - Sparkles(color: .tealAccent, count: 4) - .frame(width: size * 1.5, height: size * 1.5) - } - } - .frame(width: size, height: size) - } -} - -// MARK: - Coin Stack Illustration - -struct CoinStackIllustration: View { - var size: CGFloat = 48 - - var body: some View { - ZStack { - // Stack of coins - ForEach(0..<3, id: \.self) { i in - Ellipse() - .fill(Color.adaptiveGold) - .frame(width: size * 0.5, height: size * 0.2) - .overlay( - Ellipse() - .strokeBorder(Color.adaptiveInk, lineWidth: 1.5) - ) - .offset(y: CGFloat(2 - i) * size * 0.12) - } - - // Dollar sign on top coin - Text("$") - .font(.system(size: size * 0.2, weight: .bold, design: .rounded)) - .foregroundStyle(Color.adaptiveInk) - .offset(y: -size * 0.05) - - // Sparkles - Sparkles(color: .gold, count: 3) - .frame(width: size, height: size) - .offset(x: size * 0.3, y: -size * 0.2) - } - .frame(width: size, height: size) - } -} - -// MARK: - Network Illustration (3 connected Macs) - -struct NetworkIllustration: View { - var size: CGFloat = 80 - - var body: some View { - ZStack { - // Connection lines - Path { path in - path.move(to: CGPoint(x: size * 0.5, y: size * 0.3)) - path.addLine(to: CGPoint(x: size * 0.2, y: size * 0.7)) - path.move(to: CGPoint(x: size * 0.5, y: size * 0.3)) - path.addLine(to: CGPoint(x: size * 0.8, y: size * 0.7)) - } - .stroke(Color.adaptiveInk.opacity(0.2), style: StrokeStyle(lineWidth: 2, dash: [4, 3])) - - // Center Mac (coordinator) - CartoonMac(mood: .excited, size: size * 0.4) - .offset(y: -size * 0.2) - - // Left Mac - CartoonMac(mood: .greeting, size: size * 0.3) - .offset(x: -size * 0.3, y: size * 0.2) - - // Right Mac - CartoonMac(mood: .explaining, size: size * 0.3) - .offset(x: size * 0.3, y: size * 0.2) - - // Lock icons on connections - Image(systemName: "lock.fill") - .font(.system(size: size * 0.08, weight: .bold)) - .foregroundStyle(Color.adaptiveTealAccent) - .offset(x: -size * 0.18, y: size * 0.02) - - Image(systemName: "lock.fill") - .font(.system(size: size * 0.08, weight: .bold)) - .foregroundStyle(Color.adaptiveTealAccent) - .offset(x: size * 0.18, y: size * 0.02) - } - .frame(width: size, height: size) - } -} diff --git a/app/EigenInference/Sources/EigenInference/LaunchAgentManager.swift b/app/EigenInference/Sources/EigenInference/LaunchAgentManager.swift deleted file mode 100644 index acb9a432..00000000 --- a/app/EigenInference/Sources/EigenInference/LaunchAgentManager.swift +++ /dev/null @@ -1,131 +0,0 @@ -/// LaunchAgentManager — Install/remove a launchd LaunchAgent for app auto-launch on login. -/// -/// Creates a plist at ~/Library/LaunchAgents/com.darkbloom.app.plist that opens -/// the Darkbloom app on login. This is separate from the provider service plist -/// which is managed by the CLI's `start`/`stop`. -/// -/// Only installed when the user explicitly toggles "Start Darkbloom when you -/// log in" in Settings. Opening the app does NOT auto-start the provider; -/// the user must click "Go Online" to begin serving. - -import Foundation - -enum LaunchAgentManager { - - private static let plistName = "com.darkbloom.app.plist" - - private static var plistPath: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents") - .appendingPathComponent(plistName) - } - - /// Also check for legacy plist name and migrate if needed. - private static var legacyPlistPath: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents") - .appendingPathComponent("io.darkbloom.app.plist") - } - - /// Whether the LaunchAgent is currently installed. - static var isInstalled: Bool { - FileManager.default.fileExists(atPath: plistPath.path) - || FileManager.default.fileExists(atPath: legacyPlistPath.path) - } - - /// Install the LaunchAgent to start DarkBloom on login. - static func install() throws { - // Remove legacy plist if it exists - if FileManager.default.fileExists(atPath: legacyPlistPath.path) { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/launchctl") - proc.arguments = ["unload", legacyPlistPath.path] - proc.standardOutput = Pipe() - proc.standardError = Pipe() - try? proc.run() - proc.waitUntilExit() - try? FileManager.default.removeItem(at: legacyPlistPath) - } - - let launchAgentsDir = plistPath.deletingLastPathComponent() - - // Ensure ~/Library/LaunchAgents exists - try FileManager.default.createDirectory( - at: launchAgentsDir, - withIntermediateDirectories: true - ) - - // Find the app or executable path. - // When running as a .app bundle, use `open` to launch it. - // When running from a debug build (swift run), use the executable directly. - let bundlePath = Bundle.main.bundlePath - let isAppBundle = bundlePath.hasSuffix(".app") - let programArgs: [String] = isAppBundle - ? ["/usr/bin/open", bundlePath] - : [ProcessInfo.processInfo.arguments[0]] - - // Ensure ~/.darkbloom/ directory exists for log files - let appDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".darkbloom") - try FileManager.default.createDirectory( - at: appDir, - withIntermediateDirectories: true - ) - - let plist: [String: Any] = [ - "Label": "com.darkbloom.app", - "ProgramArguments": programArgs, - "RunAtLoad": true, - "KeepAlive": false, - "StandardOutPath": appDir.appendingPathComponent("launchagent.log").path, - "StandardErrorPath": appDir.appendingPathComponent("launchagent.log").path, - ] - - let data = try PropertyListSerialization.data( - fromPropertyList: plist, - format: .xml, - options: 0 - ) - try data.write(to: plistPath) - - // Load the agent - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/launchctl") - proc.arguments = ["load", plistPath.path] - proc.standardOutput = Pipe() - proc.standardError = Pipe() - try proc.run() - proc.waitUntilExit() - } - - /// Remove the LaunchAgent. - static func uninstall() throws { - // Unload and remove current plist - for path in [plistPath, legacyPlistPath] { - guard FileManager.default.fileExists(atPath: path.path) else { continue } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/launchctl") - proc.arguments = ["unload", path.path] - proc.standardOutput = Pipe() - proc.standardError = Pipe() - try? proc.run() - proc.waitUntilExit() - - try FileManager.default.removeItem(at: path) - } - } - - /// Sync the LaunchAgent state with the desired auto-start setting. - static func sync(autoStart: Bool) { - do { - if autoStart && !isInstalled { - try install() - } else if !autoStart && isInstalled { - try uninstall() - } - } catch { - print("LaunchAgent sync failed: \(error)") - } - } -} diff --git a/app/EigenInference/Sources/EigenInference/LogViewerView.swift b/app/EigenInference/Sources/EigenInference/LogViewerView.swift deleted file mode 100644 index 4a22f21a..00000000 --- a/app/EigenInference/Sources/EigenInference/LogViewerView.swift +++ /dev/null @@ -1,209 +0,0 @@ -/// LogViewerView — Streaming log viewer for provider output. -/// -/// Reads `~/.darkbloom/provider.log` directly and supports tail-f style -/// streaming using a DispatchSource file monitor. - -import SwiftUI - -struct LogViewerView: View { - @ObservedObject var viewModel: StatusViewModel - @State private var logLines: [String] = [] - @State private var isStreaming = false - @State private var searchText = "" - @State private var fileMonitor: DispatchSourceFileSystemObject? - @State private var fileHandle: FileHandle? - - private var logFilePath: String { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".darkbloom/provider.log").path - } - - private var filteredLines: [String] { - if searchText.isEmpty { - return logLines - } - return logLines.filter { $0.localizedCaseInsensitiveContains(searchText) } - } - - var body: some View { - VStack(spacing: 0) { - // Toolbar - HStack { - Text("Provider Logs") - .font(.display(18)) - - Spacer() - - TextField("Filter", text: $searchText) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - - Toggle(isOn: $isStreaming) { - Label("Live", systemImage: "antenna.radiowaves.left.and.right") - } - .toggleStyle(.button) - .onChange(of: isStreaming) { _, streaming in - if streaming { - startStreaming() - } else { - stopStreaming() - } - } - - Button { - logLines = [] - } label: { - Image(systemName: "trash") - } - .help("Clear log display") - - Button { - loadLogFile() - } label: { - Image(systemName: "arrow.clockwise") - } - .help("Reload") - } - .padding(12) - - Divider() - - // Log content - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 1) { - ForEach(Array(filteredLines.enumerated()), id: \.offset) { index, line in - logLineView(line) - .id(index) - } - } - .padding(8) - } - .onChange(of: logLines.count) { _, _ in - if isStreaming, let lastIndex = filteredLines.indices.last { - withAnimation { - proxy.scrollTo(lastIndex, anchor: .bottom) - } - } - } - } - .font(.monoWarm) - .background(Color.warmBgSecondary) - - // Status bar - HStack { - Text("\(filteredLines.count) lines") - .font(.caption) - .foregroundColor(.warmInkLight) - - if isStreaming { - Circle() - .fill(.tealAccent) - .frame(width: 6, height: 6) - Text("Live") - .font(.caption) - .foregroundColor(.tealAccent) - } - - Spacer() - - Text(logFilePath) - .font(.caption) - .foregroundColor(.warmInkLight) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.warmBg) - } - .frame(minWidth: 700, minHeight: 400) - .onAppear { - loadLogFile() - } - .onDisappear { - stopStreaming() - } - } - - private func logLineView(_ line: String) -> some View { - let color: Color = { - if line.contains("ERROR") || line.contains("error") { return .warmError } - if line.contains("WARN") || line.contains("warn") { return .gold } - if line.contains("INFO") { return .warmInk } - if line.contains("DEBUG") { return .warmInkLight } - return .warmInkLight - }() - - return Text(line) - .foregroundColor(color) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func loadLogFile() { - guard FileManager.default.fileExists(atPath: logFilePath) else { - logLines = ["Log file not found: \(logFilePath)", "Start the provider to generate logs."] - return - } - - do { - let content = try String(contentsOfFile: logFilePath, encoding: .utf8) - let lines = content.components(separatedBy: .newlines).filter { !$0.isEmpty } - // Show last 500 lines - logLines = Array(lines.suffix(500)) - } catch { - logLines = ["Failed to read log file: \(error.localizedDescription)"] - } - } - - private func startStreaming() { - stopStreaming() - - guard FileManager.default.fileExists(atPath: logFilePath) else { return } - - let fd = open(logFilePath, O_RDONLY) - guard fd >= 0 else { return } - - // Seek to end - lseek(fd, 0, SEEK_END) - - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - fileHandle = handle - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .extend], - queue: .global(qos: .userInitiated) - ) - - source.setEventHandler { [weak handle] in - guard let handle = handle else { return } - let data = handle.availableData - guard !data.isEmpty, - let text = String(data: data, encoding: .utf8) else { return } - - let newLines = text.components(separatedBy: .newlines).filter { !$0.isEmpty } - Task { @MainActor in - logLines.append(contentsOf: newLines) - // Cap at 2000 lines - if logLines.count > 2000 { - logLines = Array(logLines.suffix(1500)) - } - } - } - - source.setCancelHandler { - close(fd) - } - - source.resume() - fileMonitor = source - } - - private func stopStreaming() { - fileMonitor?.cancel() - fileMonitor = nil - fileHandle = nil - } -} diff --git a/app/EigenInference/Sources/EigenInference/MenuBarView.swift b/app/EigenInference/Sources/EigenInference/MenuBarView.swift deleted file mode 100644 index 8d3e7e5d..00000000 --- a/app/EigenInference/Sources/EigenInference/MenuBarView.swift +++ /dev/null @@ -1,340 +0,0 @@ -/// MenuBarView — The dropdown UI shown when clicking the Darkbloom menu bar icon. -/// -/// Shows at-a-glance provider status with quick actions. -/// Uses Liquid Glass on macOS 26+, falls back to .ultraThinMaterial on older versions. - -import SwiftUI - -struct MenuBarView: View { - @ObservedObject var viewModel: StatusViewModel - @Environment(\.openWindow) private var openWindow - @Environment(\.openSettings) private var openSettings: OpenSettingsAction - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Header - HStack { - DarkBloomBrand(size: 18) - Circle() - .fill(viewModel.coordinatorConnected ? Color.adaptiveTealAccent : Color.adaptiveError) - .frame(width: 6, height: 6) - .help(viewModel.coordinatorConnected ? "Coordinator connected" : "Coordinator offline") - Spacer() - statusBadge - } - - Divider() - - // Hardware + model info - VStack(alignment: .leading, spacing: 6) { - Text("\(viewModel.chipName) \u{00B7} \(viewModel.memoryGB) GB") - .font(.bodyWarm) - .foregroundStyle(Color.warmInkLight) - - // Current model (auto-selected, configurable in Settings) - HStack { - Text("Model:") - .foregroundStyle(Color.warmInkLight) - Text(viewModel.currentModel.components(separatedBy: "/").last ?? viewModel.currentModel) - .fontWeight(.medium) - .foregroundStyle(Color.warmInk) - .lineLimit(1) - } - .font(.bodyWarm) - - // Live status with animated throughput - HStack { - Text("Status:") - .foregroundStyle(Color.warmInkLight) - statusText - } - .font(.bodyWarm) - .animation(.smooth, value: viewModel.isServing) - - // Trust level - HStack(spacing: 4) { - Text("Trust:") - .foregroundStyle(Color.warmInkLight) - Image(systemName: viewModel.securityManager.trustLevel.iconName) - .foregroundStyle(trustColor) - Text(viewModel.securityManager.trustLevel.displayName) - .foregroundStyle(trustColor) - } - .font(.bodyWarm) - } - - // Trust warnings - trustWarning - - Divider() - - // Stats - if viewModel.requestsServed > 0 || viewModel.tokensGenerated > 0 { - HStack { - Text("Today:") - .foregroundStyle(Color.warmInkLight) - Text("\(viewModel.requestsServed) requests") - .foregroundStyle(Color.warmInk) - Text("\u{00B7}") - .foregroundStyle(Color.warmInkFaint) - Text(formatTokenCount(viewModel.tokensGenerated)) - .foregroundStyle(Color.warmInk) - } - .font(.bodyWarm) - .contentTransition(.numericText()) - } - - if !viewModel.earningsBalance.isEmpty { - HStack { - Text("Earnings:") - .foregroundStyle(Color.warmInkLight) - Text(viewModel.earningsBalance) - .fontWeight(.medium) - .foregroundStyle(Color.adaptiveTealAccent) - } - .font(.bodyWarm) - } - - if viewModel.isOnline { - HStack { - Text("Uptime:") - .foregroundStyle(Color.warmInkLight) - Text(formatUptime(viewModel.uptimeSeconds)) - .monospacedDigit() - .foregroundStyle(Color.warmInk) - } - .font(.bodyWarm) - } - - Divider() - - // On/Off toggle - providerToggle - - // Sleep prevention - if viewModel.providerManager.isRunning { - Label("Sleep prevention active", systemImage: "bolt.shield") - .font(.captionWarm) - .foregroundStyle(Color.warmInkFaint) - } - - // Pause/Resume - if viewModel.isOnline && !viewModel.isPaused { - Button(action: { viewModel.pauseProvider() }) { - Label("Pause", systemImage: "pause.fill") - } - .buttonStyle(.plain) - } else if viewModel.isPaused { - Button(action: { viewModel.resumeProvider() }) { - Label("Resume", systemImage: "play.fill") - } - .buttonStyle(.plain) - } - - Divider() - - // Navigation - navigationButtons - - Divider() - - // Footer - HStack { - Text("v\(viewModel.updateManager.currentVersion)") - .font(.captionWarm) - .foregroundStyle(Color.warmInkFaint) - if viewModel.updateManager.updateAvailable { - Text("Update available") - .font(.captionWarm) - .foregroundStyle(Color.adaptiveGold) - } - Spacer() - } - - Button(action: { NSApplication.shared.terminate(nil) }) { - Label("Quit Darkbloom", systemImage: "power") - } - .buttonStyle(.plain) - } - .padding(12) - .frame(width: 300) - .background(Color.warmBg) - .animation(.smooth, value: viewModel.isOnline) - .animation(.smooth, value: viewModel.isPaused) - } - - // MARK: - Components - - private var statusBadge: some View { - WarmBadge( - text: statusLabel, - color: statusColor, - icon: viewModel.isPaused ? "pause.fill" : viewModel.isOnline ? "bolt.fill" : "power" - ) - } - - @ViewBuilder - private var trustWarning: some View { - if viewModel.securityManager.trustLevel != .hardware { - Button(action: { openWindow(id: "setup") }) { - HStack(spacing: 4) { - Image(systemName: "exclamationmark.triangle.fill") - Text("Complete setup for inference routing \u{2192}") - } - .font(.captionWarm) - .foregroundStyle(Color.adaptiveError) - } - .buttonStyle(.plain) - } - } - - private var providerToggle: some View { - Toggle(isOn: Binding( - get: { viewModel.isOnline || viewModel.providerManager.isRunning }, - set: { newValue in - if newValue { viewModel.start() } else { viewModel.stop() } - } - )) { - Text(viewModel.isOnline ? "Online" : viewModel.providerManager.isRunning ? "Starting..." : "Offline") - .font(.bodyWarm) - .foregroundStyle(Color.warmInk) - } - .toggleStyle(.switch) - .tint(.adaptiveTealAccent) - .padding(8) - .pointerOnHover() - } - - @ViewBuilder - private var navigationButtons: some View { - if #available(macOS 26.0, *) { - GlassEffectContainer { - navButtonStack - } - } else { - navButtonStack - } - } - - private var navButtonStack: some View { - VStack(alignment: .leading, spacing: 4) { - navButton("Dashboard...", icon: "chart.bar", window: "dashboard") - navButton("Logs...", icon: "doc.text", window: "logs") - if !viewModel.hasCompletedSetup { - navButton("Setup Wizard...", icon: "wrench", window: "setup") - } - Button(action: { openSettings() }) { - Label("Settings...", systemImage: "gear") - } - .buttonStyle(.plain) - .modifier(InteractiveGlassModifier()) - .pointerOnHover() - } - } - - private func navButton(_ title: String, icon: String, window: String) -> some View { - Button(action: { openWindow(id: window) }) { - Label(title, systemImage: icon) - } - .buttonStyle(.plain) - .modifier(InteractiveGlassModifier()) - .pointerOnHover() - } - - private var statusText: some View { - Group { - if viewModel.isPaused { - Text("Paused (user active)") - .foregroundStyle(Color.adaptiveGold) - } else if viewModel.isServing { - HStack(spacing: 4) { - Text("Serving") - .foregroundStyle(Color.adaptiveTealAccent) - Text("\u{00B7}") - .foregroundStyle(Color.adaptiveInkFaint) - Text(String(format: "%.0f tok/s", viewModel.tokensPerSecond)) - .foregroundStyle(Color.adaptiveTealAccent) - .monospacedDigit() - .contentTransition(.numericText()) - } - } else if viewModel.isOnline { - Text("Ready") - .foregroundStyle(Color.adaptiveTealAccent) - } else { - Text("Stopped") - .foregroundStyle(Color.adaptiveInkFaint) - } - } - } - - // MARK: - Helpers - - private var trustColor: Color { - switch viewModel.securityManager.trustLevel { - case .hardware: return .adaptiveTealAccent - case .none: return .adaptiveError - } - } - - private var statusColor: Color { - if viewModel.isPaused { return .adaptiveGold } - if viewModel.isOnline { return .adaptiveTealAccent } - return .adaptiveInkFaint - } - - private var statusLabel: String { - if viewModel.isPaused { return "Paused" } - if viewModel.isOnline { return "Online" } - return "Offline" - } - - private func formatTokenCount(_ count: Int) -> String { - if count >= 1_000_000 { return String(format: "%.1fM tokens", Double(count) / 1_000_000) } - if count >= 1_000 { return String(format: "%.1fK tokens", Double(count) / 1_000) } - return "\(count) tokens" - } - - private func formatUptime(_ seconds: Int) -> String { - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - if hours > 0 { return "\(hours)h \(minutes)m" } - return "\(minutes)m" - } -} - -// MARK: - Glass Modifiers (macOS 26+ with fallback) - -/// Applies Liquid Glass on macOS 26+, subtle material background on older versions. -private struct GlassModifier: ViewModifier { - let shape: S - var tint: Color? - - init(shape: S, tint: Color? = nil) { - self.shape = shape - self.tint = tint - } - - func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - if let tint { - content.glassEffect(.regular.tint(tint), in: shape) - } else { - content.glassEffect(in: shape) - } - } else { - content - .background(.ultraThinMaterial, in: shape) - } - } -} - -/// Interactive glass for buttons — Liquid Glass on 26+, plain on older. -private struct InteractiveGlassModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content.glassEffect(.regular.interactive()) - } else { - content - } - } -} diff --git a/app/EigenInference/Sources/EigenInference/ModelCatalog.swift b/app/EigenInference/Sources/EigenInference/ModelCatalog.swift deleted file mode 100644 index 847ba6cb..00000000 --- a/app/EigenInference/Sources/EigenInference/ModelCatalog.swift +++ /dev/null @@ -1,47 +0,0 @@ -/// ModelCatalog — Static model catalog matching the coordinator's catalog. -/// -/// Mirrors the model catalog from coordinator/cmd/coordinator/main.go (seedModelCatalog). -/// Used by SetupWizardView and ModelCatalogView for displaying available -/// models with fit indicators and tiered defaults. - -import Foundation - -enum ModelCatalog { - - struct Entry: Identifiable { - let id: String - let name: String - let modelType: String // "text" - let sizeGB: Double - let architecture: String - let description: String - let minRAMGB: Int - - /// Whether this model fits on a machine with the given RAM. - func fitsInMemory(totalGB: Int) -> Bool { - totalGB >= minRAMGB - } - } - - /// Known models from the Darkbloom catalog, ordered by min RAM tier. - static let models: [Entry] = [ - Entry(id: "qwen3.5-27b-claude-opus-8bit", name: "Qwen3.5 27B Claude Opus", modelType: "text", sizeGB: 27.0, architecture: "27B dense, Claude Opus distilled", description: "Frontier quality reasoning", minRAMGB: 36), - Entry(id: "mlx-community/Trinity-Mini-8bit", name: "Trinity Mini", modelType: "text", sizeGB: 26.0, architecture: "27B Adaptive MoE", description: "Fast agentic inference", minRAMGB: 48), - Entry(id: "mlx-community/Qwen3.5-122B-A10B-8bit", name: "Qwen3.5 122B", modelType: "text", sizeGB: 122.0, architecture: "122B MoE, 10B active", description: "Best quality", minRAMGB: 128), - Entry(id: "mlx-community/MiniMax-M2.5-8bit", name: "MiniMax M2.5", modelType: "text", sizeGB: 243.0, architecture: "239B MoE, 11B active", description: "SOTA coding, 100 tok/s", minRAMGB: 256), - ] - - /// Returns the default model for a given RAM tier. - static func defaultModel(ramGB: Int) -> Entry? { - if ramGB >= 256 { return models.first { $0.id.contains("MiniMax") } } - if ramGB >= 128 { return models.first { $0.id.contains("Qwen3.5-122B") } } - if ramGB >= 36 { return models.first { $0.id.contains("qwen3.5-27b-claude-opus") } } - return nil - } - - /// Returns all models that fit in the given RAM but aren't the default. - static func optionalModels(ramGB: Int) -> [Entry] { - let defaultId = defaultModel(ramGB: ramGB)?.id - return models.filter { $0.fitsInMemory(totalGB: ramGB) && $0.id != defaultId } - } -} diff --git a/app/EigenInference/Sources/EigenInference/ModelCatalogView.swift b/app/EigenInference/Sources/EigenInference/ModelCatalogView.swift deleted file mode 100644 index f10c2a07..00000000 --- a/app/EigenInference/Sources/EigenInference/ModelCatalogView.swift +++ /dev/null @@ -1,212 +0,0 @@ -/// ModelCatalogView — Rich model selection with fit indicators, download, and removal. -/// -/// Shows the full model catalog with: -/// - Fit indicators (green = fits, red = too large for RAM) -/// - Download status (Downloaded, Available, Downloading) -/// - Download/Remove actions -/// - Model type badges (text) - -import SwiftUI - -struct ModelCatalogView: View { - @ObservedObject var viewModel: StatusViewModel - @State private var downloadingModel: String? - @State private var downloadStatus = "" - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Current model - HStack { - Text("Active Model:") - .foregroundColor(.warmInkLight) - Text(viewModel.currentModel) - .fontWeight(.medium) - Spacer() - Button("Refresh") { - viewModel.modelManager.scanModels() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - Divider() - - // Model list - ScrollView { - VStack(spacing: 6) { - ForEach(ModelCatalog.models, id: \.id) { entry in - modelRow(entry) - } - } - } - - // Download status - if let downloading = downloadingModel { - HStack { - ProgressView().controlSize(.small) - Text("Downloading \(downloading)...") - .font(.caption) - .foregroundColor(.warmInkLight) - Spacer() - } - } - - if !downloadStatus.isEmpty && downloadingModel == nil { - Text(downloadStatus) - .font(.caption) - .foregroundColor(.warmInkLight) - } - } - .padding() - .onAppear { - viewModel.modelManager.scanModels() - } - } - - private func modelRow(_ entry: ModelCatalog.Entry) -> some View { - let isDownloaded = viewModel.modelManager.availableModels.contains { $0.id == entry.id } - let fits = entry.fitsInMemory(totalGB: viewModel.memoryGB) - let isActive = viewModel.currentModel == entry.id - let isDownloading = downloadingModel == entry.id - let isDefault = ModelCatalog.defaultModel(ramGB: viewModel.memoryGB)?.id == entry.id - - return HStack(spacing: 12) { - // Fit indicator - Image(systemName: fits ? "checkmark.circle.fill" : "xmark.circle") - .foregroundColor(fits ? .adaptiveTealAccent : .adaptiveError) - .font(.caption) - .help(fits ? "Fits in memory" : "Requires more RAM") - - // Model info - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(entry.name) - .fontWeight(.medium) - Text(entry.modelType) - .font(.captionWarm) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(typeColor(entry.modelType).opacity(0.15)) - .foregroundColor(typeColor(entry.modelType)) - .cornerRadius(3) - if isDefault { - Text("default") - .font(.captionWarm) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.adaptiveCoral.opacity(0.15)) - .foregroundColor(.adaptiveCoral) - .cornerRadius(3) - } - } - Text("\(String(format: "%.1f", entry.sizeGB)) GB \(entry.architecture)") - .font(.captionWarm) - .foregroundColor(.warmInkLight) - } - - Spacer() - - // Status badges - if isDownloaded { - Text("Downloaded") - .font(.captionWarm) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.adaptiveTealAccent.opacity(0.15)) - .foregroundColor(.adaptiveTealAccent) - .cornerRadius(4) - } - - // Actions - if isActive { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.tealAccent) - .help("Currently active") - } else if isDownloaded { - HStack(spacing: 4) { - Button("Select") { - viewModel.currentModel = entry.id - } - .buttonStyle(.bordered) - .controlSize(.mini) - - Button { - Task { await removeModel(entry.id) } - } label: { - Image(systemName: "trash") - .foregroundColor(.adaptiveError) - } - .buttonStyle(.borderless) - .controlSize(.mini) - .help("Remove model") - } - } else if isDownloading { - ProgressView().controlSize(.small) - } else if fits { - Button("Download") { - Task { await downloadModel(entry.id) } - } - .buttonStyle(.bordered) - .controlSize(.mini) - } else { - Text("Too large") - .font(.captionWarm) - .foregroundColor(.adaptiveError) - } - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background(isActive ? Color.adaptiveCoral.opacity(0.06) : Color.clear) - .cornerRadius(6) - } - - private func typeColor(_ type: String) -> Color { - switch type { - case "text": return .adaptiveBlueAccent - default: return .adaptiveInkFaint - } - } - - private func downloadModel(_ modelId: String) async { - downloadingModel = modelId - downloadStatus = "" - - do { - let result = try await CLIRunner.run(["models", "download", "--model", modelId]) - if result.success { - downloadStatus = "Downloaded \(modelId)" - viewModel.modelManager.scanModels() - } else { - // Fallback: download from S3 - let s3Result = try await CLIRunner.run(["models", "download-s3", "--model", modelId]) - if s3Result.success { - downloadStatus = "Downloaded \(modelId) from CDN" - viewModel.modelManager.scanModels() - } else { - downloadStatus = "Download failed: \(result.stderr)" - } - } - } catch { - downloadStatus = "Error: \(error.localizedDescription)" - } - - downloadingModel = nil - } - - private func removeModel(_ modelId: String) async { - let home = FileManager.default.homeDirectoryForCurrentUser - let cacheDir = home.appendingPathComponent(".cache/huggingface/hub") - let dirName = "models--" + modelId.replacingOccurrences(of: "/", with: "--") - let modelDir = cacheDir.appendingPathComponent(dirName) - - do { - try FileManager.default.removeItem(at: modelDir) - viewModel.modelManager.scanModels() - if viewModel.currentModel == modelId { - viewModel.currentModel = "None" - } - } catch { - downloadStatus = "Failed to remove: \(error.localizedDescription)" - } - } -} diff --git a/app/EigenInference/Sources/EigenInference/ModelManager.swift b/app/EigenInference/Sources/EigenInference/ModelManager.swift deleted file mode 100644 index 7c731663..00000000 --- a/app/EigenInference/Sources/EigenInference/ModelManager.swift +++ /dev/null @@ -1,214 +0,0 @@ -/// ModelManager — Discovers and manages MLX models on disk. -/// -/// Scans the HuggingFace cache directory (`~/.cache/huggingface/hub/`) for -/// downloaded MLX models and reports their names and sizes. Can also trigger -/// new model downloads via `huggingface-cli`. -/// -/// MLX models in the HuggingFace cache follow this directory structure: -/// ~/.cache/huggingface/hub/models----/ -/// snapshots// -/// config.json -/// *.safetensors -/// tokenizer.json -/// ... -/// -/// The model identifier is reconstructed from the directory name by replacing -/// `--` with `/` (e.g., `models--mlx-community--Qwen3.5-4B-4bit` becomes -/// `mlx-community/Qwen3.5-4B-4bit`). - -import Foundation - -/// A discovered model on disk. -struct LocalModel: Identifiable, Hashable { - /// HuggingFace model identifier (e.g., "mlx-community/Qwen3.5-4B-4bit"). - let id: String - - /// Human-readable model name (e.g., "Qwen3.5-4B-4bit"). - let name: String - - /// Total size of model files on disk, in bytes. - let sizeBytes: UInt64 - - /// Whether this model is an MLX model (contains mlx or MLX in the path). - let isMLX: Bool -} - -/// Discovers HuggingFace-cached models and manages downloads. -/// -/// Scans `~/.cache/huggingface/hub/` for model directories, parses their -/// identifiers, computes on-disk size, and can invoke `huggingface-cli download` -/// for new models. -@MainActor -final class ModelManager: ObservableObject { - - /// All discovered local models, sorted by name. - @Published var availableModels: [LocalModel] = [] - - /// Whether a model download is currently in progress. - @Published var isDownloading = false - - /// Progress message for an active download. - @Published var downloadStatus = "" - - private let fileManager = FileManager.default - - /// The HuggingFace cache directory. - private var cacheDir: URL { - fileManager.homeDirectoryForCurrentUser - .appendingPathComponent(".cache/huggingface/hub") - } - - // MARK: - Scanning - - /// Scan the HuggingFace cache for downloaded models. - /// - /// Looks for directories matching `models--*` inside the cache dir, - /// reconstructs the model identifier, and computes on-disk size from - /// the latest snapshot. - func scanModels() { - var models: [LocalModel] = [] - - guard fileManager.fileExists(atPath: cacheDir.path) else { - availableModels = [] - return - } - - let contents: [URL] - do { - contents = try fileManager.contentsOfDirectory( - at: cacheDir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - } catch { - availableModels = [] - return - } - - for dir in contents { - let dirName = dir.lastPathComponent - guard dirName.hasPrefix("models--") else { continue } - - // Reconstruct model ID: models--org--name -> org/name - let stripped = String(dirName.dropFirst("models--".count)) - let modelId = stripped.replacingOccurrences(of: "--", with: "/") - let modelName = modelId.components(separatedBy: "/").last ?? modelId - - // Check if it's an MLX model - let isMLX = modelId.lowercased().contains("mlx") - - // Calculate size from the latest snapshot - let snapshotsDir = dir.appendingPathComponent("snapshots") - let size = directorySize(snapshotsDir) - - // Only include if there are actual model files - if size > 0 { - models.append(LocalModel( - id: modelId, - name: modelName, - sizeBytes: size, - isMLX: isMLX - )) - } - } - - availableModels = models.sorted { $0.name < $1.name } - } - - /// Check if a model will fit in the available unified memory. - /// - /// A rough heuristic: the model's on-disk size (safetensors) is - /// approximately equal to its memory footprint. We leave 4 GB - /// headroom for the OS and other processes. - func fitsInMemory(_ model: LocalModel, totalMemoryGB: Int) -> Bool { - let availableBytes = UInt64(max(totalMemoryGB - 4, 1)) * 1024 * 1024 * 1024 - return model.sizeBytes <= availableBytes - } - - // MARK: - Download - - /// Download a model from HuggingFace using huggingface-cli. - /// - /// Runs `huggingface-cli download ` as a subprocess. - /// Updates `isDownloading` and `downloadStatus` for UI feedback. - /// - /// - Parameter modelId: Full HuggingFace model identifier - /// (e.g., "mlx-community/Qwen3.5-4B-4bit"). - func downloadModel(_ modelId: String) { - guard !isDownloading else { return } - - isDownloading = true - downloadStatus = "Starting download of \(modelId)..." - - Task { [weak self] in - let exitStatus: Int32 - let errorMessage: String? - - do { - exitStatus = try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") - proc.arguments = ["huggingface-cli", "download", modelId] - - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = pipe - - do { - try proc.run() - proc.waitUntilExit() - continuation.resume(returning: proc.terminationStatus) - } catch { - continuation.resume(throwing: error) - } - } - } - errorMessage = nil - } catch { - exitStatus = -1 - errorMessage = error.localizedDescription - } - - if let errorMessage = errorMessage { - self?.downloadStatus = "Error: \(errorMessage)" - } else if exitStatus == 0 { - self?.downloadStatus = "Downloaded \(modelId)" - self?.scanModels() - } else { - self?.downloadStatus = "Failed to download \(modelId)" - } - self?.isDownloading = false - } - } - - // MARK: - Helpers - - /// Calculate the total size of all files in a directory, recursively. - private func directorySize(_ url: URL) -> UInt64 { - guard let enumerator = fileManager.enumerator( - at: url, - includingPropertiesForKeys: [.fileSizeKey], - options: [.skipsHiddenFiles] - ) else { return 0 } - - var total: UInt64 = 0 - for case let file as URL in enumerator { - if let values = try? file.resourceValues(forKeys: [.fileSizeKey]), - let size = values.fileSize { - total += UInt64(size) - } - } - return total - } - - /// Format bytes into a human-readable string (e.g., "4.2 GB"). - nonisolated static func formatSize(_ bytes: UInt64) -> String { - let gb = Double(bytes) / (1024 * 1024 * 1024) - if gb >= 1 { - return String(format: "%.1f GB", gb) - } - let mb = Double(bytes) / (1024 * 1024) - return String(format: "%.0f MB", mb) - } -} diff --git a/app/EigenInference/Sources/EigenInference/NotificationManager.swift b/app/EigenInference/Sources/EigenInference/NotificationManager.swift deleted file mode 100644 index 8d8f548a..00000000 --- a/app/EigenInference/Sources/EigenInference/NotificationManager.swift +++ /dev/null @@ -1,131 +0,0 @@ -/// NotificationManager — macOS system notifications for key provider events. -/// -/// Sends notifications for: -/// - Provider went offline unexpectedly -/// - Security status changed -/// - First inference request completed -/// - Earnings milestone reached - -import Foundation -import UserNotifications - -@MainActor -final class NotificationManager: ObservableObject { - - @Published var isAuthorized = false - - /// Request notification permission on first launch. - func requestAuthorization() { - // UNUserNotificationCenter crashes in SPM test runner and CLI contexts - // where there's no bundle proxy. Guard against that. - guard Bundle.main.bundleIdentifier != nil else { return } - - UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge] - ) { [weak self] granted, _ in - Task { @MainActor in - self?.isAuthorized = granted - } - } - } - - /// Notify that the provider went offline unexpectedly. - func notifyProviderOffline() { - send( - title: "Provider Offline", - body: "The inference provider stopped unexpectedly. Open Darkbloom to restart.", - identifier: "provider-offline" - ) - } - - /// Notify that the provider started serving. - func notifyProviderOnline(model: String) { - send( - title: "Provider Online", - body: "Now serving \(model). Your Mac is earning while idle.", - identifier: "provider-online" - ) - } - - /// Notify a security posture change. - func notifySecurityChange(_ message: String) { - send( - title: "Security Alert", - body: message, - identifier: "security-change" - ) - } - - /// Notify an inference completion with milestone celebrations. - func notifyInferenceCompleted(requestCount: Int) { - if requestCount == 1 { - send( - title: "First Inference Served!", - body: "Your Mac just served its first AI request. You're earning now.", - identifier: "milestone-first" - ) - return - } - - let milestones = [10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000] - guard milestones.contains(requestCount) else { return } - - let formatted = requestCount >= 1000 - ? String(format: "%.0fK", Double(requestCount) / 1000) - : "\(requestCount)" - send( - title: "\(formatted) Requests Served!", - body: "Your Mac has served \(formatted) inference requests. Keep it up!", - identifier: "milestone-\(requestCount)" - ) - } - - /// Notify earnings milestones in dollars. - func notifyEarningsMilestone(_ amount: Double) { - let milestones: [Double] = [1, 5, 10, 25, 50, 100, 250, 500, 1000] - guard milestones.contains(amount) else { return } - - send( - title: "You've earned $\(Int(amount))!", - body: "Your Mac has earned $\(Int(amount)) serving private inference.", - identifier: "earnings-\(Int(amount))" - ) - } - - /// Notify token generation milestones. - func notifyTokenMilestone(_ count: Int) { - let milestones = [100_000, 1_000_000, 10_000_000, 100_000_000] - guard milestones.contains(count) else { return } - - let formatted: String - if count >= 1_000_000 { - formatted = String(format: "%.0fM", Double(count) / 1_000_000) - } else { - formatted = String(format: "%.0fK", Double(count) / 1_000) - } - send( - title: "\(formatted) Tokens Generated!", - body: "Your Mac has generated \(formatted) tokens of AI inference.", - identifier: "tokens-\(count)" - ) - } - - // MARK: - Internal - - private func send(title: String, body: String, identifier: String) { - guard isAuthorized, Bundle.main.bundleIdentifier != nil else { return } - - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: nil // Deliver immediately - ) - - UNUserNotificationCenter.current().add(request) - } -} diff --git a/app/EigenInference/Sources/EigenInference/ProviderManager.swift b/app/EigenInference/Sources/EigenInference/ProviderManager.swift deleted file mode 100644 index 9f09332a..00000000 --- a/app/EigenInference/Sources/EigenInference/ProviderManager.swift +++ /dev/null @@ -1,283 +0,0 @@ -/// ProviderManager — Manages the Rust provider binary as a subprocess. -/// -/// This class wraps Foundation's `Process` to spawn, monitor, and stop the -/// `darkbloom` binary. It captures stdout/stderr for status parsing, -/// auto-restarts on unexpected crashes, and sets up the same environment -/// (PATH, PYTHONHOME) that the CLI uses. -/// -/// Binary resolution is delegated to CLIRunner.resolveBinaryPath() for -/// consistency — both the app and CLI use the same search order. -/// -/// The provider binary is invoked as: -/// darkbloom serve --coordinator --model --backend-port - -import Combine -import Foundation -import UserNotifications - -/// Manages the darkbloom subprocess lifecycle. -/// -/// Spawns the Rust binary, captures its output, monitors for crashes, -/// and provides clean shutdown via SIGTERM/SIGKILL. -@MainActor -final class ProviderManager: ObservableObject { - - /// Whether the provider subprocess is currently running. - @Published var isRunning = false - - /// The most recent line of output from the provider binary. - /// StatusViewModel observes this to parse status updates. - @Published var lastOutputLine = "" - - /// Accumulated stderr output for diagnostics. - @Published var lastError = "" - - private var process: Process? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var autoRestartEnabled = false - private var currentModel = "" - private var currentCoordinatorURL = "" - private var currentPort = 8321 - private var restartCount = 0 - private let maxRestarts = 5 - - // MARK: - Binary Path Resolution - - /// Resolve the path to the darkbloom binary. - /// - /// Uses the same resolution order as CLIRunner for consistency: - /// 1. `~/.darkbloom/bin/darkbloom` (shared install — single source of truth) - /// 2. Adjacent to the app bundle (fallback for first-run) - /// 3. PATH lookup (development) - nonisolated static func resolveBinaryPath() -> String? { - CLIRunner.resolveBinaryPath() - } - - /// Build the full command arguments for the provider binary. - /// - /// Returns the arguments array: ["serve", "--coordinator", url, "--model", model, "--backend-port", port] - nonisolated static func buildArguments(model: String, coordinatorURL: String, port: Int) -> [String] { - return [ - "serve", - "--coordinator", coordinatorURL, - "--model", model, - "--backend-port", String(port), - ] - } - - // MARK: - Lifecycle - - /// Start the provider subprocess. - /// - /// Resolves the binary path, spawns the process with the given - /// configuration, and sets up stdout/stderr capture. Enables - /// auto-restart on crash. - /// - /// - Parameters: - /// - model: The model identifier to serve (e.g., "mlx-community/Qwen3.5-4B-4bit") - /// - coordinatorURL: The coordinator endpoint URL - /// - port: The local port for the MLX backend - func start(model: String, coordinatorURL: String, port: Int) { - guard !isRunning else { return } - - currentModel = model - currentCoordinatorURL = coordinatorURL - currentPort = port - autoRestartEnabled = true - restartCount = 0 - - spawnProcess() - } - - /// Stop the provider subprocess. - /// - /// Sends SIGTERM first, waits up to 5 seconds for clean shutdown, - /// then sends SIGKILL if the process hasn't exited. Disables - /// auto-restart so the process stays down. - func stop() { - autoRestartEnabled = false - - guard let process = process, process.isRunning else { - isRunning = false - return - } - - // SIGTERM for graceful shutdown - process.terminate() - - // Wait up to 5 seconds, then SIGKILL - DispatchQueue.global().async { [weak self] in - for _ in 0..<50 { - if !process.isRunning { break } - Thread.sleep(forTimeInterval: 0.1) - } - - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - - Task { @MainActor in - self?.isRunning = false - self?.process = nil - } - } - } - - // MARK: - Internal - - /// Spawn the provider process and wire up output capture. - private func spawnProcess() { - guard let binaryPath = Self.resolveBinaryPath() else { - lastError = "darkbloom binary not found. Run the installer:\n" - + " curl -fsSL https://api.darkbloom.dev/install.sh | bash" - return - } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: binaryPath) - proc.arguments = Self.buildArguments( - model: currentModel, - coordinatorURL: currentCoordinatorURL, - port: currentPort - ) - - // Match CLIRunner's environment so the provider subprocess can find - // Python/vllm-mlx and other tools in the same paths the CLI uses. - let home = FileManager.default.homeDirectoryForCurrentUser.path - var env = ProcessInfo.processInfo.environment - let extraPaths = [ - "\(home)/.darkbloom/bin", - "\(home)/.darkbloom/python/bin", - "/opt/homebrew/bin", - "/usr/local/bin", - ] - let existingPath = env["PATH"] ?? "/usr/bin:/bin" - env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") - - let pythonHome = "\(home)/.darkbloom/python" - if FileManager.default.fileExists(atPath: "\(pythonHome)/bin/python3.12") { - env["PYTHONHOME"] = pythonHome - } - proc.environment = env - - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardOutput = outPipe - proc.standardError = errPipe - - // Read stdout line by line - outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty, - let line = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty else { return } - - Task { @MainActor in - self?.lastOutputLine = line - } - } - - // Read stderr - errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty, - let line = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty else { return } - - Task { @MainActor in - self?.lastError = line - } - } - - // Handle process termination - proc.terminationHandler = { [weak self] terminatedProcess in - let exitCode = Int(terminatedProcess.terminationStatus) - let reason = terminatedProcess.terminationReason - Task { @MainActor in - guard let self = self else { return } - self.isRunning = false - self.process = nil - - let crashed = exitCode != 0 || reason == .uncaughtSignal - - // Auto-restart on crash (non-zero exit) - if self.autoRestartEnabled && crashed && self.restartCount < self.maxRestarts { - self.restartCount += 1 - // Report the crash before restarting so operators see - // every restart cycle, not just the final give-up. - TelemetryReporter.shared.emit( - kind: .backendCrash, - severity: .error, - message: "provider subprocess crashed", - fields: [ - "component": "app", - "backend": "darkbloom", - "exit_code": exitCode, - "signal": reason == .uncaughtSignal ? "signal" : "exit", - "attempt": self.restartCount, - "reason": "subprocess_exit", - "model": self.currentModel, - ], - stack: self.lastError.isEmpty ? nil : String(self.lastError.suffix(4096)) - ) - - // Exponential backoff: 1s, 2s, 4s, 8s, 16s - let delay = pow(2.0, Double(self.restartCount - 1)) - try? await Task.sleep(for: .seconds(delay)) - if self.autoRestartEnabled { - self.spawnProcess() - } - } else if self.autoRestartEnabled && crashed - && self.restartCount >= self.maxRestarts - { - self.autoRestartEnabled = false - TelemetryReporter.shared.emit( - kind: .backendCrash, - severity: .fatal, - message: "provider exceeded max restart attempts", - fields: [ - "component": "app", - "backend": "darkbloom", - "exit_code": exitCode, - "attempt": self.restartCount, - "reason": "restart_limit_exceeded", - "model": self.currentModel, - ], - stack: self.lastError.isEmpty ? nil : String(self.lastError.suffix(4096)) - ) - let content = UNMutableNotificationContent() - content.title = "Darkbloom Provider Stopped" - content.body = "Provider crashed \(self.maxRestarts) times. Check logs: darkbloom logs" - content.sound = .default - let request = UNNotificationRequest(identifier: "crash-limit", content: content, trigger: nil) - try? await UNUserNotificationCenter.current().add(request) - } - } - } - - do { - try proc.run() - process = proc - stdoutPipe = outPipe - stderrPipe = errPipe - isRunning = true - } catch { - lastError = "Failed to start provider: \(error.localizedDescription)" - isRunning = false - TelemetryReporter.shared.emit( - kind: .backendCrash, - severity: .error, - message: "failed to launch provider subprocess", - fields: [ - "component": "app", - "backend": "darkbloom", - "reason": "spawn_failed", - "error": error.localizedDescription, - ] - ) - } - } -} diff --git a/app/EigenInference/Sources/EigenInference/Resources/AppIcon-full.png b/app/EigenInference/Sources/EigenInference/Resources/AppIcon-full.png deleted file mode 100644 index 45c47666199ddc54b4717b1ebead298676719ec3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6770 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&zE~RK2WrGXsN^rKgKyNX4ADw>Ngp4V7rS zn17lxY4SO*j)+R;BunuLW)sd?^-eA2(hZ8@nq>8$lx3rzxbvx!GN%?64h{kCx!!0zc4WTXm~U8=kqTd>)tAMuq!ew0ml7@dr_~Cp1D&r zVRruezX@q|c6*r_)=mDzJDN2{OA7^HA{YT#qXFbif)zoGA3p;_L-x0q_kL@K``feB zmR7MG{}Z`yFHo<;*}vcGO>CpGYwN05<~$Guna3djA{87!WCH^uh++cOo1+pApimeM zlF`HfOa`O*W3*%dCW8@QSUxDKVqloF;rl*YC4Oy zL>L(wR_!+aeEOxJ%;$==rBDAcOsQZ6m87G!3Mdpt3zE@-1egp)d_m#>$_%58tI@_4 zFd1Mg6L-v!V<@;Vckg%G|BY+b`}03opbMInJo3lj-s`o4aOXNfSg!fS6>L zfws$TsADZ?xVTc+=^zA3yg%OW z%$)PhIrGf(KJRnRXZg8V=$${`2>?JJ%g%fsfFL*w0;mx9;o$c-0DvtxmYH#a*{)Zn zwG>&|6Q(rsS%t7EC@6ae`d6>)oBQRBsDI^WAP)ZH^t*3-U<&y7{U3h)=gps`XJ6{u z_x4*iZd7Ju9o_ZWg@xU_Un%$^Q&v3@GKevU<4f_nJq3xK(%Wt~>#MmKl+py(3HSDgAAz3`fwjAlLn6S;50$i#Vs+L!&GeQ1;t`f(OR%T;-V+v4rLsLl{|GoeH_>2wu1%mVZdJEuN7~5jZYG++E}3@{D?mnaI~) zHEi&d_SJfWVYOD%fx!#u_6@sH6#y6%2E#sSVLh2_B-K2^fOh1&he5k3EHp3#$N~{Q z7z2D|4N&2}9F^|NT;QL8FueSbw9)Djz!-ljX3tb~bo8)~zjdNwYJD1J%)uHbg*IR# z0G$B&ksO$pX8k@vC=^EA`UDXOG=GHnkk^z9m0LsZx@dD>hEa)Aj(YYsl|?SVs-V$m zhTh@T?N)MU?Z!<+cpJ(W&E!7$dM!R1~k*5ZuQpe1U}O9rMA%B zU=uC|($r zS6-^=)@+)6rP0A6ehT;pl#Y_#lRCHCy>#az#klKPkAPjpWHQg-vNWsM{!_hskVs@p ziLgd5PBhi!KYb4cqJFfLTmG7~`a-P}h&>qj>1R}dwrniDW24@^ZB-SIUC`2q&gX+1 z+*IXb>VW=c+KEfQA1HJg@6dx8d62d!e(VY5RU#Ia9}?DSk3=Go&ju!U8-nPJFNy(J zG=_1twcFRn8-G3T?e`HhHat`wiO-iVw z-!pfv?)J%^dqav4&VB8Hu~WTYz8>5j4O?l&{W?+NOhTK+WHQ0NZYYgxsw8$9um?5D zWFD#g3;~BrCGxrtqS~>()f3$<#KWm>i`-MJysnjb&)vBe>zSG*d0iyC-R}JnhOiPx z=DUd1(7pa{pb$r^$73#2ZcSGtF1xp(Mvm2*Hy-49KVF^*fD*RG{>yqY)vT;lUn%hX zAir>N`dw7qseLflCrBChef30xm}2%2OT@X zB6q1GadUIC_|QY?Z)01hfRXoL^G!)jEjk|EotvGcx$tFHR#r25JVvZB8tK}_V6O8u05YM*aZL9jyydv5 zIGf$BnNdHYel2}iolGPa6TL1oC&g|4_CfhB!HoARG^ueS%cHK65&DyysaVV-UJtwk zspoqNXl$o`SVxIy?kJcTS`wfbPRW+swyKRY>@VnZJ>62zh~Z`L6SIS*3a`z-4Gpq| zere1xnw#*ZVsL#?o$(Dcupe9yvEADdg7NKVB_*Odt8=MhXs{@DSac{#76!QC5nq#& z6bW^d&9;FQ^Yyk^565-?%G_uoy)U^80k~*G;i!4<+Fif6t`DAQ_=CH9-cs0mvY9GCf@D(eEc#^6{Q2dN5F?iXAW5KUYlP3JN~N+n1O-+o zxG92c023DWDxv8+I=AX5;0nJj7SK2stDdoOHF5}1vr`TeollOM_ z#sc*6# zvxZRrUVCAr0}TH zH>@pr+vu!^xvxDXd%nX{F8E~s$w`FyC7JIiyNfvgW(U7k2=d7ka^2_v0000z zOHRWu5QaY|DH{-vvSQB>;0zpqEfT#4dv1hOHY~UYN8qUfqK_CE#?@e)RH1GN;+HI2 zf7%Ehtf1nKyp*F0_vN<|;mt}MeMVrS#+?HIBmENbW4%n(v zU{)ol05;WTzUbYmnxo-uwpZZcrwcAC z$OiA}T-{l99GNZ;?sQvSGD5GO28uCq~E#j;+h9*Xey`=?8>`& zEaNIpU6wsne9V{h9LitinJV!io@F6_XSomBSip%@>SN$4Cc3GI))R5&kNOZz1=)x# z4BWcf<2{rtsLhg`y3Ffl9m?n!GXA+BvxuOB+GT)mO-g>!EPDsW*@+Cf{-E^$0000< KMNUMnLSTXfL-9KR diff --git a/app/EigenInference/Sources/EigenInference/Resources/guide-avatar-celebrating.png b/app/EigenInference/Sources/EigenInference/Resources/guide-avatar-celebrating.png deleted file mode 100644 index b8fe86f509acbed30036fb18503fec95afb7341b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1632685 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

fyarHHRYgtUy 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