Merge B-1: LLM provider abstraction
Phase B-1 of provider abstraction. Adds LLMProvider protocol and OpenAICompatibleLLMProvider single implementation in OpenLessPolish. DoubaoPolishClient retained, marked deprecated. 9 unit tests added. Branch: feature/llm-provider-abstraction
This commit is contained in:
commit
d916f073f8
4 files changed
+575
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)?
|
||||
|
||||
@@ -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 ?? "<binary>")")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = ["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("<raw_transcript>"))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user