From 450d597cd502d0e35729b46b9f5de9045640a1a6 Mon Sep 17 00:00:00 2001 From: HedgehogsGX <203076171+HedgehogsGX@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:01:41 -0500 Subject: [PATCH] Move online ingredient analysis from OpenRouter to DeepSeek Point the analysis provider at DeepSeek's official Chat Completions endpoint and the deepseek-flash model, replacing OpenRouter and z-ai/glm-5.3-flash. The /models endpoint on the current key serves only deepseek-flash and deepseek-v4-pro, so flash is the like-for-like tier. Drop the reasoning and provider fields from the request body along with the X-OpenRouter-Title header. Both are OpenRouter-only knobs that DeepSeek silently ignores, and provider.data_collection=deny was being cited in the privacy policy as a limit on upstream retention. Continuing to send a field that does nothing while advertising it to users would have been a guarantee with nothing behind it, so the claim is removed from the UI copy and the README rather than restated. Because the upstream provider changed, bump OnlineAnalysisConsent to version 2. Every existing online consent is void and falls back to local analysis until the user re-confirms against the new data boundary. Move the Keychain slot to v3 for the same reason, so an OpenRouter key cached by an earlier build can never be replayed against DeepSeek. Narrow the transport allowlist to api.deepseek.com/chat/completions and rename OPENROUTER_API_KEY to DEEPSEEK_API_KEY across the xcconfigs, scheme and tests. The local credential still lives only in the gitignored Config/Secrets.xcconfig. Also fix Tests/LabelPipelineHarnessTests, which used XCTUnwrap where it meant XCTSkip and so failed the default suite whenever no photo was supplied. Verified with the full suite (83 tests), the live smoke test against DeepSeek, and a real Pepsi label driven end to end through OCR, the local parser and the live model. --- AddiGuard.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/AddiGuard.xcscheme | 6 +- Config/Debug.xcconfig | 2 +- Config/Release.xcconfig | 2 +- Config/Secrets.xcconfig.example | 2 +- README.md | 17 +-- Sources/Design/RecognitionModePicker.swift | 2 +- .../Features/Profile/PrivacyPolicyView.swift | 6 +- .../Services/IngredientAnalysisService.swift | 11 +- ...OpenRouterIngredientAnalysisProvider.swift | 33 +----- .../OpenRouterRecognitionProvider.swift | 21 ++-- Tests/LabelPipelineHarnessTests.swift | 104 ++++++++++++++++++ Tests/OpenRouterConfigurationTests.swift | 12 +- ...outerIngredientAnalysisProviderTests.swift | 26 ++--- Tests/OpenRouterLiveSmokeTests.swift | 12 +- project.yml | 4 +- 16 files changed, 167 insertions(+), 97 deletions(-) create mode 100644 Tests/LabelPipelineHarnessTests.swift diff --git a/AddiGuard.xcodeproj/project.pbxproj b/AddiGuard.xcodeproj/project.pbxproj index a722f3e..a08e8ff 100644 --- a/AddiGuard.xcodeproj/project.pbxproj +++ b/AddiGuard.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ D737EBE9B04451C4221E790A /* IngredientAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7D6B8338E7EF549D224BA9 /* IngredientAnalyzer.swift */; }; D7C7D95FDB65EE201699D629 /* RecognitionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C8A7B39A0B2C3A5BC721CA /* RecognitionServiceTests.swift */; }; DA65C06D64964B4B1B393D85 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 318ABEA401C73CCB7E66128A /* PrivacyInfo.xcprivacy */; }; + DB0315228A8B223A2F39BD3D /* LabelPipelineHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */; }; DC745D61DCA0DFB712627D68 /* CuratedSpriteAdditivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C63F3F6662117CFB78AA5D /* CuratedSpriteAdditivesTests.swift */; }; E1A58E33DABF1B21DFDB43D4 /* CameraScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECF8C13D900BDF9935559E00 /* CameraScanner.swift */; }; E1F1993D3731777CDECDAAD7 /* OpenRouterIngredientAnalysisProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854651E9176E019610153559 /* OpenRouterIngredientAnalysisProvider.swift */; }; @@ -108,6 +109,7 @@ 88E567CDD8409A1E03A4B3EA /* AddiGuardTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AddiGuardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8917A567CF885CE79177E324 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 892B30AF09C77CB5B823FF2B /* ReferenceSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceSourceTests.swift; sourceTree = ""; }; + 89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelPipelineHarnessTests.swift; sourceTree = ""; }; 8A7D6B8338E7EF549D224BA9 /* IngredientAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngredientAnalyzer.swift; sourceTree = ""; }; 9457EF4B050581874E15B0BD /* PrivacyPolicyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPolicyView.swift; sourceTree = ""; }; 9AAFFCBADA2244FE50C819A7 /* Additive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Additive.swift; sourceTree = ""; }; @@ -210,6 +212,7 @@ 64F83313A0AAD3270BA836AF /* IngredientAnalysisServiceTests.swift */, 6AD56770E84737E8823CB7ED /* IngredientAnalyzerTests.swift */, F007426E6A5809F4EC89D8F8 /* IngredientTextParserTests.swift */, + 89533DA93230E8F670FC3FCB /* LabelPipelineHarnessTests.swift */, DCDB426A7AE2AF1B38C2B236 /* OCRServiceTests.swift */, E5821238597D3191FE995CF8 /* OpenRouterConfigurationTests.swift */, AC6D660EE10BD2358D95538A /* OpenRouterIngredientAnalysisProviderTests.swift */, @@ -482,6 +485,7 @@ A340C30C960F64D49DA243EE /* IngredientAnalysisServiceTests.swift in Sources */, C4CA188B704BD25BFB08B8FE /* IngredientAnalyzerTests.swift in Sources */, 090D42B2635BC155111973EE /* IngredientTextParserTests.swift in Sources */, + DB0315228A8B223A2F39BD3D /* LabelPipelineHarnessTests.swift in Sources */, 867133B9FE9E7BCE4301DE77 /* OCRServiceTests.swift in Sources */, 34E34F3328FBBF6378A6000B /* OpenRouterConfigurationTests.swift in Sources */, 059363A4F1C54849C46E9259 /* OpenRouterIngredientAnalysisProviderTests.swift in Sources */, diff --git a/AddiGuard.xcodeproj/xcshareddata/xcschemes/AddiGuard.xcscheme b/AddiGuard.xcodeproj/xcshareddata/xcschemes/AddiGuard.xcscheme index bba54f6..d80f7f6 100644 --- a/AddiGuard.xcodeproj/xcshareddata/xcschemes/AddiGuard.xcscheme +++ b/AddiGuard.xcodeproj/xcshareddata/xcschemes/AddiGuard.xcscheme @@ -79,8 +79,8 @@ @@ -105,7 +105,7 @@ diff --git a/Config/Debug.xcconfig b/Config/Debug.xcconfig index 66e405d..7667684 100644 --- a/Config/Debug.xcconfig +++ b/Config/Debug.xcconfig @@ -1,2 +1,2 @@ -OPENROUTER_API_KEY = +DEEPSEEK_API_KEY = #include? "Secrets.xcconfig" diff --git a/Config/Release.xcconfig b/Config/Release.xcconfig index f1bfebf..e0439e3 100644 --- a/Config/Release.xcconfig +++ b/Config/Release.xcconfig @@ -1,2 +1,2 @@ // Production credentials must be supplied by a backend proxy, never the app bundle. -OPENROUTER_API_KEY = +DEEPSEEK_API_KEY = diff --git a/Config/Secrets.xcconfig.example b/Config/Secrets.xcconfig.example index 9e689de..1f444c5 100644 --- a/Config/Secrets.xcconfig.example +++ b/Config/Secrets.xcconfig.example @@ -1,2 +1,2 @@ // Copy this file to Secrets.xcconfig for local Debug builds only. -OPENROUTER_API_KEY = your-local-development-key +DEEPSEEK_API_KEY = your-local-development-key diff --git a/README.md b/README.md index d975e47..086e62e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # AddiGuard for iOS -AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入食品配料表,始终通过设备上的 Apple Vision 提取文字,再选择本地知识库或 OpenRouter 上的 GLM-5.3 Flash 对配料进行分析。 +AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入食品配料表,始终通过设备上的 Apple Vision 提取文字,再选择本地知识库或 DeepSeek 的 DeepSeek Flash 对配料进行分析。 ## MVP 功能 - 相机取景、拍照识别与相册导入 - 锁定屏幕小组件,可一键打开食品配料识别页 -- 两种明确可选的分析方式:本地知识库分析,以及 OpenRouter GLM-5.3 Flash 在线逐项辅助说明 +- 两种明确可选的分析方式:本地知识库分析,以及 DeepSeek Flash 在线逐项辅助说明 - 始终在设备端执行的中英文双路 OCR(Vision):原图与增强图并行识别,并使用本地添加剂名称、别名和 E/INS 编码词表辅助小字识别;图片不会上传 - 完整配料拆分:分别标出已匹配添加剂、常见普通配料、可能匹配和未匹配文字,不再静默丢弃未知项 - GB 2760-2024 附录 F(附录 A 索引)的 287 个名称组、本地中英文常用名与经校验的 E/INS 编码匹配 @@ -18,16 +18,16 @@ AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入 ## 在线 API 接入状态 -- 图片始终只在设备上由 Apple Vision 提取文字。应用先在本地拆分配料,再按所选方式分析;OpenRouter 不参与 OCR,也不会收到图片。 +- 图片始终只在设备上由 Apple Vision 提取文字。应用先在本地拆分配料,再按所选方式分析;DeepSeek 不参与 OCR,也不会收到图片。 - 本地解析器生成有界、按原顺序排列的配料数组。在线模式只发送每项的不透明序号和配料名称,不发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;若没有拆出配料,会直接停止在线请求,不会改为上传全文。 - 本地分析是默认模式。启用在线辅助分析必须基于当前数据边界重新确认,不会隐藏上传或静默切换;确认后该偏好持续到用户切回本地模式。 -- 本地 Debug 构建通过 OpenRouter Chat Completions 调用 `z-ai/glm-5.3-flash`(Z.ai GLM-5.3 Flash)。请求采用 HTTPS、Bearer 认证、低强度推理和 `data_collection=deny`;模型只为本地清单中的每一种配料生成辅助说明,不能新增、删除、改名、排序或改写本地风险结论。 -- 模型 slug 会随上游下线而失效:本项目最初使用的 `stealth/ox-alpha` 隐身测试期结束后已被 OpenRouter 移除,改名为 `z-ai/glm-5.3-flash`。此时接口返回 HTTP 404,应用按 `modelUnavailable` 提示改用本地分析,不会崩溃也不会静默上传。升级模型前可先用 `curl` 调用 `https://openrouter.ai/api/v1/models` 或直接发一次最小请求确认 slug 仍然有效。 +- 本地 Debug 构建直接调用 DeepSeek 官方 Chat Completions(`https://api.deepseek.com/chat/completions`)上的 `deepseek-flash`。请求采用 HTTPS、Bearer 认证、`temperature=0` 与 `response_format=json_object`;模型只为本地清单中的每一种配料生成辅助说明,不能新增、删除、改名、排序或改写本地风险结论。传输层仅放行 `api.deepseek.com` 与 `/chat/completions`,其他主机一律判定为 `invalidEndpoint`。 +- 模型名会随上游下线而失效:本项目先后用过 OpenRouter 上的 `stealth/ox-alpha` 与 `z-ai/glm-5.3-flash`,现已整体迁移到 DeepSeek 官方接口。模型不可用时接口返回 HTTP 404,应用按 `modelUnavailable` 提示改用本地分析,不会崩溃也不会静默上传。升级模型前先用 `curl -s https://api.deepseek.com/models -H "Authorization: Bearer $KEY"` 确认名称仍然有效;该接口当前只返回 `deepseek-flash` 与 `deepseek-v4-pro`。 - 本地凭证只放在被 Git 忽略的 `Config/Secrets.xcconfig`,不会写入源码、测试、README、Info.plist 或 App 包。`Config/Debug.xcconfig` 会可选加载它,Debug scheme 在启动时传入并保存至版本化的本机 Keychain 槽位;若启动变量存在但为空或未展开,应用会直接判定 API 未配置,不会静默回退到旧凭证。`Config/Release.xcconfig` 始终为空,Release 代码也不会读取开发凭证,因此 Archive/TestFlight/App Store 构建不会携带它。 - 新环境可复制 `Config/Secrets.xcconfig.example` 为 `Config/Secrets.xcconfig` 并填写仅用于开发、带额度限制的密钥,然后运行 `xcodegen generate` 并从 Xcode 执行一次 Run。没有本地凭证或 Keychain 记录时,在线选项显示“待配置”且不会发起网络请求。 - Debug 日志只记录凭证来源、不可逆短指纹、请求返回的 generation ID、模型、结束原因、token 数与成本,不记录 API key、Authorization、图片、完整 OCR 或配料名称。排查免费模型时应以 HTTP 200、`finish_reason=stop`、token 数和 generation 记录为准;费用为 0 本身不代表请求失败。 - 客户端在运行时使用的任何密钥仍可能从受控设备或进程中提取。正式发布应改为调用自有后端代理;若改为用户自带密钥,应提供应用内录入、Keychain 保存与删除能力。 -- `data_collection=deny` 只用于排除明确声明收集数据的上游,不构成零留存保证。OpenRouter、模型提供方 Z.ai,以及 OpenRouter 实际路由到的第三方推理服务商,仍可能依服务条款长期处理、保留并使用提示、回复及请求元数据,包括服务或模型改进许可;提供方、处理地区和政策也可能变化。发布前需重新核对服务政策、重新取得适当同意,并更新 App Store 隐私披露。 +- 迁移到 DeepSeek 后不再发送任何声称限制上游留存的参数(OpenRouter 专有的 `provider.data_collection=deny` 与 `reasoning` 已一并移除,它们在 DeepSeek 上是无效字段,继续发送等于给出一个并不存在的隐私承诺)。DeepSeek 仍可能依其服务条款长期处理、保留并使用提示、回复及请求元数据,包括服务或模型改进许可;处理地区与政策也可能变化。因上游提供方已更换,`OnlineAnalysisConsent.currentVersion` 升到 2,旧的在线同意全部作废并回落到本地分析。发布前需重新核对服务政策、重新取得适当同意,并更新 App Store 隐私披露。 ## 运行 @@ -39,12 +39,13 @@ AddiGuard(添加剂卫士)是一款原生 SwiftUI 应用:拍摄或导入 ## 测试 - 常规测试:`xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test`。全部用例都不联网。 -- 联网冒烟测试:`Tests/OpenRouterLiveSmokeTests.swift` 会用生产请求体真实调用一次 OpenRouter,用来发现模型 slug 被上游下线这类离线测试查不出的问题。默认跳过,CI 不会执行,也不会消耗额度。 +- 联网冒烟测试:`Tests/OpenRouterLiveSmokeTests.swift` 会用生产请求体真实调用一次 DeepSeek,用来发现模型被上游下线这类离线测试查不出的问题。默认跳过,CI 不会执行,也不会消耗额度。 +- 整图流水线诊断:`Tests/LabelPipelineHarnessTests.swift` 把一张真实照片依次跑过 Vision OCR、本地解析器和在线接口,并打印每一阶段的产物,用 `TEST_RUNNER_ADDIGUARD_LABEL_IMAGE` 指定图片路径。 - 该用例在模拟器进程内运行,不继承终端环境变量,因此必须使用 xcodebuild 的 `TEST_RUNNER_` 前缀(转发时会自动去掉前缀);不加前缀只会静默跳过: ``` TEST_RUNNER_ADDIGUARD_LIVE_API_TESTS=1 \ -TEST_RUNNER_OPENROUTER_API_KEY="$(awk -F= '/OPENROUTER_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \ +TEST_RUNNER_DEEPSEEK_API_KEY="$(awk -F= '/DEEPSEEK_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \ xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ -only-testing:AddiGuardTests/OpenRouterLiveSmokeTests test diff --git a/Sources/Design/RecognitionModePicker.swift b/Sources/Design/RecognitionModePicker.swift index 87b51fb..0e2e36b 100644 --- a/Sources/Design/RecognitionModePicker.swift +++ b/Sources/Design/RecognitionModePicker.swift @@ -47,7 +47,7 @@ struct AnalysisModePicker: View { } Button("取消", role: .cancel) {} } message: { - Text("图片始终由设备上的 Apple Vision 提取文字,不会上传。在线模式只把本地拆出的配料名称发送给 OpenRouter 的 \(OpenRouterConfiguration.displayName),供其逐项生成辅助说明;不会发送完整 OCR、用户画像或历史。模型提供方 Z.ai 与 OpenRouter 路由到的第三方推理服务商,可能按服务条款长期保留并使用这些文本与回复,包括服务或模型改进许可。AI 说明不会改写本地风险结论。") + Text("图片始终由设备上的 Apple Vision 提取文字,不会上传。在线模式只把本地拆出的配料名称发送给 DeepSeek 官方接口的 \(OpenRouterConfiguration.displayName),供其逐项生成辅助说明;不会发送完整 OCR、用户画像或历史。模型提供方 DeepSeek 可能按其服务条款保留并使用这些文本与回复,包括服务或模型改进许可。AI 说明不会改写本地风险结论。") } } diff --git a/Sources/Features/Profile/PrivacyPolicyView.swift b/Sources/Features/Profile/PrivacyPolicyView.swift index ef283cb..03abff1 100644 --- a/Sources/Features/Profile/PrivacyPolicyView.swift +++ b/Sources/Features/Profile/PrivacyPolicyView.swift @@ -15,7 +15,7 @@ struct PrivacyPolicyView: View { policySection( title: "如何处理", - text: "图片始终只由设备上的 Apple Vision 提取文字,不会上传。你可以选择本地分析或在线辅助分析:本地模式不发送任何内容;在线模式需要重新确认,只把本地解析器拆出的配料名称和不透明序号发送给 OpenRouter 的 \(OpenRouterConfiguration.displayName),由其逐项生成辅助说明。不会发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;如果本地没有拆出配料,应用不会改为上传完整 OCR。" + text: "图片始终只由设备上的 Apple Vision 提取文字,不会上传。你可以选择本地分析或在线辅助分析:本地模式不发送任何内容;在线模式需要重新确认,只把本地解析器拆出的配料名称和不透明序号发送给 DeepSeek 的 \(OpenRouterConfiguration.displayName),由其逐项生成辅助说明。不会发送完整 OCR、产品名、地址、电话、用户画像、扫描历史或本地知识库;如果本地没有拆出配料,应用不会改为上传完整 OCR。" ) policySection( @@ -25,7 +25,7 @@ struct PrivacyPolicyView: View { policySection( title: "在线服务说明", - text: "在线请求会设置 data_collection=deny,以排除明确声明收集数据的上游提供方,但这不等于零留存保证。OpenRouter、模型提供方 Z.ai,以及 OpenRouter 实际路由到的第三方推理服务商,仍可能依其服务条款长期处理、保留并使用配料文本、回复及请求元数据,包括服务或模型改进许可;模型页面的当前说明与未来政策也可能变化。启用前请基于这一边界重新同意,发布前也必须再次核对 OpenRouter、上游服务和 App Store 的最新隐私要求。" + text: "在线请求直接发往 DeepSeek 官方接口 api.deepseek.com,不经第三方聚合路由,也不再附带任何声称限制上游留存的参数——本应用不提供零留存保证。DeepSeek 仍可能依其服务条款长期处理、保留并使用配料文本、回复及请求元数据,包括服务或模型改进许可;其隐私政策与数据保留期限也可能变化。请求会离开中国大陆以外用户所在地区并在 DeepSeek 的服务器处理。启用前请基于这一边界重新同意,发布前也必须再次核对 DeepSeek 与 App Store 的最新隐私要求。" ) policySection( @@ -39,7 +39,7 @@ struct PrivacyPolicyView: View { ) Section { - Text("版本:2026-08-23") + Text("版本:2026-09-14") .foregroundStyle(.secondary) } footer: { Text("App Store 上架前仍需由发布方提供公开可访问的隐私政策与支持网址,并确保其内容与本说明一致。") diff --git a/Sources/Services/IngredientAnalysisService.swift b/Sources/Services/IngredientAnalysisService.swift index 867d955..11347e2 100644 --- a/Sources/Services/IngredientAnalysisService.swift +++ b/Sources/Services/IngredientAnalysisService.swift @@ -3,7 +3,8 @@ import SwiftUI enum OnlineAnalysisConsent { /// Increment whenever the provider, transmitted fields, or retention disclosure changes. - static let currentVersion = 1 + /// v2: upstream moved from OpenRouter/Z.ai to DeepSeek, so earlier consent is void. + static let currentVersion = 2 } struct IngredientAnalysisHTTPTransport: @unchecked Sendable { @@ -81,7 +82,7 @@ struct RemoteIngredientAnalyzer: @unchecked Sendable { let provider = IngredientAnalysisAPIProvider.openRouter(configuration: configuration) return RemoteIngredientAnalyzer { tokens in let request = try provider.makeRequest(tokens) - try validateOpenRouterRequest(request) + try validateAnalysisRequest(request) let data: Data let response: HTTPURLResponse @@ -218,12 +219,12 @@ extension EnvironmentValues { } } -private func validateOpenRouterRequest(_ request: URLRequest) throws { +private func validateAnalysisRequest(_ request: URLRequest) throws { guard let url = request.url, url.scheme?.lowercased() == "https", - url.host?.lowercased() == "openrouter.ai", + url.host?.lowercased() == "api.deepseek.com", url.port == nil || url.port == 443, - url.path == "/api/v1/chat/completions" + url.path == "/chat/completions" else { throw RecognitionServiceError.invalidEndpoint } diff --git a/Sources/Services/OpenRouterIngredientAnalysisProvider.swift b/Sources/Services/OpenRouterIngredientAnalysisProvider.swift index 6130423..32704f7 100644 --- a/Sources/Services/OpenRouterIngredientAnalysisProvider.swift +++ b/Sources/Services/OpenRouterIngredientAnalysisProvider.swift @@ -46,10 +46,8 @@ extension IngredientAnalysisAPIProvider { ], stream: false, temperature: 0, - reasoning: .lowExcluded, maxTokens: 4_096, - responseFormat: .jsonObject, - provider: .requiredPrivacyPreserving + responseFormat: .jsonObject ) var request = URLRequest(url: configuration.endpoint) @@ -62,10 +60,6 @@ extension IngredientAnalysisAPIProvider { "application/json", forHTTPHeaderField: "Content-Type" ) - request.setValue( - "AddiGuard", - forHTTPHeaderField: "X-OpenRouter-Title" - ) do { request.httpBody = try JSONEncoder().encode(payload) } catch { @@ -164,20 +158,16 @@ private struct OpenRouterIngredientAnalysisRequest: Encodable { let messages: [Message] let stream: Bool let temperature: Double - let reasoning: Reasoning let maxTokens: Int let responseFormat: ResponseFormat - let provider: ProviderPreferences enum CodingKeys: String, CodingKey { case model case messages case stream case temperature - case reasoning case maxTokens = "max_tokens" case responseFormat = "response_format" - case provider } struct Message: Encodable { @@ -191,27 +181,6 @@ private struct OpenRouterIngredientAnalysisRequest: Encodable { static let jsonObject = ResponseFormat(type: "json_object") } - struct Reasoning: Encodable { - let effort: String - let exclude: Bool - - static let lowExcluded = Reasoning(effort: "low", exclude: true) - } - - struct ProviderPreferences: Encodable { - let requireParameters: Bool - let dataCollection: String - - enum CodingKeys: String, CodingKey { - case requireParameters = "require_parameters" - case dataCollection = "data_collection" - } - - static let requiredPrivacyPreserving = ProviderPreferences( - requireParameters: true, - dataCollection: "deny" - ) - } } private struct IngredientAnalysisInput: Encodable { diff --git a/Sources/Services/OpenRouterRecognitionProvider.swift b/Sources/Services/OpenRouterRecognitionProvider.swift index 59c9f83..4806215 100644 --- a/Sources/Services/OpenRouterRecognitionProvider.swift +++ b/Sources/Services/OpenRouterRecognitionProvider.swift @@ -4,11 +4,11 @@ import OSLog import Security struct OpenRouterConfiguration: Equatable, Sendable { - static let defaultModel = "z-ai/glm-5.3-flash" + static let defaultModel = "deepseek-flash" /// 面向用户展示的模型名称,供界面与隐私说明统一引用。 - static let displayName = "GLM-5.3 Flash" + static let displayName = "DeepSeek Flash" static let defaultEndpoint = URL( - string: "https://openrouter.ai/api/v1/chat/completions" + string: "https://api.deepseek.com/chat/completions" )! let apiKey: String @@ -66,7 +66,7 @@ struct OpenRouterConfiguration: Equatable, Sendable { let loadStoredKey = loadStoredKey ?? OpenRouterKeychain.load let saveStoredKey = saveStoredKey ?? OpenRouterKeychain.save - if let launchKey = environment["OPENROUTER_API_KEY"] { + if let launchKey = environment["DEEPSEEK_API_KEY"] { guard let configuration = OpenRouterConfiguration( apiKey: launchKey, credentialSource: .launchEnvironment @@ -107,10 +107,11 @@ struct OpenRouterConfiguration: Equatable, Sendable { } private enum OpenRouterKeychain { - private static let service = "com.addiguard.app.openrouter" + private static let service = "com.addiguard.app.deepseek" // Version the slot so a credential silently cached by an older build can - // never be mistaken for the key supplied by the current Debug scheme. - private static let account = "local-development-api-key-v2" + // never be mistaken for the key supplied by the current Debug scheme. v3 + // also retires every OpenRouter key cached before the provider switch. + private static let account = "local-development-api-key-v3" static func load() -> String? { let query: [String: Any] = [ @@ -170,17 +171,17 @@ private enum OpenRouterKeychain { enum OpenRouterDiagnostics { private static let logger = Logger( subsystem: "com.addiguard.app", - category: "OpenRouter" + category: "DeepSeek" ) static func configurationRejected() { logger.error( - "OPENROUTER_API_KEY was present but invalid; refusing Keychain fallback" + "DEEPSEEK_API_KEY was present but invalid; refusing Keychain fallback" ) } static func configurationUnavailable() { - logger.notice("OpenRouter credential is unavailable") + logger.notice("DeepSeek credential is unavailable") } static func configurationLoaded( diff --git a/Tests/LabelPipelineHarnessTests.swift b/Tests/LabelPipelineHarnessTests.swift new file mode 100644 index 0000000..9815593 --- /dev/null +++ b/Tests/LabelPipelineHarnessTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import AddiGuard + +/// Runs one real photo through the whole pipeline: Apple Vision OCR, the local +/// ingredient parser, and — when a key is present — the live DeepSeek call. +/// +/// This is a hand-run diagnostic, not a CI test: it needs a photo on disk and +/// prints what each stage produced so a human can judge OCR quality. It is +/// skipped unless `ADDIGUARD_LABEL_IMAGE` points at an image. +/// +/// The test runs inside the simulator, which does not inherit the shell +/// environment, so variables need xcodebuild's `TEST_RUNNER_` prefix: +/// +/// ``` +/// TEST_RUNNER_ADDIGUARD_LABEL_IMAGE=/path/to/label.jpg \ +/// TEST_RUNNER_ADDIGUARD_LIVE_API_TESTS=1 \ +/// TEST_RUNNER_DEEPSEEK_API_KEY="$(awk -F= '/DEEPSEEK_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \ +/// xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard \ +/// -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ +/// -only-testing:AddiGuardTests/LabelPipelineHarnessTests test +/// ``` +final class LabelPipelineHarnessTests: XCTestCase { + func testRealLabelPhotoThroughOCRParserAndAPI() async throws { + let environment = ProcessInfo.processInfo.environment + // A hand-run diagnostic: skip, never fail, when no photo is supplied, + // so the default `xcodebuild test` run stays green. + try XCTSkipIf( + environment["ADDIGUARD_LABEL_IMAGE"] == nil, + "Set ADDIGUARD_LABEL_IMAGE to a label photo path." + ) + let path = try XCTUnwrap(environment["ADDIGUARD_LABEL_IMAGE"]) + try XCTSkipUnless( + FileManager.default.fileExists(atPath: path), + "No image at \(path)." + ) + + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + print("=== IMAGE === \(path) (\(data.count) bytes)") + + // Stage 1: Apple Vision, on device, exactly as the app runs it. + let image = try await OCRService.downsampleImage(from: data) + print("=== DOWNSAMPLED === \(Int(image.size.width))x\(Int(image.size.height))") + + let recognized = try await OCRService.recognizeText(in: image) + print("=== OCR TEXT (\(recognized.count) chars) ===") + print(recognized) + + XCTAssertFalse( + recognized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Vision returned no text." + ) + + // Stage 2: the local parser that decides what may leave the device. + let tokens = IngredientTextParser.parse(text: recognized) + print("=== PARSED TOKENS (\(tokens.count)) ===") + for token in tokens { + let matched = token.matchedAdditiveName.map { " -> \($0)" } ?? "" + print(" [\(token.kind.rawValue)] \(token.text)\(matched)") + } + + // Stage 3: the live API, only when explicitly enabled. + guard environment["ADDIGUARD_LIVE_API_TESTS"] == "1", + let apiKey = environment["DEEPSEEK_API_KEY"], + let configuration = OpenRouterConfiguration(apiKey: apiKey) + else { + print("=== API SKIPPED (set ADDIGUARD_LIVE_API_TESTS=1 and DEEPSEEK_API_KEY) ===") + return + } + + guard !tokens.isEmpty else { + print("=== API SKIPPED: parser produced no ingredients, so nothing is sent ===") + return + } + + let provider = IngredientAnalysisAPIProvider.openRouter( + configuration: configuration + ) + let request = try provider.makeRequest(tokens) + let body = String(decoding: request.httpBody ?? Data(), as: UTF8.self) + print("=== REQUEST BODY (\(body.count) chars, key not shown) ===") + print(body) + + let (responseData, response) = try await URLSession.shared.data(for: request) + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + print("=== HTTP \(httpResponse.statusCode) ===") + + XCTAssertEqual( + httpResponse.statusCode, + 200, + "Body: \(String(decoding: responseData, as: UTF8.self).prefix(500))" + ) + + let insights = try provider.decodeInsights(responseData, httpResponse, tokens) + print("=== INSIGHTS (\(insights.count)) ===") + for insight in insights { + print(" \(insight.ingredient) [\(insight.category.rawValue)/\(insight.confidence.rawValue)]") + print(" role: \(insight.commonRole)") + print(" analysis: \(insight.analysis)") + if let note = insight.reviewNote { print(" note: \(note)") } + } + + XCTAssertEqual(insights.count, tokens.count) + } +} diff --git a/Tests/OpenRouterConfigurationTests.swift b/Tests/OpenRouterConfigurationTests.swift index e2a3146..25d11ec 100644 --- a/Tests/OpenRouterConfigurationTests.swift +++ b/Tests/OpenRouterConfigurationTests.swift @@ -2,20 +2,20 @@ import XCTest @testable import AddiGuard final class OpenRouterConfigurationTests: XCTestCase { - func testConfigurationUsesOxAlphaAndRejectsMissingValues() throws { + func testConfigurationUsesDeepSeekFlashAndRejectsMissingValues() throws { let configuration = try XCTUnwrap( OpenRouterConfiguration(apiKey: "dummy-local-key") ) - XCTAssertEqual(configuration.model, "z-ai/glm-5.3-flash") + XCTAssertEqual(configuration.model, "deepseek-flash") XCTAssertEqual( configuration.endpoint.absoluteString, - "https://openrouter.ai/api/v1/chat/completions" + "https://api.deepseek.com/chat/completions" ) XCTAssertEqual(configuration.credentialSource, .direct) XCTAssertEqual(configuration.credentialFingerprint.count, 24) XCTAssertNil(OpenRouterConfiguration(apiKey: " \n")) - XCTAssertNil(OpenRouterConfiguration(apiKey: "$(OPENROUTER_API_KEY)")) + XCTAssertNil(OpenRouterConfiguration(apiKey: "$(DEEPSEEK_API_KEY)")) } func testAnalysisServiceIsConfiguredOnlyWhenCredentialExists() throws { @@ -33,7 +33,7 @@ final class OpenRouterConfigurationTests: XCTestCase { var savedKey: String? let configuration = try XCTUnwrap( OpenRouterConfiguration.localDevelopment( - environment: ["OPENROUTER_API_KEY": " launch-key\n"], + environment: ["DEEPSEEK_API_KEY": " launch-key\n"], loadStoredKey: { XCTFail("Stored key should not be read"); return nil }, saveStoredKey: { savedKey = $0; return true } ) @@ -47,7 +47,7 @@ final class OpenRouterConfigurationTests: XCTestCase { func testInvalidLaunchValueFailsClosedWithoutReadingStoredKey() { var readStoredKey = false let configuration = OpenRouterConfiguration.localDevelopment( - environment: ["OPENROUTER_API_KEY": "$(OPENROUTER_API_KEY)"], + environment: ["DEEPSEEK_API_KEY": "$(DEEPSEEK_API_KEY)"], loadStoredKey: { readStoredKey = true return "stale-key" diff --git a/Tests/OpenRouterIngredientAnalysisProviderTests.swift b/Tests/OpenRouterIngredientAnalysisProviderTests.swift index 26182ee..8d5292b 100644 --- a/Tests/OpenRouterIngredientAnalysisProviderTests.swift +++ b/Tests/OpenRouterIngredientAnalysisProviderTests.swift @@ -3,7 +3,7 @@ import XCTest final class OpenRouterIngredientAnalysisProviderTests: XCTestCase { private let endpoint = URL( - string: "https://openrouter.ai/api/v1/chat/completions" + string: "https://api.deepseek.com/chat/completions" )! func testRequestContainsOnlyOpaqueIDsAndSourceTextAsIngredientData() throws { @@ -43,33 +43,23 @@ final class OpenRouterIngredientAnalysisProviderTests: XCTestCase { "application/json" ) XCTAssertEqual(Set(object.keys), [ - "model", "messages", "stream", "temperature", "reasoning", - "max_tokens", "response_format", "provider" + "model", "messages", "stream", "temperature", + "max_tokens", "response_format" ]) - XCTAssertEqual(object["model"] as? String, "z-ai/glm-5.3-flash") + XCTAssertEqual(object["model"] as? String, "deepseek-flash") XCTAssertEqual(object["stream"] as? Bool, false) XCTAssertEqual(object["temperature"] as? Double, 0) XCTAssertEqual(object["max_tokens"] as? Int, 4_096) - let reasoning = try XCTUnwrap(object["reasoning"] as? [String: Any]) - XCTAssertEqual(reasoning["effort"] as? String, "low") - XCTAssertEqual(reasoning["exclude"] as? Bool, true) - XCTAssertEqual(Set(reasoning.keys), ["effort", "exclude"]) + XCTAssertNil(object["reasoning"]) + XCTAssertNil(object["provider"]) + XCTAssertNil(request.value(forHTTPHeaderField: "X-OpenRouter-Title")) let responseFormat = try XCTUnwrap( object["response_format"] as? [String: Any] ) XCTAssertEqual(responseFormat["type"] as? String, "json_object") - let preferences = try XCTUnwrap( - object["provider"] as? [String: Any] - ) - XCTAssertEqual(preferences["require_parameters"] as? Bool, true) - XCTAssertEqual(preferences["data_collection"] as? String, "deny") - XCTAssertEqual(Set(preferences.keys), [ - "require_parameters", "data_collection" - ]) - let messages = try XCTUnwrap(object["messages"] as? [[String: Any]]) XCTAssertEqual(messages.count, 2) XCTAssertEqual(messages.map { $0["role"] as? String }, ["system", "user"]) @@ -475,7 +465,7 @@ final class OpenRouterIngredientAnalysisProviderTests: XCTestCase { } return try JSONSerialization.data(withJSONObject: [ "id": "generation-analysis-test", - "model": "z-ai/glm-5.3-flash", + "model": "deepseek-flash", "usage": [ "prompt_tokens": 120, "completion_tokens": 80, diff --git a/Tests/OpenRouterLiveSmokeTests.swift b/Tests/OpenRouterLiveSmokeTests.swift index cd4f73c..b82a177 100644 --- a/Tests/OpenRouterLiveSmokeTests.swift +++ b/Tests/OpenRouterLiveSmokeTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import AddiGuard -/// Opt-in smoke test that calls the real OpenRouter endpoint. +/// Opt-in smoke test that calls the real DeepSeek endpoint. /// /// Every other OpenRouter test is hermetic and asserts against a fixed payload, /// so none of them notice when the configured model is withdrawn upstream. That @@ -21,7 +21,7 @@ import XCTest /// /// ``` /// TEST_RUNNER_ADDIGUARD_LIVE_API_TESTS=1 \ -/// TEST_RUNNER_OPENROUTER_API_KEY="$(awk -F= '/OPENROUTER_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \ +/// TEST_RUNNER_DEEPSEEK_API_KEY="$(awk -F= '/DEEPSEEK_API_KEY/{print $2}' Config/Secrets.xcconfig | tr -d ' ')" \ /// xcodebuild -project AddiGuard.xcodeproj -scheme AddiGuard \ /// -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ /// -only-testing:AddiGuardTests/OpenRouterLiveSmokeTests test @@ -37,12 +37,12 @@ final class OpenRouterLiveSmokeTests: XCTestCase { "Set ADDIGUARD_LIVE_API_TESTS=1 to call the live OpenRouter API." ) let apiKey = try XCTUnwrap( - environment["OPENROUTER_API_KEY"], - "Live smoke test needs OPENROUTER_API_KEY in the environment." + environment["DEEPSEEK_API_KEY"], + "Live smoke test needs DEEPSEEK_API_KEY in the environment." ) let configuration = try XCTUnwrap( OpenRouterConfiguration(apiKey: apiKey), - "OPENROUTER_API_KEY was empty or still an unexpanded placeholder." + "DEEPSEEK_API_KEY was empty or still an unexpanded placeholder." ) let provider = IngredientAnalysisAPIProvider.openRouter( configuration: configuration @@ -75,7 +75,7 @@ final class OpenRouterLiveSmokeTests: XCTestCase { httpResponse.statusCode, 200, """ - Live OpenRouter call for \(OpenRouterConfiguration.defaultModel) \ + Live DeepSeek call for \(OpenRouterConfiguration.defaultModel) \ returned \(httpResponse.statusCode). A 404 means the slug is no \ longer served and OpenRouterConfiguration.defaultModel needs \ updating. Body: \(String(decoding: data, as: UTF8.self).prefix(500)) diff --git a/project.yml b/project.yml index 6f2430e..45bc7f6 100644 --- a/project.yml +++ b/project.yml @@ -104,7 +104,7 @@ schemes: run: config: Debug environmentVariables: - OPENROUTER_API_KEY: "$(OPENROUTER_API_KEY)" + DEEPSEEK_API_KEY: "$(DEEPSEEK_API_KEY)" test: config: Debug gatherCoverageData: true @@ -113,7 +113,7 @@ schemes: profile: config: Release environmentVariables: - - variable: OPENROUTER_API_KEY + - variable: DEEPSEEK_API_KEY value: "" isEnabled: false analyze: