From 5105afeb77a9cda298a9b0d69bfa1de44072bda5 Mon Sep 17 00:00:00 2001 From: Harzva <49864959+Harzva@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:00:56 +0800 Subject: [PATCH] feat: harden MobileCore model switching --- README.md | 2 +- docs/mobilecore-dual-app-qa.md | 27 ++ docs/releases/v0.1.78.md | 3 + mobile_agent/lib/screens/home_screen.dart | 27 +- .../lib/services/tuima_provider_service.dart | 306 ++++++++++++++++-- .../services/tuima_provider_service_test.dart | 255 +++++++++++++++ 6 files changed, 576 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 38bb2fb..c4e9787 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ flowchart LR ## MobileCore Link Status -MobileCode no longer hard-codes a local Qwen model. `MobileCoreClient` resolves the active model, runtime, revision, backend, quantization, capabilities, artifact state, resource preflight, Android background-restriction state, recommendations, and performance metrics from the co-installed MobileCore service. The in-app TuiMa sheet can load, unload, and switch installed models by public `model_id`; ordinary clients never receive or submit absolute model paths. A background-restricted MobileCore remains visible for recovery but is removed from eligible local routes before inference payloads are sent. +MobileCode no longer hard-codes a local Qwen model. `MobileCoreClient` resolves the active model, runtime, revision, backend, quantization, capabilities, artifact state, resource preflight, Android background-restriction state, recommendations, and performance metrics from the co-installed MobileCore service. The in-app TuiMa sheet can load, unload, and switch installed models by public `model_id`; ordinary clients never receive or submit absolute model paths. Cross-model switching projects the memory available after reclaiming the matching active runtime, retains safety headroom, and revalidates the runtime immediately before loading so a stale snapshot cannot trigger a lifecycle request. A background-restricted MobileCore remains visible for recovery but is removed from eligible local routes before inference payloads are sent. Image and audio buttons appear only when the active local runtime advertises the corresponding capability. Attachment bytes stay in memory, are sent only to `127.0.0.1`, are never persisted in chat turns or evidence, and never fall back to a cloud provider. Local inference evidence records safe model/runtime/latency metadata while omitting prompts, media, credentials, and payloads. diff --git a/docs/mobilecore-dual-app-qa.md b/docs/mobilecore-dual-app-qa.md index 0e59916..3097f19 100644 --- a/docs/mobilecore-dual-app-qa.md +++ b/docs/mobilecore-dual-app-qa.md @@ -30,6 +30,15 @@ IDs containing path separators or control characters are rejected before a request is sent, and a switch is successful only when `/health` reports the exact requested public model ID. +Model switching also performs a projected post-switch memory check. MobileCode +may count the current runtime peak as reclaimable only when health and metrics +identify the same active model, caps that value by the active model's estimated +memory, and keeps 10% projected headroom. Under pressure it reduces requested +context to at most 2048 tokens. The client revalidates the runtime identity +immediately before the load request; a concurrent model, backend, capability, +quantization, or background-state change returns `runtime_snapshot_changed` +without sending `/mobilecore/model/load`. + The TuiMa control sheet exposes that state inside MobileCode. When MobileCore reports a complete verified Omni pair that is not active, the sheet offers an explicit local activation control. Image and audio entry points remain hidden @@ -125,6 +134,23 @@ ID as active with 456 MB peak memory, before restoring the exact Qwen2.5 public ID. This proves the v2 control path against the running dual-app service, but it remains emulator evidence and does not satisfy the physical-device gate. +### v0.1.78 projected-switch and offline regression + +On 2026-08-07, the final `pureDebug` v0.1.78 (`68`) candidate containing the +projected-memory switch preflight was clean-built and reinstalled with its +Android test APK. The MobileCode APK SHA-256 was +`f7ef72d6f615edae7015e6113d0c60b14eabf0ae42181ebae7b2b490581f705c`. + +The same Android 16 ARM64 emulator then passed the full 30-task cross-app lane +again in airplane mode: 15 buffered requests and 15 SSE requests completed in +274.615 seconds. The test asserted non-empty local output, SSE completion, +MobileCore metrics, and absence of the controlled prompt marker in metrics. +Airplane mode was restored by the host runner. MobileCode cold-launched as +v0.1.78, MobileCore 0.1.4-rc6 remained a foreground service with protocol v2 +and the real Qwen2.5 GGUF loaded, and the post-run log scan found no app fatal +exception, ANR, OOM, or SIGABRT. This is repeatable emulator evidence; it does +not replace the pending physical-device lane. + ### One-task cloud approval check On 2026-08-07, the one-task cloud approval path was exercised through the real @@ -461,6 +487,7 @@ Raw screenshots and sanitized logcat remain under the ignored `.qa-artifacts/` d background-recovery, and APK-signing paths pass 12 deterministic host-side unit tests. - ActionEvidence inference-to-device linking passes focused unit coverage, including idempotency and action-type rejection. +- The final v0.1.78 client regression passes 582 Flutter tests, including stale-runtime rejection before model load and projected-memory refusal without a load request. ## Remaining Release Gates diff --git a/docs/releases/v0.1.78.md b/docs/releases/v0.1.78.md index d9311c0..b8ff1bb 100644 --- a/docs/releases/v0.1.78.md +++ b/docs/releases/v0.1.78.md @@ -8,12 +8,15 @@ Release type: local-model linkage and Android background-safety pre-release patc - Reject local inference with a typed `background_restricted` error before sending prompts or attachments, while showing a user-controlled Android Battery-settings recovery instruction. - Keep a restricted but loaded model visible in the coherent MobileCore runtime snapshot instead of misclassifying the control plane as inconsistent. - Activate the pinned Omni runtime for a selected local image or audio task only when both artifacts are verified, then re-check the live capability before sending media bytes. +- Project post-switch memory from the coherent runtime snapshot, count only bounded matching active-runtime memory as reclaimable, retain 10% headroom, and reduce context under pressure. +- Revalidate the active runtime immediately before model load so a concurrent model, backend, capability, quantization, or background-state change fails without sending the lifecycle request. - Preserve the v0.1.77 public-build credential policy: no provider keys, relay bearer tokens, or OAuth client secrets are compiled into distributed Android/iOS binaries. ## Acceptance gates - Focused MobileCore provider and adaptive-policy tests cover restricted probing, zero-payload inference rejection, coherent restricted snapshots, and verified Omni activation. - The full Flutter test suite and targeted analyzer complete without fatal diagnostics. +- The final client regression passes 582 Flutter tests; the Android 16 ARM64 emulator passes 30/30 offline cross-app tasks with the rebuilt v0.1.78 APK. - The exact upload-signed `0.1.78+68` APK passes version, certificate, credential-pattern, install, cold-launch, and fatal-log checks. - With MobileCode foregrounded, MobileCore `0.1.4-rc6` retains a real local model for 40 authenticated health polls and one controlled inference without FGS timeout, freeze, ANR, OOM, or SIGABRT. diff --git a/mobile_agent/lib/screens/home_screen.dart b/mobile_agent/lib/screens/home_screen.dart index d1a7844..5458642 100644 --- a/mobile_agent/lib/screens/home_screen.dart +++ b/mobile_agent/lib/screens/home_screen.dart @@ -14125,32 +14125,15 @@ class _ChatPanelState extends State<_ChatPanel> { Future _switchMobileCoreModel(String modelId) async { final startedAt = DateTime.now(); try { - final recommendation = await _tuimaProviderService.recommendations(); - final matches = recommendation.recommendations - .where((item) => item.modelId == modelId) - .toList(growable: false); - final entry = matches.isEmpty ? null : matches.first; - final constrained = entry?.fit == 'too_tight'; - if (constrained) { - throw const MobileCoreProviderException( - code: 'insufficient_memory', - message: - 'MobileCore reports that this model is too tight for the device.', - ); - } - final contextLength = entry == null || entry.contextLength <= 0 - ? 4096 - : entry.contextLength.clamp(512, 8192); - await _tuimaProviderService.switchModel( - modelId, - contextLength: contextLength, - ); - await _refreshTuimaHealth(); + final result = + await _tuimaProviderService.switchModelWithPreflight(modelId); + if (mounted) setState(() => _tuimaHealth = result.health); await _recordMobileCoreControl( operation: 'switch_model', modelId: modelId, startedAt: startedAt, success: true, + safeMetadata: {'switchPreflight': result.plan.evidenceMetadata}, ); } on Object catch (error) { await _recordMobileCoreControl( @@ -14389,7 +14372,7 @@ class _ChatPanelState extends State<_ChatPanel> { title: Text(model.id, style: const TextStyle(color: _text)), subtitle: Text( - '${model.quantization} · ${_formatBytes(model.sizeBytes)} · context ${model.contextLength}' + '${model.backend} · ${model.quantization} · ${_formatBytes(model.sizeBytes)} · context ${model.contextLength}' '${model.capabilities.imageInput ? ' · image' : ''}', style: const TextStyle(color: _muted, fontSize: 12), ), diff --git a/mobile_agent/lib/services/tuima_provider_service.dart b/mobile_agent/lib/services/tuima_provider_service.dart index 4bde9bb..0fa0bfc 100644 --- a/mobile_agent/lib/services/tuima_provider_service.dart +++ b/mobile_agent/lib/services/tuima_provider_service.dart @@ -279,7 +279,14 @@ class MobileCoreMetrics { const MobileCoreMetrics({ this.activeModel, this.backend = '', + this.uptimeSeconds = 0, + this.requestsTotal = 0, + this.requestsCompleted = 0, + this.requestsFailed = 0, + this.inferenceCancelRequests = 0, + this.inferenceBusyRejections = 0, this.decodeTokensPerSecond = 0, + this.averageDecodeTokensPerSecond = 0, this.firstTokenMs = 0, this.totalMs = 0, this.memoryPeakMb = 0, @@ -290,7 +297,14 @@ class MobileCoreMetrics { final String? activeModel; final String backend; + final int uptimeSeconds; + final int requestsTotal; + final int requestsCompleted; + final int requestsFailed; + final int inferenceCancelRequests; + final int inferenceBusyRejections; final double decodeTokensPerSecond; + final double averageDecodeTokensPerSecond; final int firstTokenMs; final int totalMs; final int memoryPeakMb; @@ -301,7 +315,14 @@ class MobileCoreMetrics { Map get evidenceMetadata => { 'activeModel': activeModel, 'backend': backend, + 'uptimeSeconds': uptimeSeconds, + 'requestsTotal': requestsTotal, + 'requestsCompleted': requestsCompleted, + 'requestsFailed': requestsFailed, + 'inferenceCancelRequests': inferenceCancelRequests, + 'inferenceBusyRejections': inferenceBusyRejections, 'decodeTokensPerSecond': decodeTokensPerSecond, + 'averageDecodeTokensPerSecond': averageDecodeTokensPerSecond, 'firstTokenMs': firstTokenMs, 'totalMs': totalMs, 'memoryPeakMb': memoryPeakMb, @@ -315,8 +336,16 @@ class MobileCoreMetrics { return MobileCoreMetrics( activeModel: _nullableString(map['active_model']), backend: map['backend']?.toString() ?? '', + uptimeSeconds: _asInt(map['uptime_seconds']), + requestsTotal: _asInt(map['requests_total']), + requestsCompleted: _asInt(map['requests_completed']), + requestsFailed: _asInt(map['requests_failed']), + inferenceCancelRequests: _asInt(map['inference_cancel_requests']), + inferenceBusyRejections: _asInt(map['inference_busy_rejections']), decodeTokensPerSecond: _asDouble(map['last_decode_tokens_per_second'] ?? map['decode_tokens_per_second']), + averageDecodeTokensPerSecond: + _asDouble(map['average_decode_tokens_per_second']), firstTokenMs: _asInt(map['last_first_token_ms'] ?? map['first_token_ms']), totalMs: _asInt(map['last_total_ms'] ?? map['total_ms']), memoryPeakMb: _asInt(map['memory_peak_mb']), @@ -331,6 +360,7 @@ class MobileCoreMetrics { class MobileCoreModel { const MobileCoreModel({ required this.id, + this.backend = 'unknown', this.quantization = 'unknown', this.contextLength = 0, this.sizeBytes = 0, @@ -344,6 +374,7 @@ class MobileCoreModel { }); final String id; + final String backend; final String quantization; final int contextLength; final int sizeBytes; @@ -360,6 +391,7 @@ class MobileCoreModel { final details = _stringMap(map['mobilecore']); return MobileCoreModel( id: map['id']?.toString() ?? '', + backend: details['backend']?.toString() ?? 'unknown', quantization: details['quantization']?.toString() ?? 'unknown', contextLength: _asInt(details['context_length']), sizeBytes: _asInt(details['size_bytes']), @@ -468,6 +500,59 @@ class MobileCoreRuntimeSnapshot { final DateTime capturedAt; } +class MobileCoreModelSwitchPlan { + const MobileCoreModelSwitchPlan({ + required this.modelId, + required this.allowed, + required this.contextLength, + required this.availableMemoryMb, + required this.reclaimableRuntimeMemoryMb, + required this.projectedAvailableMemoryMb, + required this.estimatedRequiredMemoryMb, + required this.serverFit, + this.noSwitchRequired = false, + this.usedReclaimableRuntimeMemory = false, + this.failureCode, + }); + + final String modelId; + final bool allowed; + final bool noSwitchRequired; + final int contextLength; + final int availableMemoryMb; + final int reclaimableRuntimeMemoryMb; + final int projectedAvailableMemoryMb; + final int estimatedRequiredMemoryMb; + final String serverFit; + final bool usedReclaimableRuntimeMemory; + final String? failureCode; + + Map get evidenceMetadata => { + 'modelId': modelId, + 'allowed': allowed, + 'noSwitchRequired': noSwitchRequired, + 'contextLength': contextLength, + 'availableMemoryMb': availableMemoryMb, + 'reclaimableRuntimeMemoryMb': reclaimableRuntimeMemoryMb, + 'projectedAvailableMemoryMb': projectedAvailableMemoryMb, + 'estimatedRequiredMemoryMb': estimatedRequiredMemoryMb, + 'serverFit': serverFit, + 'usedReclaimableRuntimeMemory': usedReclaimableRuntimeMemory, + if (failureCode != null) 'failureCode': failureCode, + 'redaction': 'model_paths_and_payloads_omitted', + }; +} + +class MobileCoreModelSwitchResult { + const MobileCoreModelSwitchResult({ + required this.plan, + required this.health, + }); + + final MobileCoreModelSwitchPlan plan; + final TuimaHealth health; +} + enum MobileCoreAttachmentKind { image, audio } class MobileCoreAttachment { @@ -920,6 +1005,79 @@ class MobileCoreClient { ); } + Future planModelSwitch( + String modelId, { + Duration timeout = const Duration(seconds: 5), + }) async { + final normalized = _validatedPublicArtifactId( + modelId, + code: 'invalid_model_id', + label: 'model', + ); + final snapshot = await runtimeSnapshot(timeout: timeout); + return _modelSwitchPlan(normalized, snapshot); + } + + /// Plans and performs a model switch against one coherent v2 snapshot. + /// + /// MobileCore reports currently available RAM while the active model is + /// still resident. For a cross-model switch, the active runtime peak is + /// reclaimable. Counting it only when metrics and health identify the same + /// active model avoids trapping the client on the first loaded model while + /// retaining a conservative 10% projected-memory headroom. + Future switchModelWithPreflight( + String modelId, { + int threads = 4, + Duration snapshotTimeout = const Duration(seconds: 5), + Duration loadTimeout = const Duration(minutes: 2), + }) async { + final normalized = _validatedPublicArtifactId( + modelId, + code: 'invalid_model_id', + label: 'model', + ); + final snapshot = await runtimeSnapshot(timeout: snapshotTimeout); + final plan = _modelSwitchPlan(normalized, snapshot); + if (!plan.allowed) { + throw MobileCoreProviderException( + code: plan.failureCode ?? 'model_switch_preflight_failed', + message: switch (plan.failureCode) { + 'background_restricted' => + 'MobileCore background operation is restricted. Allow it in Android Battery settings before switching models.', + 'model_not_found' => + 'The selected model is not present in MobileCore model discovery.', + 'service_unavailable' => + 'MobileCore is unavailable on the loopback service.', + 'invalid_response' => + 'MobileCore returned an ambiguous model discovery response.', + _ => + 'The selected model does not pass the projected post-switch memory preflight.', + }, + ); + } + if (plan.noSwitchRequired) { + return MobileCoreModelSwitchResult( + plan: plan, + health: snapshot.health, + ); + } + final currentHealth = await probe(timeout: snapshotTimeout); + if (!_hasSameRuntimeIdentity(snapshot.health, currentHealth)) { + throw const MobileCoreProviderException( + code: 'runtime_snapshot_changed', + message: + 'MobileCore changed state after model-switch preflight. Retry with a fresh runtime snapshot.', + ); + } + final health = await switchModel( + normalized, + contextLength: plan.contextLength, + threads: threads, + timeout: loadTimeout, + ); + return MobileCoreModelSwitchResult(plan: plan, health: health); + } + Future loadModel( String modelId, { String? projectorId, @@ -1351,33 +1509,36 @@ class MobileCoreClient { (expectedAlgorithm.isEmpty || activeAlgorithm == expectedAlgorithm); } + static bool _hasSameRuntimeIdentity(TuimaHealth before, TuimaHealth after) => + before.state == after.state && + before.protocol?.name == after.protocol?.name && + before.protocol?.major == after.protocol?.major && + before.protocol?.minor == after.protocol?.minor && + before.protocol?.minimumClientMajor == + after.protocol?.minimumClientMajor && + before.protocol?.maximumClientMajor == + after.protocol?.maximumClientMajor && + before.activeModel == after.activeModel && + before.runtime == after.runtime && + before.runtimeRevision == after.runtimeRevision && + before.backend == after.backend && + before.quantization == after.quantization && + before.backgroundRestricted == after.backgroundRestricted && + before.projectorArtifact.fileName == after.projectorArtifact.fileName && + before.capabilities.textInput == after.capabilities.textInput && + before.capabilities.imageInput == after.capabilities.imageInput && + before.capabilities.audioInput == after.capabilities.audioInput && + before.capabilities.videoInput == after.capabilities.videoInput && + before.capabilities.textOutput == after.capabilities.textOutput && + before.capabilities.audioOutput == after.capabilities.audioOutput; + static bool _isConsistentRuntimeSnapshot({ required TuimaHealth before, required TuimaHealth after, required List models, required MobileCoreMetrics metrics, }) { - if (before.state != after.state || - before.protocol?.name != after.protocol?.name || - before.protocol?.major != after.protocol?.major || - before.protocol?.minor != after.protocol?.minor || - before.protocol?.minimumClientMajor != - after.protocol?.minimumClientMajor || - before.protocol?.maximumClientMajor != - after.protocol?.maximumClientMajor || - before.activeModel != after.activeModel || - before.runtime != after.runtime || - before.runtimeRevision != after.runtimeRevision || - before.backend != after.backend || - before.quantization != after.quantization || - before.backgroundRestricted != after.backgroundRestricted || - before.projectorArtifact.fileName != after.projectorArtifact.fileName || - before.capabilities.textInput != after.capabilities.textInput || - before.capabilities.imageInput != after.capabilities.imageInput || - before.capabilities.audioInput != after.capabilities.audioInput || - before.capabilities.videoInput != after.capabilities.videoInput || - before.capabilities.textOutput != after.capabilities.textOutput || - before.capabilities.audioOutput != after.capabilities.audioOutput) { + if (!_hasSameRuntimeIdentity(before, after)) { return false; } final metricsModel = metrics.activeModel?.trim() ?? ''; @@ -1392,6 +1553,109 @@ class MobileCoreClient { (loadedModels.isEmpty || loadedModels.single.id == after.activeModel); } + static MobileCoreModelSwitchPlan _modelSwitchPlan( + String modelId, + MobileCoreRuntimeSnapshot snapshot, + ) { + final health = snapshot.health; + if (health.state == TuimaConnectionState.unavailable) { + return MobileCoreModelSwitchPlan( + modelId: modelId, + allowed: false, + contextLength: 0, + availableMemoryMb: 0, + reclaimableRuntimeMemoryMb: 0, + projectedAvailableMemoryMb: 0, + estimatedRequiredMemoryMb: 0, + serverFit: 'unavailable', + failureCode: health.failureCode ?? 'service_unavailable', + ); + } + final matchingModels = + snapshot.models.where((model) => model.id == modelId).toList(); + if (matchingModels.length != 1) { + return MobileCoreModelSwitchPlan( + modelId: modelId, + allowed: false, + contextLength: 0, + availableMemoryMb: snapshot.recommendations.availableRamMb, + reclaimableRuntimeMemoryMb: 0, + projectedAvailableMemoryMb: snapshot.recommendations.availableRamMb, + estimatedRequiredMemoryMb: 0, + serverFit: matchingModels.isEmpty ? 'missing' : 'ambiguous', + failureCode: + matchingModels.isEmpty ? 'model_not_found' : 'invalid_response', + ); + } + final model = matchingModels.single; + final matchingRecommendations = snapshot.recommendations.recommendations + .where((item) => item.modelId == modelId) + .toList(); + final recommendation = + matchingRecommendations.isEmpty ? null : matchingRecommendations.single; + final activeModel = health.activeModel?.trim() ?? ''; + final noSwitchRequired = health.canInfer && activeModel == modelId; + final metricsMatchActive = activeModel.isNotEmpty && + snapshot.metrics.activeModel == activeModel && + modelId != activeModel; + final availableMemoryMb = snapshot.recommendations.availableRamMb; + final activeModels = + snapshot.models.where((item) => item.id == activeModel).toList(); + final activeRecommendations = snapshot.recommendations.recommendations + .where((item) => item.modelId == activeModel) + .toList(); + final activeEstimatedMemoryMb = activeRecommendations.length == 1 && + activeRecommendations.single.estimatedMemoryMb > 0 + ? activeRecommendations.single.estimatedMemoryMb + : activeModels.length == 1 + ? _fallbackEstimatedMemoryMb(activeModels.single) + : snapshot.metrics.memoryPeakMb; + final reclaimableMemoryMb = metricsMatchActive + ? snapshot.metrics.memoryPeakMb.clamp(0, activeEstimatedMemoryMb) + : 0; + final projectedAvailableMemoryMb = availableMemoryMb + reclaimableMemoryMb; + final estimatedRequiredMemoryMb = + recommendation?.estimatedMemoryMb ?? _fallbackEstimatedMemoryMb(model); + final serverFit = recommendation?.fit.toLowerCase() ?? 'unreported'; + final serverAllows = + const {'perfect', 'good', 'marginal'}.contains(serverFit); + final projectedAllows = projectedAvailableMemoryMb > 0 && + estimatedRequiredMemoryMb > 0 && + estimatedRequiredMemoryMb <= projectedAvailableMemoryMb * 0.90; + final allowed = noSwitchRequired || + (!health.backgroundRestricted && (serverAllows || projectedAllows)); + final requestedContext = + recommendation?.contextLength ?? model.contextLength; + final baseContext = requestedContext <= 0 ? 4096 : requestedContext; + final pressureRatio = projectedAvailableMemoryMb <= 0 + ? 1.0 + : estimatedRequiredMemoryMb / projectedAvailableMemoryMb; + final contextLength = (pressureRatio >= 0.75 + ? baseContext.clamp(512, 2048) + : baseContext.clamp(512, 4096)); + final failureCode = allowed + ? null + : health.backgroundRestricted + ? 'background_restricted' + : 'insufficient_memory'; + return MobileCoreModelSwitchPlan( + modelId: modelId, + allowed: allowed, + noSwitchRequired: noSwitchRequired, + contextLength: contextLength, + availableMemoryMb: availableMemoryMb, + reclaimableRuntimeMemoryMb: reclaimableMemoryMb, + projectedAvailableMemoryMb: projectedAvailableMemoryMb, + estimatedRequiredMemoryMb: estimatedRequiredMemoryMb, + serverFit: serverFit, + usedReclaimableRuntimeMemory: !serverAllows && projectedAllows, + failureCode: failureCode, + ); + } + + static int _fallbackEstimatedMemoryMb(MobileCoreModel model) => + ((model.sizeBytes + 128 * 1024 * 1024) / (1024 * 1024)).ceil(); + void close() => _client.close(force: true); static HybridModelRouteDecision route({ diff --git a/mobile_agent/test/services/tuima_provider_service_test.dart b/mobile_agent/test/services/tuima_provider_service_test.dart index f2d260d..d64f896 100644 --- a/mobile_agent/test/services/tuima_provider_service_test.dart +++ b/mobile_agent/test/services/tuima_provider_service_test.dart @@ -392,6 +392,7 @@ void main() { 'id': 'small-q4', 'mobilecore': { 'path': '/private/models/small-q4.gguf', + 'backend': 'llama.cpp', 'quantization': 'Q4_K_M', 'context_length': 4096, 'size_bytes': 1234, @@ -411,7 +412,14 @@ void main() { request.response.write(jsonEncode({ 'active_model': 'small-q4', 'backend': 'llama.cpp', + 'uptime_seconds': 99, + 'requests_total': 8, + 'requests_completed': 6, + 'requests_failed': 1, + 'inference_cancel_requests': 1, + 'inference_busy_rejections': 2, 'last_decode_tokens_per_second': 18.5, + 'average_decode_tokens_per_second': 17.25, 'last_first_token_ms': 82, 'last_total_ms': 200, 'memory_peak_mb': 512, @@ -423,13 +431,21 @@ void main() { final models = await service.listModels(); final metrics = await service.metrics(); expect(models.single.id, 'small-q4'); + expect(models.single.backend, 'llama.cpp'); expect(models.single.quantization, 'Q4_K_M'); expect(models.single.projectorId, 'mmproj-small-q4-bf16'); expect(models.single.projectorSizeBytes, 456); expect(models.single.capabilities.imageInput, isTrue); expect(models.single.toString(), isNot(contains('/private/'))); expect(metrics.decodeTokensPerSecond, 18.5); + expect(metrics.averageDecodeTokensPerSecond, 17.25); expect(metrics.firstTokenMs, 82); + expect(metrics.uptimeSeconds, 99); + expect(metrics.requestsTotal, 8); + expect(metrics.requestsCompleted, 6); + expect(metrics.requestsFailed, 1); + expect(metrics.inferenceCancelRequests, 1); + expect(metrics.inferenceBusyRejections, 2); }); test('reads a coherent runtime snapshot across control endpoints', @@ -634,6 +650,245 @@ void main() { expect(unloadedHealth.canInfer, isFalse); }); + test( + 'switch preflight counts only the matching active runtime as reclaimable', + () async { + var activeModel = 'current-q4'; + var loadRequests = 0; + server.listen((request) async { + request.response.headers.contentType = ContentType.json; + switch (request.uri.path) { + case '/health': + request.response.write( + jsonEncode(_healthPayload(model: activeModel)), + ); + break; + case '/v1/models': + request.response.write(jsonEncode({ + 'data': [ + { + 'id': 'current-q4', + 'mobilecore': { + 'backend': 'llama.cpp', + 'size_bytes': 480 * 1024 * 1024, + 'context_length': 4096, + 'loaded': activeModel == 'current-q4', + }, + }, + { + 'id': 'target-q4', + 'mobilecore': { + 'backend': 'llama.cpp', + 'size_bytes': 468 * 1024 * 1024, + 'context_length': 32768, + 'loaded': activeModel == 'target-q4', + }, + }, + ], + })); + break; + case '/metrics': + request.response.write(jsonEncode({ + 'active_model': activeModel, + 'backend': 'android-llama-cpp', + 'memory_peak_mb': 456, + })); + break; + case '/v1/recommendations': + request.response.write(jsonEncode({ + 'device': {'available_ram_mb': 554}, + 'recommendations': [ + { + 'model_id': 'current-q4', + 'fit': 'too_tight', + 'estimated_memory_mb': 589, + 'context_length': 4096, + 'loaded': activeModel == 'current-q4', + 'score': 0, + }, + { + 'model_id': 'target-q4', + 'fit': 'too_tight', + 'estimated_memory_mb': 596, + 'context_length': 32768, + 'loaded': activeModel == 'target-q4', + 'score': 0, + }, + ], + })); + break; + case '/mobilecore/model/load': + final body = jsonDecode(await utf8.decoder.bind(request).join()) + as Map; + expect(body['model_id'], 'target-q4'); + expect(body['context_length'], 4096); + expect(body.containsKey('path'), isFalse); + loadRequests += 1; + activeModel = 'target-q4'; + request.response.write('{"ok":true}'); + break; + } + await request.response.close(); + }); + + final result = await service.switchModelWithPreflight('target-q4'); + expect(result.health.activeModel, 'target-q4'); + expect(result.plan.allowed, isTrue); + expect(result.plan.serverFit, 'too_tight'); + expect(result.plan.availableMemoryMb, 554); + expect(result.plan.reclaimableRuntimeMemoryMb, 456); + expect(result.plan.projectedAvailableMemoryMb, 1010); + expect(result.plan.estimatedRequiredMemoryMb, 596); + expect(result.plan.usedReclaimableRuntimeMemory, isTrue); + expect(result.plan.contextLength, 4096); + expect(result.plan.evidenceMetadata.toString(), isNot(contains('/'))); + expect(loadRequests, 1); + }); + + test('switch preflight rejects a stale runtime before model load', + () async { + var healthRequests = 0; + var loadRequests = 0; + server.listen((request) async { + request.response.headers.contentType = ContentType.json; + switch (request.uri.path) { + case '/health': + healthRequests += 1; + request.response.write(jsonEncode(_healthPayload( + model: healthRequests <= 2 ? 'current-q4' : 'other-q4', + ))); + break; + case '/v1/models': + request.response.write(jsonEncode({ + 'data': [ + { + 'id': 'current-q4', + 'mobilecore': { + 'size_bytes': 128 * 1024 * 1024, + 'loaded': true, + }, + }, + { + 'id': 'target-q4', + 'mobilecore': { + 'size_bytes': 128 * 1024 * 1024, + 'loaded': false, + }, + }, + ], + })); + break; + case '/metrics': + request.response.write(jsonEncode({ + 'active_model': 'current-q4', + 'backend': 'android-llama-cpp', + 'memory_peak_mb': 100, + })); + break; + case '/v1/recommendations': + request.response.write(jsonEncode({ + 'device': {'available_ram_mb': 1000}, + 'recommendations': [ + { + 'model_id': 'target-q4', + 'fit': 'good', + 'estimated_memory_mb': 256, + 'context_length': 4096, + }, + ], + })); + break; + case '/mobilecore/model/load': + loadRequests += 1; + request.response.write('{"ok":true}'); + break; + } + await request.response.close(); + }); + + await expectLater( + service.switchModelWithPreflight('target-q4'), + throwsA(isA().having( + (error) => error.code, + 'code', + 'runtime_snapshot_changed', + )), + ); + expect(healthRequests, 3); + expect(loadRequests, 0); + }); + + test('switch preflight rejects insufficient projected memory without load', + () async { + var loadRequests = 0; + server.listen((request) async { + request.response.headers.contentType = ContentType.json; + switch (request.uri.path) { + case '/health': + request.response + .write(jsonEncode(_healthPayload(model: 'current-q4'))); + break; + case '/v1/models': + request.response.write(jsonEncode({ + 'data': [ + { + 'id': 'current-q4', + 'mobilecore': { + 'size_bytes': 128 * 1024 * 1024, + 'loaded': true, + }, + }, + { + 'id': 'large-q4', + 'mobilecore': { + 'size_bytes': 700 * 1024 * 1024, + 'context_length': 8192, + 'loaded': false, + }, + }, + ], + })); + break; + case '/metrics': + request.response.write(jsonEncode({ + 'active_model': 'current-q4', + 'backend': 'android-llama-cpp', + 'memory_peak_mb': 100, + })); + break; + case '/v1/recommendations': + request.response.write(jsonEncode({ + 'device': {'available_ram_mb': 200}, + 'recommendations': [ + { + 'model_id': 'large-q4', + 'fit': 'too_tight', + 'estimated_memory_mb': 700, + 'context_length': 8192, + 'score': 0, + }, + ], + })); + break; + case '/mobilecore/model/load': + loadRequests += 1; + request.response.write('{"ok":true}'); + break; + } + await request.response.close(); + }); + + await expectLater( + service.switchModelWithPreflight('large-q4'), + throwsA(isA().having( + (error) => error.code, + 'code', + 'insufficient_memory', + )), + ); + expect(loadRequests, 0); + }); + test('activates only a verified Omni pair and rechecks audio capability', () async { var loaded = false;