From dfcdd3184bebdd2244563f2a351b38a6b0e24c6e Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 11:23:51 +0800 Subject: [PATCH] feat: introduce LLMProvider protocol + OpenAICompatibleLLMProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 polish 层引入与具体供应商解耦的抽象: - OpenLessCore: LLMProvider 协议、OpenAICompatibleConfig、LLMError - OpenLessPolish: 单一 OpenAICompatibleLLMProvider 实现,覆盖 Ark / OpenAI / DashScope / DeepSeek / Moonshot 等所有 OpenAI Chat Completions 协议端点 - DoubaoPolishClient 标记为 @available deprecated(Phase B-3 接入新 provider 后移除) - 新增 9 项 URLProtocol 桩测试,含 apiKey 不入日志的安全断言 --- Sources/OpenLessCore/LLMProvider.swift | 73 ++++ .../OpenLessPolish/DoubaoPolishClient.swift | 1 + .../OpenAICompatibleLLMProvider.swift | 149 ++++++++ .../OpenAICompatibleLLMProviderTests.swift | 352 ++++++++++++++++++ 4 files changed, 575 insertions(+) create mode 100644 Sources/OpenLessCore/LLMProvider.swift create mode 100644 Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift create mode 100644 Tests/OpenLessPolishTests/OpenAICompatibleLLMProviderTests.swift diff --git a/Sources/OpenLessCore/LLMProvider.swift b/Sources/OpenLessCore/LLMProvider.swift new file mode 100644 index 00000000..6020b7d8 --- /dev/null +++ b/Sources/OpenLessCore/LLMProvider.swift @@ -0,0 +1,73 @@ +import Foundation + +// LLMProvider 是文本润色层与具体大模型供应商之间的抽象。 +// Ark / OpenAI / 阿里云 DashScope / DeepSeek / Moonshot 等都共用 OpenAI Chat +// Completions 协议,所以我们只需要一份 Provider 实现 + 不同的 OpenAICompatibleConfig。 +// +// 该协议放在 OpenLessCore 是为了避免 Core 之外的层之间互相依赖:UI/设置层通过 Core +// 拿到 Provider 抽象,OpenLessPolish 提供具体实现。 + +public protocol LLMProvider: Sendable { + /// 当前 Provider 使用的配置(baseURL / model / apiKey 等)。 + var config: OpenAICompatibleConfig { get } + + /// 把口语原始转写整理成最终输出文本。 + /// - Parameters: + /// - rawText: ASR 给出的原始文本,可能包含口癖、乱断句。 + /// - mode: 润色模式(原文 / 轻度 / 结构化 / 正式)。 + /// - hotwords: 用户词典里的热词,仅在非空时附加到 system prompt。 + /// - Returns: 经过模型整理后的纯文本,调用方仍需自己按业务做后处理(比如去前缀套话)。 + func polish(rawText: String, mode: PolishMode, hotwords: [String]) async throws -> String +} + +/// OpenAI Chat Completions 协议下的通用 Provider 配置。 +/// +/// 同一份结构能描述 Ark / OpenAI / DashScope / DeepSeek / Moonshot 等所有 OpenAI 兼容端点。 +/// `providerId` 是一个稳定字符串,用于在持久化里区分不同 provider;`displayName` 给 UI 用。 +public struct OpenAICompatibleConfig: Codable, Sendable, Hashable { + public let providerId: String + public let displayName: String + public let baseURL: URL + public let apiKey: String + public let model: String + public let extraHeaders: [String: String] + public let temperature: Double + public let requestTimeout: TimeInterval + + public init( + providerId: String, + displayName: String, + baseURL: URL, + apiKey: String, + model: String, + extraHeaders: [String: String] = [:], + temperature: Double = 0.3, + requestTimeout: TimeInterval = 20 + ) { + self.providerId = providerId + self.displayName = displayName + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.extraHeaders = extraHeaders + self.temperature = temperature + self.requestTimeout = requestTimeout + } +} + +/// LLMProvider 抛出的错误。 +/// +/// 这里没有把 URLError 直接暴露出去:URLError 不是 Sendable-friendly 的稳定值类型, +/// 而且我们要求 LLMError 是 Equatable(方便测试断言)。所以网络错误统一用 NSError 包装。 +public enum LLMError: Error, Sendable, Equatable { + /// 缺少调用所需的凭证(apiKey 为空等)。 + case missingCredentials + /// HTTP 状态码非 2xx。`body` 是响应体的前若干字符(不是完整 body,避免日志过大)。 + case invalidResponse(statusCode: Int, body: String?) + /// 解析响应 JSON 失败,附带定位信息。 + case parseError(String) + /// 请求超时(URLError.timedOut 等)。 + case timeout + /// 其他网络错误,原始错误用 NSError 携带 domain/code/userInfo,保持 Equatable。 + case network(NSError) +} diff --git a/Sources/OpenLessPolish/DoubaoPolishClient.swift b/Sources/OpenLessPolish/DoubaoPolishClient.swift index 341b7f5a..c638b35c 100644 --- a/Sources/OpenLessPolish/DoubaoPolishClient.swift +++ b/Sources/OpenLessPolish/DoubaoPolishClient.swift @@ -8,6 +8,7 @@ public enum PolishError: Error, Sendable { case modelReturnedEmpty } +@available(*, deprecated, message: "Use OpenAICompatibleLLMProvider with Ark config; this class will be removed after the provider migration ships.") public final class DoubaoPolishClient: @unchecked Sendable { private let credentials: ArkCredentials private let logger: (@Sendable (String) -> Void)? diff --git a/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift new file mode 100644 index 00000000..0c690f90 --- /dev/null +++ b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift @@ -0,0 +1,149 @@ +import Foundation +import OpenLessCore + +/// 通用的 OpenAI Chat Completions 协议 Provider 实现。 +/// +/// 适用所有遵循 OpenAI 协议的供应商:Volcengine Ark、OpenAI、阿里云 DashScope、 +/// DeepSeek、Moonshot 等。差异完全由 `OpenAICompatibleConfig` 提供(baseURL / model / +/// apiKey / extraHeaders 等),不再为每个供应商维护一份 client。 +/// +/// 关键约束: +/// - 永远不会在日志里打印 `apiKey`(仅打印 URL / 状态码 / body 前 200 字符)。 +/// - 对外抛出的错误统一为 `LLMError`,便于上层做无声降级(粘贴原文)。 +/// - `URLSession` 可注入,方便测试用 URLProtocol 桩。 +public final class OpenAICompatibleLLMProvider: LLMProvider, @unchecked Sendable { + public let config: OpenAICompatibleConfig + private let session: URLSession + private let logger: (@Sendable (String) -> Void)? + + public init( + config: OpenAICompatibleConfig, + session: URLSession = .shared, + logger: (@Sendable (String) -> Void)? = nil + ) { + self.config = config + self.session = session + self.logger = logger + } + + public func polish( + rawText: String, + mode: PolishMode, + hotwords: [String] + ) async throws -> String { + guard !config.apiKey.isEmpty else { + throw LLMError.missingCredentials + } + + let url = Self.chatCompletionsURL(from: config.baseURL) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = config.requestTimeout + + // 标准 header 先写:apiKey 永远走 Bearer,不走查询参数也不写进 body。 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + + // extraHeaders 在最后写入,允许用户在确实需要时覆盖默认 header + // (比如某些云服务要求自定义 Content-Type 或加上 X-Region)。 + for (key, value) in config.extraHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + let systemPrompt = Self.composeSystemPrompt(for: mode, hotwords: hotwords) + let userPrompt = PolishPrompts.userPrompt(for: rawText) + + // 不引入 thinking / reasoning 等供应商私有字段——这些应由 extraHeaders 或 + // 未来的扩展点处理。OpenAI 协议本身只保证以下字段被普遍兼容。 + let body: [String: Any] = [ + "model": config.model, + "stream": false, + "temperature": config.temperature, + "messages": [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": userPrompt], + ], + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + // 日志只打印 URL,不打印 body(body 里没有 apiKey,但仍可能含用户原话, + // 隐私上能少打就少打);apiKey 只在 Authorization header 里,不会被打印。 + logger?("[llm] POST \(url.absoluteString) provider=\(config.providerId) model=\(config.model)") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let urlError as URLError { + if urlError.code == .timedOut { + throw LLMError.timeout + } + throw LLMError.network(urlError as NSError) + } catch { + throw LLMError.network(error as NSError) + } + + guard let http = response as? HTTPURLResponse else { + throw LLMError.parseError("response is not HTTPURLResponse") + } + + let bodyPreview = String(data: data, encoding: .utf8).map { String($0.prefix(Self.bodyPreviewLimit)) } + logger?("[llm] HTTP \(http.statusCode) body=\(bodyPreview ?? "")") + + guard (200..<300).contains(http.statusCode) else { + throw LLMError.invalidResponse(statusCode: http.statusCode, body: bodyPreview) + } + + return try Self.extractAssistantContent(from: data) + } + + // MARK: - Helpers + + private static let bodyPreviewLimit = 200 + + /// system prompt = mode 对应的基础规则 + (可选)hotwords 块。 + /// hotwords 为空时绝对不附加任何"词典"标题,避免给模型暗示。 + static func composeSystemPrompt(for mode: PolishMode, hotwords: [String]) -> String { + let base = PolishPrompts.systemPrompt(for: mode) + let cleaned = hotwords + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !cleaned.isEmpty else { return base } + + let list = cleaned.map { "- \($0)" }.joined(separator: "\n") + return base + "\n\n热词(用户提供的正确写法,仅当原始转写明显是其误识别时才纠正,不做机械替换):\n\(list)" + } + + /// 既支持完整 URL(已带 /chat/completions),也支持 baseURL(自动追加)。 + static func chatCompletionsURL(from baseURL: URL) -> URL { + let s = baseURL.absoluteString + if s.hasSuffix("/chat/completions") { + return baseURL + } + let trimmed = s.hasSuffix("/") ? String(s.dropLast()) : s + return URL(string: trimmed + "/chat/completions") ?? baseURL + } + + static func extractAssistantContent(from data: Data) throws -> String { + let json: Any + do { + json = try JSONSerialization.jsonObject(with: data) + } catch { + throw LLMError.parseError("not valid JSON: \(error.localizedDescription)") + } + + guard let dict = json as? [String: Any] else { + throw LLMError.parseError("response is not a JSON object") + } + guard let choices = dict["choices"] as? [[String: Any]], let first = choices.first else { + throw LLMError.parseError("missing choices array") + } + guard let message = first["message"] as? [String: Any] else { + throw LLMError.parseError("missing message in first choice") + } + guard let content = message["content"] as? String else { + throw LLMError.parseError("message.content is not a string") + } + return content + } +} diff --git a/Tests/OpenLessPolishTests/OpenAICompatibleLLMProviderTests.swift b/Tests/OpenLessPolishTests/OpenAICompatibleLLMProviderTests.swift new file mode 100644 index 00000000..c59b188d --- /dev/null +++ b/Tests/OpenLessPolishTests/OpenAICompatibleLLMProviderTests.swift @@ -0,0 +1,352 @@ +import XCTest +import OpenLessCore +@testable import OpenLessPolish + +/// OpenAICompatibleLLMProvider 的单元测试。 +/// +/// 用一个自定义的 URLProtocol 子类拦截 URLSession 的请求,把它们转给一个 +/// `RequestHandler` 闭包,由各个测试用例自己决定返回什么 HTTP 响应。 +/// 这样不需要真实网络,也不需要 mock framework。 +final class OpenAICompatibleLLMProviderTests: XCTestCase { + + // MARK: - 测试夹具 + + /// 拦截 URLSession 流量的协议;所有 URLSessionConfiguration.ephemeral 走它。 + /// 注意:URLProtocol 子类必须是 NSObject 子类,且静态状态访问按 XCTest 串行假设处理。 + final class MockURLProtocol: URLProtocol { + // 静态闭包:每个测试自己塞处理函数。XCTest 串行跑用例,单一句柄足够。 + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var capturedRequests: [URLRequest] = [] + static var capturedBodies: [Data] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + // URLProtocol 不会自动把 httpBodyStream 露给我们,需要手动读。 + // URLSession 在用 .data(for:) 时会把 body 转成 stream,所以这里两条都试。 + var captured = request + if let stream = request.httpBodyStream { + captured.httpBody = Self.readAll(stream: stream) + } + Self.capturedRequests.append(captured) + if let body = captured.httpBody { + Self.capturedBodies.append(body) + } + + guard let handler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(captured) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func readAll(stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } + + static func reset() { + requestHandler = nil + capturedRequests = [] + capturedBodies = [] + } + } + + private func makeSession() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: cfg) + } + + private func makeConfig( + apiKey: String = "SECRET_TEST_KEY_12345", + model: String = "test-model", + baseURL: URL = URL(string: "https://api.example.com/v1")!, + extraHeaders: [String: String] = [:], + temperature: Double = 0.3 + ) -> OpenAICompatibleConfig { + OpenAICompatibleConfig( + providerId: "test-provider", + displayName: "Test Provider", + baseURL: baseURL, + apiKey: apiKey, + model: model, + extraHeaders: extraHeaders, + temperature: temperature + ) + } + + /// 标准成功响应(content = polished)。 + private func okResponse(content: String, url: URL) -> (HTTPURLResponse, Data) { + let payload: [String: Any] = [ + "id": "chatcmpl-1", + "object": "chat.completion", + "choices": [ + ["index": 0, "message": ["role": "assistant", "content": content], "finish_reason": "stop"] + ], + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return (response, data) + } + + override func setUp() { + super.setUp() + MockURLProtocol.reset() + } + + override func tearDown() { + MockURLProtocol.reset() + super.tearDown() + } + + // MARK: - 用例 + + func test_polish_sends_correct_request_body() async throws { + let config = makeConfig(model: "claude-test", temperature: 0.42) + let provider = OpenAICompatibleLLMProvider(config: config, session: makeSession()) + + MockURLProtocol.requestHandler = { req in + self.okResponse(content: "polished", url: req.url!) + } + + _ = try await provider.polish(rawText: "嗯就是那个 hello", mode: .light, hotwords: []) + + XCTAssertEqual(MockURLProtocol.capturedRequests.count, 1) + let body = try XCTUnwrap(MockURLProtocol.capturedBodies.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + + XCTAssertEqual(json["model"] as? String, "claude-test") + XCTAssertEqual(json["temperature"] as? Double, 0.42) + XCTAssertEqual(json["stream"] as? Bool, false) + + let messages = try XCTUnwrap(json["messages"] as? [[String: String]]) + XCTAssertEqual(messages.count, 2) + XCTAssertEqual(messages[0]["role"], "system") + XCTAssertEqual(messages[1]["role"], "user") + + // 不应出现非 OpenAI 协议的私有字段。 + let knownKeys: Set = ["model", "temperature", "stream", "messages"] + let actualKeys = Set(json.keys) + XCTAssertEqual(actualKeys, knownKeys, "请求 body 出现了未预期字段:\(actualKeys.subtracting(knownKeys))") + } + + func test_polish_sends_bearer_auth_header() async throws { + let secret = "SECRET_TEST_KEY_12345" + let config = makeConfig(apiKey: secret) + let provider = OpenAICompatibleLLMProvider(config: config, session: makeSession()) + + MockURLProtocol.requestHandler = { req in + self.okResponse(content: "polished", url: req.url!) + } + + _ = try await provider.polish(rawText: "hi", mode: .raw, hotwords: []) + + let req = try XCTUnwrap(MockURLProtocol.capturedRequests.first) + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(secret)") + + // apiKey 不能出现在 URL 上。 + let urlString = req.url?.absoluteString ?? "" + XCTAssertFalse(urlString.contains(secret), "apiKey 不能出现在 URL 中") + + // apiKey 不能出现在 body 里。 + let body = try XCTUnwrap(MockURLProtocol.capturedBodies.first) + let bodyString = String(data: body, encoding: .utf8) ?? "" + XCTAssertFalse(bodyString.contains(secret), "apiKey 不能出现在 body 中") + } + + func test_polish_each_mode_uses_correct_prompts() async throws { + for mode in PolishMode.allCases { + MockURLProtocol.reset() + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + + MockURLProtocol.requestHandler = { req in + self.okResponse(content: "p", url: req.url!) + } + + _ = try await provider.polish(rawText: "测试原文 ABC", mode: mode, hotwords: []) + + let body = try XCTUnwrap(MockURLProtocol.capturedBodies.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let messages = try XCTUnwrap(json["messages"] as? [[String: String]]) + + // System prompt 应等同于(或包含)PolishPrompts 给出的当前 mode 内容。 + let expectedSystem = PolishPrompts.systemPrompt(for: mode) + let actualSystem = messages[0]["content"] ?? "" + XCTAssertTrue( + actualSystem.contains(expectedSystem), + "mode=\(mode) 的 system prompt 应包含 PolishPrompts.systemPrompt(for:) 的输出" + ) + + // User prompt 应包含原始转写文本和 PolishPrompts.userPrompt 的标记。 + let actualUser = messages[1]["content"] ?? "" + XCTAssertTrue(actualUser.contains("测试原文 ABC")) + XCTAssertTrue(actualUser.contains("")) + } + } + + func test_polish_returns_assistant_message() async throws { + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + + MockURLProtocol.requestHandler = { req in + self.okResponse(content: "整理后的最终输出。", url: req.url!) + } + + let result = try await provider.polish(rawText: "原始", mode: .light, hotwords: []) + XCTAssertEqual(result, "整理后的最终输出。") + } + + func test_polish_throws_on_4xx() async throws { + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + + MockURLProtocol.requestHandler = { req in + let body = "{\"error\":\"unauthorized\"}".data(using: .utf8)! + let resp = HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: "HTTP/1.1", headerFields: nil)! + return (resp, body) + } + + do { + _ = try await provider.polish(rawText: "x", mode: .raw, hotwords: []) + XCTFail("期望抛 LLMError.invalidResponse(401)") + } catch let error as LLMError { + guard case .invalidResponse(let code, _) = error else { + XCTFail("错误类型不对:\(error)"); return + } + XCTAssertEqual(code, 401) + } + } + + func test_polish_throws_on_5xx() async throws { + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + + MockURLProtocol.requestHandler = { req in + let resp = HTTPURLResponse(url: req.url!, statusCode: 503, httpVersion: "HTTP/1.1", headerFields: nil)! + return (resp, Data("oops".utf8)) + } + + do { + _ = try await provider.polish(rawText: "x", mode: .raw, hotwords: []) + XCTFail("期望抛 LLMError.invalidResponse(503)") + } catch let error as LLMError { + guard case .invalidResponse(let code, _) = error else { + XCTFail("错误类型不对:\(error)"); return + } + XCTAssertEqual(code, 503) + } + } + + func test_polish_throws_on_malformed_json() async throws { + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + + MockURLProtocol.requestHandler = { req in + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return (resp, Data("not json {[".utf8)) + } + + do { + _ = try await provider.polish(rawText: "x", mode: .raw, hotwords: []) + XCTFail("期望抛 LLMError.parseError") + } catch let error as LLMError { + guard case .parseError = error else { + XCTFail("错误类型不对:\(error)"); return + } + } + } + + func test_polish_appends_hotwords_only_when_present() async throws { + // ---- 空数组:system prompt 不应包含"热词"段 ---- + do { + MockURLProtocol.reset() + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + MockURLProtocol.requestHandler = { req in self.okResponse(content: "p", url: req.url!) } + _ = try await provider.polish(rawText: "x", mode: .light, hotwords: []) + + let body = try XCTUnwrap(MockURLProtocol.capturedBodies.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let messages = try XCTUnwrap(json["messages"] as? [[String: String]]) + let system = messages[0]["content"] ?? "" + XCTAssertFalse(system.contains("热词"), "无热词时 system 不应出现热词块") + } + + // ---- 有热词:应出现热词块和具体词 ---- + do { + MockURLProtocol.reset() + let provider = OpenAICompatibleLLMProvider(config: makeConfig(), session: makeSession()) + MockURLProtocol.requestHandler = { req in self.okResponse(content: "p", url: req.url!) } + _ = try await provider.polish(rawText: "x", mode: .light, hotwords: ["Claude", "OpenLess"]) + + let body = try XCTUnwrap(MockURLProtocol.capturedBodies.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let messages = try XCTUnwrap(json["messages"] as? [[String: String]]) + let system = messages[0]["content"] ?? "" + XCTAssertTrue(system.contains("热词")) + XCTAssertTrue(system.contains("Claude")) + XCTAssertTrue(system.contains("OpenLess")) + } + } + + func test_apikey_never_appears_in_logs() async throws { + let secret = "SECRET_TEST_KEY_12345" + + // 用一个 actor-safe 容器收集 logger 输出,避免 Swift 6 严格并发下闭包捕获可变变量。 + let collector = LogCollector() + let logger: @Sendable (String) -> Void = { line in collector.append(line) } + + let provider = OpenAICompatibleLLMProvider( + config: makeConfig(apiKey: secret), + session: makeSession(), + logger: logger + ) + + MockURLProtocol.requestHandler = { req in + // 故意把 secret 字符串塞回 body —— 真实场景里供应商不会这么干, + // 但这能保护我们:哪怕将来不小心把 body 整体 echo 进日志,测试也能抓住。 + // 用 200 字符以内的占位,确保不会触发 body 截断让 secret 偶然落在边界外。 + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + return (resp, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}}]}".data(using: .utf8)!) + } + + _ = try await provider.polish(rawText: "原话", mode: .light, hotwords: []) + + let combined = collector.allLines().joined(separator: "\n") + XCTAssertFalse(combined.isEmpty, "至少应有一条日志被记录") + XCTAssertFalse(combined.contains(secret), "apiKey 字符串不能出现在任何日志输出中:\n\(combined)") + } +} + +// MARK: - 工具 + +/// 可在 @Sendable 闭包中写入的日志容器。用 NSLock 保证并发安全。 +final class LogCollector: @unchecked Sendable { + private let lock = NSLock() + private var lines: [String] = [] + + func append(_ line: String) { + lock.lock(); defer { lock.unlock() } + lines.append(line) + } + + func allLines() -> [String] { + lock.lock(); defer { lock.unlock() } + return lines + } +}