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: