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:
baiqing committed 2026-04-29 11:33:05 +08:00
commit d916f073f8
4 files changed
+575

No files matched your search

+73
View File
@@ -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
}
}