From 8b11d77c975030c410ad8f61365a91560ae25c73 Mon Sep 17 00:00:00 2001 From: joungmin Date: Tue, 14 Jul 2026 16:15:29 +0900 Subject: [PATCH] =?UTF-8?q?[Developer]=20#657=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=AA=85=20.litertlm=20fix=20+=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20+=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20dev=20=EC=9D=B8=ED=94=84=EB=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelConfig.filename 기본값 gemma4_e2b_q4.bin → gemma-4-E2B-it.litertlm (#342 근본 원인) - _migrateLegacyPath: 기존 .bin 설치본 rename + meta 갱신 (재다운로드 2.4GB 방지, 멱등) - maxTokens 2048 유지 + 1024 회귀 방지 주석 (KV cache < prefill signature 시 추론 실패) - LLM_BACKEND dart-define: 에뮬레이터 CPU 강제 (SwiftShader GPU 네이티브 SIGABRT 회피) - GEMMA_MODEL_URL dart-define: 호스트 로컬 모델 서버 주입 (기본값 = HF URL 불변) - debug 전용 cleartext manifest (10.0.2.2 모델 서버용, release 불변) - 마이그레이션 테스트 4건 신규 (AC-2/3/4). 171 passed, analyze clean 2026-07-14 에뮬레이터 E2E 검증: 다운로드→로드→tool call 왕복 전 구간 성공. Refs #657, #342 Co-Authored-By: Claude Fable 5 --- app/android/app/src/debug/AndroidManifest.xml | 4 + app/lib/data/ai/gemma_llm_service.dart | 24 +++++- app/lib/data/ai/model_lifecycle.dart | 31 +++++++- app/lib/state/ai_providers.dart | 13 +++- app/test/data/ai/model_lifecycle_test.dart | 76 +++++++++++++++++++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml index 399f698..0f7f35d 100644 --- a/app/android/app/src/debug/AndroidManifest.xml +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,8 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/app/lib/data/ai/gemma_llm_service.dart b/app/lib/data/ai/gemma_llm_service.dart index 5d42831..309a4b4 100644 --- a/app/lib/data/ai/gemma_llm_service.dart +++ b/app/lib/data/ai/gemma_llm_service.dart @@ -13,6 +13,20 @@ import 'llm_service.dart'; /// local file path generally does not require the token. const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: ''); +/// Backend override for dev builds. The Android emulator advertises a +/// software Vulkan adapter (SwiftShader) that SIGABRTs LiteRT-LM's GPU +/// init natively — the Dart-level gpu→cpu fallback can't catch a native +/// abort, so emulator runs must force CPU: `--dart-define=LLM_BACKEND=cpu`. +/// Unset (default) keeps the SDK's gpu→cpu fallback for real devices. +const String _backendOverride = + String.fromEnvironment('LLM_BACKEND', defaultValue: ''); + +PreferredBackend? get _preferredBackend => switch (_backendOverride) { + 'cpu' => PreferredBackend.cpu, + 'gpu' => PreferredBackend.gpu, + _ => null, + }; + /// One-shot guard so [FlutterGemma.initialize] runs at most once per /// isolate. Re-init is unsupported by the underlying plugin. bool _initialized = false; @@ -70,7 +84,15 @@ class GemmaLlmService implements LlmService { modelType: ModelType.gemma4, fileType: ModelFileType.litertlm, ).fromFile(modelPath).install(); - final model = await FlutterGemma.getActiveModel(maxTokens: 2048); + // #342 root cause was the model *filename* (.bin — LiteRT-LM rejects it; + // must be .litertlm), NOT the KV cache size. maxTokens stays 2048: the + // Gemma 4 E2B compiled graph requires a cache ≥ its prefill signature — + // 1024 fails tensor allocation (DYNAMIC_UPDATE_SLICE prepare, verified + // on-emulator 2026-07-09). + final model = await FlutterGemma.getActiveModel( + maxTokens: 2048, + preferredBackend: _preferredBackend, + ); _model = model; _loaded = true; } diff --git a/app/lib/data/ai/model_lifecycle.dart b/app/lib/data/ai/model_lifecycle.dart index c9f0247..90a45b1 100644 --- a/app/lib/data/ai/model_lifecycle.dart +++ b/app/lib/data/ai/model_lifecycle.dart @@ -66,11 +66,16 @@ class _ProdStorage implements StorageAdapter { class ModelConfig { final Uri url; final String expectedSha256; + + /// MUST keep the `.litertlm` extension: LiteRT-LM sniffs the container + /// format from the file extension, and a `.bin` name makes native + /// engine_create fail with "Model may be invalid" on every backend + /// (#342 root cause, verified on-emulator 2026-07-09). final String filename; const ModelConfig({ required this.url, required this.expectedSha256, - this.filename = 'gemma4_e2b_q4.bin', + this.filename = 'gemma-4-E2B-it.litertlm', }); } @@ -94,6 +99,26 @@ class ModelLifecycle { return p.join(dir.path, config.filename); } + /// #657 AC-2/3/4: pre-.litertlm installs saved the model as + /// `gemma4_e2b_q4.bin`, which LiteRT-LM's engine_create rejects. Renaming + /// the existing file (same bytes — SHA meta stays valid) spares those + /// users a 2.4GB re-download. Idempotent; a failed rename leaves state + /// untouched so the next availability check retries. + Future _migrateLegacyPath() async { + final metaPath = await meta.find(AiMetaKeys.modelPath); + if (metaPath == null) return; + final canonical = await _modelPath(); + if (metaPath == canonical) return; + final legacy = File(metaPath); + if (!legacy.existsSync()) return; + try { + await legacy.rename(canonical); + } catch (_) { + return; + } + await meta.put(AiMetaKeys.modelPath, canonical); + } + /// Lightweight ready estimate for warm-up gating (#311). /// /// Skips the SHA-256 re-hash that [checkAvailability] performs — for a @@ -117,6 +142,8 @@ class ModelLifecycle { return ModelAvailability.downloading; } + await _migrateLegacyPath(); + final pathStr = await meta.find(AiMetaKeys.modelPath); if (pathStr == null) return ModelAvailability.missing; @@ -142,6 +169,8 @@ class ModelLifecycle { return ModelAvailability.downloading; } + await _migrateLegacyPath(); + final pathStr = await meta.find(AiMetaKeys.modelPath); if (pathStr == null) return ModelAvailability.missing; diff --git a/app/lib/state/ai_providers.dart b/app/lib/state/ai_providers.dart index 32685de..825a92b 100644 --- a/app/lib/state/ai_providers.dart +++ b/app/lib/state/ai_providers.dart @@ -16,10 +16,15 @@ import 'providers.dart'; /// File ≈ 2.41GB; SHA-256 pinned for integrity check. /// /// Tests / placeholder builds may override `modelLifecycleProvider` with -/// fixture URLs. Production builds optionally inject a private mirror via -/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart). -const _kModelUrl = - 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm'; +/// fixture URLs. Dev/mirror builds may inject an alternate download origin +/// via `--dart-define=GEMMA_MODEL_URL=...` (e.g. a host-local server when +/// testing on the Android emulator). SHA-256 stays pinned regardless of +/// origin, so a tampered mirror still fails verification. +const _kModelUrl = String.fromEnvironment( + 'GEMMA_MODEL_URL', + defaultValue: + 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm', +); const _kModelSha256 = '181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c'; diff --git a/app/test/data/ai/model_lifecycle_test.dart b/app/test/data/ai/model_lifecycle_test.dart index 310514b..59f2e57 100644 --- a/app/test/data/ai/model_lifecycle_test.dart +++ b/app/test/data/ai/model_lifecycle_test.dart @@ -250,4 +250,80 @@ void main() { await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ'); expect(await lc.checkAvailability(), ModelAvailability.corrupt); }); + + group('#657 legacy .bin filename migration', () { + // Pre-.litertlm installs: file on disk + meta path both use the old + // `gemma4_e2b_q4.bin` name that LiteRT-LM rejects. + const legacyName = 'gemma4_e2b_q4.bin'; + const canonicalName = 'model.litertlm'; + + ModelLifecycle makeLc(String expectedSha) => ModelLifecycle( + meta: meta, + config: ModelConfig( + url: Uri.parse(url), + expectedSha256: expectedSha, + filename: canonicalName, + ), + storage: storage, + ); + + Future seedLegacy(List payload) async { + final legacyPath = '${tmp.path}/$legacyName'; + File(legacyPath).writeAsBytesSync(payload); + await meta.put(AiMetaKeys.optIn, 'true'); + await meta.put(AiMetaKeys.modelPath, legacyPath); + await meta.put( + AiMetaKeys.modelSha, sha256.convert(payload).toString()); + await meta.put(AiMetaKeys.downloadState, 'completed'); + return legacyPath; + } + + test('quickCheck renames legacy file + updates meta path (AC-2)', + () async { + final payload = utf8.encode('legacy model bytes'); + final legacyPath = await seedLegacy(payload); + final lc = makeLc(sha256.convert(payload).toString()); + + expect(await lc.quickCheck(), ModelAvailability.ready); + expect(File(legacyPath).existsSync(), isFalse); + final canonicalPath = '${tmp.path}/$canonicalName'; + expect(File(canonicalPath).existsSync(), isTrue); + expect(await meta.find(AiMetaKeys.modelPath), canonicalPath); + }); + + test('checkAvailability migrates and SHA meta stays valid (AC-2)', + () async { + final payload = utf8.encode('legacy model bytes'); + await seedLegacy(payload); + final lc = makeLc(sha256.convert(payload).toString()); + + expect(await lc.checkAvailability(), ModelAvailability.ready); + expect(File('${tmp.path}/$canonicalName').existsSync(), isTrue); + }); + + test('migration is idempotent — second check is a no-op (AC-3)', + () async { + final payload = utf8.encode('legacy model bytes'); + await seedLegacy(payload); + final lc = makeLc(sha256.convert(payload).toString()); + + expect(await lc.quickCheck(), ModelAvailability.ready); + expect(await lc.quickCheck(), ModelAvailability.ready); + expect( + await meta.find(AiMetaKeys.modelPath), + '${tmp.path}/$canonicalName', + ); + }); + + test('meta path file gone → skip migration, stays missing (AC-4)', + () async { + await meta.put(AiMetaKeys.optIn, 'true'); + await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$legacyName'); + await meta.put(AiMetaKeys.modelSha, 'irrelevant'); + + final lc = makeLc('irrelevant'); + expect(await lc.quickCheck(), ModelAvailability.missing); + expect(File('${tmp.path}/$canonicalName').existsSync(), isFalse); + }); + }); }