feat(persistence): generalize providers.llm + multi-provider vault API

将 v1 schema 中 `providers.llm` 从 Ark 专用结构推广为通用
`CredentialsProviderLLMEntry`(containing displayName / temperature / extraHeaders),
为 OpenAI / DeepSeek / DashScope / Moonshot 等多 LLM provider 共用一套字段。

旧 `CredentialsProviderLLMArk` 保留为 typealias,已迁移过的 v1 文件无需再次迁移即可继续解码。

新增公开 API:
- `CredentialsVault.activeLLMProviderId` getter/setter
- `CredentialsVault.configuredLLMProviderIds`
- `CredentialsVault.llmProviderConfig(for:)`
- `CredentialsVault.setLLMProviderConfig(_:)`
- `CredentialsVault.removeLLMProvider(_:)`,禁删 active provider

`OpenLessCore` 新增 `LLMProviderRegistry`,集中维护 5 家 OpenAI 兼容供应商
预设(baseURL / model / 帮助文案 / docs URL),UI 与 vault 兜底逻辑共用。

`PolishOutputCleaner` 从 DoubaoPolishClient 内部提升为独立 public 类型,
让 `OpenAICompatibleLLMProvider` 输出统一过 boilerplate / markdown 栅栏清理。

DoubaoPolishClient.swift 已无调用方,整个文件删除。

Tests:
- LLMProviderRegistryTests:5 家预设健康检查 + 唯一性 / URL 合法性
- CredentialsVaultMultiProviderTests:active 默认 / 增删 / 往返 /
  老 `ark.*` 账号 key 路由到 active provider
This commit is contained in:
baiqing committed 2026-04-29 11:54:11 +08:00
1 parent b48d60f74c
commit d8fb309feb
9 files changed
+712 -165

No files matched your search

@@ -0,0 +1,94 @@
import Foundation
/// LLM provider 预设注册表。
///
/// 集中维护"用户能开箱即用的供应商列表"——豆包(Ark)、OpenAI、阿里通义 DashScope、
/// DeepSeek、Moonshot——以及 baseURL / model / 帮助文案。UI 层用它生成添加 provider
/// 的下拉菜单与帮助提示,运行期则用它兜底未填写的 baseURL / displayName。
///
/// `customProviderId` 是占位常量,不是真正的"id":用户在添加自定义 provider 时由 UI 收集
/// 一个 slug 写入 schema;这里仅用来在添加 sheet 中区分"预设 vs 自定义"。
public enum LLMProviderRegistry {
/// 单条预设记录。
public struct Preset: Sendable, Hashable {
public let providerId: String
public let displayName: String
public let defaultBaseURL: URL
/// 预设的默认 model。Ark 故意留空——endpoint id 必须由用户在控制台拿到再填。
public let defaultModel: String
/// "怎么拿到 API Key" 的提示文案,UI 在帮助 disclosure 里展示。
public let helpText: String
/// 供应商 API Key 页面(可选)。UI 给一个"打开"按钮。
public let docsURL: URL?
public init(
providerId: String,
displayName: String,
defaultBaseURL: URL,
defaultModel: String,
helpText: String,
docsURL: URL?
) {
self.providerId = providerId
self.displayName = displayName
self.defaultBaseURL = defaultBaseURL
self.defaultModel = defaultModel
self.helpText = helpText
self.docsURL = docsURL
}
}
/// 5 家 OpenAI 兼容供应商的开箱即用预设。
/// 顺序即为 UI 添加菜单里的展示顺序——第一个是 Ark(老用户迁移过来的默认)。
public static let presets: [Preset] = [
Preset(
providerId: "ark",
displayName: "豆包 (Ark)",
defaultBaseURL: URL(string: "https://ark.cn-beijing.volces.com/api/v3")!,
defaultModel: "",
helpText: "在火山引擎方舟控制台创建 endpoint 并把 endpoint id 填到 Model;API Key 在「我的 API Key」页生成。",
docsURL: URL(string: "https://www.volcengine.com/docs/82379/1099475")
),
Preset(
providerId: "openai",
displayName: "OpenAI",
defaultBaseURL: URL(string: "https://api.openai.com/v1")!,
defaultModel: "gpt-4o-mini",
helpText: "platform.openai.com 创建 API key;推荐 gpt-4o-mini(性价比)或 gpt-4o(高质量)。需国外网络。",
docsURL: URL(string: "https://platform.openai.com/api-keys")
),
Preset(
providerId: "aliyun-dashscope",
displayName: "阿里通义 (DashScope)",
defaultBaseURL: URL(string: "https://dashscope.aliyuncs.com/compatible-mode/v1")!,
defaultModel: "qwen-turbo",
helpText: "百炼控制台「API-KEY 管理」生成 sk-...;推荐 qwen-turbo(快)或 qwen-plus(强)。",
docsURL: URL(string: "https://help.aliyun.com/zh/model-studio/get-api-key")
),
Preset(
providerId: "deepseek",
displayName: "DeepSeek",
defaultBaseURL: URL(string: "https://api.deepseek.com/v1")!,
defaultModel: "deepseek-chat",
helpText: "platform.deepseek.com 创建 API key;推荐 deepseek-chat。",
docsURL: URL(string: "https://platform.deepseek.com/api_keys")
),
Preset(
providerId: "moonshot",
displayName: "Moonshot 月之暗面",
defaultBaseURL: URL(string: "https://api.moonshot.cn/v1")!,
defaultModel: "moonshot-v1-8k",
helpText: "platform.moonshot.cn 创建 API key;推荐 moonshot-v1-8k(短)或 moonshot-v1-32k(长)。",
docsURL: URL(string: "https://platform.moonshot.cn/console/api-keys")
),
]
/// 自定义 provider 的占位 id(不是真实 providerId,仅用于 UI 添加菜单)。
public static let customProviderId = "custom"
public static let customDisplayName = "自定义 OpenAI 兼容"
/// 根据 providerId 查预设;未知 id 返回 nil(自定义 provider 通常落到这里)。
public static func preset(for providerId: String) -> Preset? {
presets.first { $0.providerId == providerId }
}
}
@@ -18,4 +18,8 @@ public enum CredentialsError: Error, Sendable, Equatable {
/// 写入 v1 文件失败。携带目标 URL 和失败描述。
case writeFailed(URL, String)
/// 试图删除当前 active 的 LLM provider。携带 providerId。
/// 调用方必须先把 active 切到别的 provider 再调 `removeLLMProvider`。
case cannotRemoveActiveProvider(String)
}
@@ -34,12 +34,13 @@ public struct CredentialsSchemaV1: Codable, Sendable, Equatable {
public struct CredentialsProviders: Codable, Sendable, Equatable {
/// ASR 类别:providerId → provider 配置。M1 仅有 `volcengine`。
public var asr: [String: CredentialsProviderASRVolcengine]
/// LLM 类别:providerId → provider 配置。M1 仅有 `ark`。
public var llm: [String: CredentialsProviderLLMArk]
/// LLM 类别:providerId → provider 配置。
/// 任意 OpenAI 兼容供应商(ark / openai / aliyun-dashscope / deepseek / moonshot / 自定义)共用一份 entry。
public var llm: [String: CredentialsProviderLLMEntry]
public init(
asr: [String: CredentialsProviderASRVolcengine] = [:],
llm: [String: CredentialsProviderLLMArk] = [:]
llm: [String: CredentialsProviderLLMEntry] = [:]
) {
self.asr = asr
self.llm = llm
@@ -84,44 +85,80 @@ public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable {
}
}
/// Ark (Doubao) chat-completions 凭据。
/// 通用 OpenAI 兼容 LLM provider 凭据条目。
///
/// 自定义编码:nil 字段直接省略。
public struct CredentialsProviderLLMArk: Codable, Sendable, Equatable {
/// 同一份结构覆盖 Ark / OpenAI / DashScope / DeepSeek / Moonshot 以及用户自定义条目;
/// 需要呈现给 UI 的"友好名"放在 `displayName`(自定义时由用户指定,预设条目可缺省让 UI 兜底)。
///
/// 自定义编码:nil 字段直接省略;空 dict 的 `extraHeaders` 同样省略,保持 JSON 干净。
public struct CredentialsProviderLLMEntry: Codable, Sendable, Equatable, Hashable {
/// 用户给该条目设置的展示名(自定义条目必填;预设条目允许 nil 由 UI 用 registry 兜底)。
public var displayName: String?
public var apiKey: String?
/// 字符串而不是 URL:编辑过程中用户输入可能是非法 URL,schema 不在这里早 fail。
public var baseURL: String?
public var model: String?
/// 该 provider 的 temperature 覆盖;为空时使用 `OpenAICompatibleConfig` 的默认值。
public var temperature: Double?
public var extraHeaders: [String: String]?
public init(apiKey: String? = nil, baseURL: String? = nil, model: String? = nil) {
public init(
displayName: String? = nil,
apiKey: String? = nil,
baseURL: String? = nil,
model: String? = nil,
temperature: Double? = nil,
extraHeaders: [String: String]? = nil
) {
self.displayName = displayName
self.apiKey = apiKey
self.baseURL = baseURL
self.model = model
self.temperature = temperature
self.extraHeaders = extraHeaders
}
/// 是否所有字段都是空 / nil。
public var isAllEmpty: Bool {
isNilOrEmpty(apiKey) && isNilOrEmpty(baseURL) && isNilOrEmpty(model)
isNilOrEmpty(displayName)
&& isNilOrEmpty(apiKey)
&& isNilOrEmpty(baseURL)
&& isNilOrEmpty(model)
&& temperature == nil
&& (extraHeaders?.isEmpty ?? true)
}
private enum CodingKeys: String, CodingKey {
case apiKey, baseURL, model
case displayName, apiKey, baseURL, model, temperature, extraHeaders
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(displayName, forKey: .displayName)
try c.encodeIfPresent(apiKey, forKey: .apiKey)
try c.encodeIfPresent(baseURL, forKey: .baseURL)
try c.encodeIfPresent(model, forKey: .model)
try c.encodeIfPresent(temperature, forKey: .temperature)
if let headers = extraHeaders, !headers.isEmpty {
try c.encode(headers, forKey: .extraHeaders)
}
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey)
baseURL = try c.decodeIfPresent(String.self, forKey: .baseURL)
model = try c.decodeIfPresent(String.self, forKey: .model)
temperature = try c.decodeIfPresent(Double.self, forKey: .temperature)
extraHeaders = try c.decodeIfPresent([String: String].self, forKey: .extraHeaders)
}
}
/// 兼容别名:B-2 时期叫 `CredentialsProviderLLMArk`。
/// 通用化后所有 LLM provider 共用同一类型。保留 typealias 让外部代码继续编译。
public typealias CredentialsProviderLLMArk = CredentialsProviderLLMEntry
// MARK: - Active selection
/// 当前激活的 provider。M1 默认 `volcengine` / `ark`。
@@ -1,4 +1,5 @@
import Foundation
import OpenLessCore
/// 开发期凭据存储:JSON 文件,路径 `~/.openless/credentials.json`,权限 0600。
///
@@ -81,19 +82,20 @@ public final class CredentialsVault: @unchecked Sendable {
}
/// 一次性把所有账号读出来;调用方在内存里持有,避免每次会话都打 IO。
/// snapshot 跟随当前 active provider;切换 active LLM 后再次调用会反映新的 provider 字段。
public func snapshot() -> CredentialsSnapshot {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
let volc = schema.providers.asr[defaultActiveASRProviderId]
let ark = schema.providers.llm[defaultActiveLLMProviderId]
let volc = schema.providers.asr[schema.active.asr]
let llm = schema.providers.llm[schema.active.llm]
return CredentialsSnapshot(
volcengineAppKey: volc?.appKey,
volcengineAccessKey: volc?.accessKey,
volcengineResourceId: volc?.resourceId,
arkApiKey: ark?.apiKey,
arkModelId: ark?.model,
arkEndpoint: ark?.baseURL
arkApiKey: llm?.apiKey,
arkModelId: llm?.model,
arkEndpoint: llm?.baseURL
)
}
@@ -261,19 +263,24 @@ public final class CredentialsVault: @unchecked Sendable {
// MARK: - 账号 key → v1 字段路由
private func readAccountLocked(_ account: String) -> String? {
// ASR 路径锁定到 active ASR provider;LLM 路径锁定到 active LLM provider。
// 一旦用户在 LLM 设置页切换 active(比如从 ark 切到 deepseek),老 UI 通过
// `ark.*` 账号 key 看到的就是 deepseek 的字段——这是预期行为。
let activeASR = schema.active.asr
let activeLLM = schema.active.llm
switch account {
case CredentialAccount.volcengineAppKey:
return schema.providers.asr[defaultActiveASRProviderId]?.appKey
return schema.providers.asr[activeASR]?.appKey
case CredentialAccount.volcengineAccessKey:
return schema.providers.asr[defaultActiveASRProviderId]?.accessKey
return schema.providers.asr[activeASR]?.accessKey
case CredentialAccount.volcengineResourceId:
return schema.providers.asr[defaultActiveASRProviderId]?.resourceId
return schema.providers.asr[activeASR]?.resourceId
case CredentialAccount.arkApiKey:
return schema.providers.llm[defaultActiveLLMProviderId]?.apiKey
return schema.providers.llm[activeLLM]?.apiKey
case CredentialAccount.arkModelId:
return schema.providers.llm[defaultActiveLLMProviderId]?.model
return schema.providers.llm[activeLLM]?.model
case CredentialAccount.arkEndpoint:
return schema.providers.llm[defaultActiveLLMProviderId]?.baseURL
return schema.providers.llm[activeLLM]?.baseURL
default:
return nil
}
@@ -319,26 +326,179 @@ public final class CredentialsVault: @unchecked Sendable {
}
private func mutateVolc(_ apply: (inout CredentialsProviderASRVolcengine) -> Void) {
var existing = schema.providers.asr[defaultActiveASRProviderId] ?? CredentialsProviderASRVolcengine()
// 老路径:`volcengine.*` 账号 key 始终路由到 active ASR provider。
// M1 active.asr 永远是 "volcengine",未来加供应商时这里也无需再改。
let key = schema.active.asr
var existing = schema.providers.asr[key] ?? CredentialsProviderASRVolcengine()
apply(&existing)
if existing.isAllEmpty {
schema.providers.asr.removeValue(forKey: defaultActiveASRProviderId)
schema.providers.asr.removeValue(forKey: key)
} else {
schema.providers.asr[defaultActiveASRProviderId] = existing
schema.providers.asr[key] = existing
}
}
private func mutateArk(_ apply: (inout CredentialsProviderLLMArk) -> Void) {
var existing = schema.providers.llm[defaultActiveLLMProviderId] ?? CredentialsProviderLLMArk()
private func mutateArk(_ apply: (inout CredentialsProviderLLMEntry) -> Void) {
// 老路径:`ark.*` 账号 key 始终路由到 active LLM provider。
// 这让 SettingsHubTab 等老 UI 在新 schema 下保持原本的"豆包"语义。
let key = schema.active.llm
var existing = schema.providers.llm[key] ?? CredentialsProviderLLMEntry()
apply(&existing)
if existing.isAllEmpty {
schema.providers.llm.removeValue(forKey: defaultActiveLLMProviderId)
schema.providers.llm.removeValue(forKey: key)
} else {
schema.providers.llm[defaultActiveLLMProviderId] = existing
schema.providers.llm[key] = existing
}
}
}
// MARK: - 多 LLM provider 支持(B-3)
extension CredentialsVault {
/// 当前选中的 LLM provider id;getter 同时承担"loadIfNeeded"。
public var activeLLMProviderId: String {
get {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
return schema.active.llm
}
set {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
guard schema.active.llm != newValue else { return }
schema.active.llm = newValue
try? writeLocked()
}
}
/// 列出所有已配置的 LLM provider id(包含还没填 apiKey 的占位条目)。
/// 用于设置页的 picker;当前 active 即便条目不存在也会被列出,避免"切换-删除"流程把用户卡死。
public var configuredLLMProviderIds: [String] {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
var ids = Set(schema.providers.llm.keys)
ids.insert(schema.active.llm)
return ids.sorted()
}
/// 读出某个 provider 的完整 OpenAICompatibleConfig;缺 apiKey 也仍然返回(让 UI 显示空表单)。
/// baseURL / displayName 缺省时用 registry 兜底;都没兜底则返回 nil。
public func llmProviderConfig(for providerId: String) -> OpenAICompatibleConfig? {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
let entry = schema.providers.llm[providerId]
let preset = LLMProviderRegistry.preset(for: providerId)
let displayName: String
if let stored = entry?.displayName, !stored.isEmpty {
displayName = stored
} else if let preset {
displayName = preset.displayName
} else {
displayName = providerId
}
let baseURL: URL
if let stored = entry?.baseURL,
!stored.isEmpty,
let parsed = URL(string: stored.trimmingCharacters(in: .whitespacesAndNewlines)) {
baseURL = parsed
} else if let preset {
baseURL = preset.defaultBaseURL
} else {
// 自定义 provider 又没填 baseURL:无法构造 config。
return nil
}
let model = entry?.model ?? preset?.defaultModel ?? ""
let apiKey = entry?.apiKey ?? ""
let temperature = entry?.temperature ?? 0.3
let extraHeaders = entry?.extraHeaders ?? [:]
return OpenAICompatibleConfig(
providerId: providerId,
displayName: displayName,
baseURL: baseURL,
apiKey: apiKey,
model: model,
extraHeaders: extraHeaders,
temperature: temperature
)
}
/// 写一份完整的 OpenAICompatibleConfig 到对应 provider id 下。
/// - 预设 provider:与 registry 默认值相同的 baseURL / displayName 不写盘(保持 JSON 简洁,
/// 未来 registry 调整时不会被旧文件锁死)。
/// - 自定义 provider:所有字段都写入(包括 displayName,因为没有 registry 可以兜底)。
public func setLLMProviderConfig(_ config: OpenAICompatibleConfig) {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
let preset = LLMProviderRegistry.preset(for: config.providerId)
var entry = schema.providers.llm[config.providerId] ?? CredentialsProviderLLMEntry()
// displayName:与 preset 相同就不写,UI 始终从 registry 拿。
if let preset, preset.displayName == config.displayName {
entry.displayName = nil
} else {
let trimmed = config.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
entry.displayName = trimmed.isEmpty ? nil : trimmed
}
// baseURL:与 preset.defaultBaseURL 相同就不写。
if let preset, preset.defaultBaseURL == config.baseURL {
entry.baseURL = nil
} else {
entry.baseURL = config.baseURL.absoluteString
}
// model:与 preset.defaultModel 相同 / 为空 → 视为"未覆盖",不写盘。
if let preset, !preset.defaultModel.isEmpty, preset.defaultModel == config.model {
entry.model = nil
} else {
let trimmed = config.model.trimmingCharacters(in: .whitespacesAndNewlines)
entry.model = trimmed.isEmpty ? nil : trimmed
}
// apiKey:空字符串 → nil,避免落出 `"apiKey": ""`。
let trimmedKey = config.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
entry.apiKey = trimmedKey.isEmpty ? nil : trimmedKey
// temperature:默认 0.3 视为未覆盖。
entry.temperature = config.temperature == 0.3 ? nil : config.temperature
// extraHeaders:空 dict 收敛成 nil。
entry.extraHeaders = config.extraHeaders.isEmpty ? nil : config.extraHeaders
if entry.isAllEmpty {
schema.providers.llm.removeValue(forKey: config.providerId)
} else {
schema.providers.llm[config.providerId] = entry
}
try? writeLocked()
}
/// 删除某个 LLM provider;不允许删 active provider。
/// 需要先把 `activeLLMProviderId` 切到别的 id,再删旧条目。
public func removeLLMProvider(_ providerId: String) throws {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
if schema.active.llm == providerId {
throw CredentialsError.cannotRemoveActiveProvider(providerId)
}
guard schema.providers.llm[providerId] != nil else { return }
schema.providers.llm.removeValue(forKey: providerId)
try writeLocked()
}
}
// MARK: - 时间戳工具
/// 紧凑 ISO8601 时间戳,例如 `20260429T103045`。固定 UTC,避免 DST。
@@ -1,137 +0,0 @@
import Foundation
import OpenLessCore
public enum PolishError: Error, Sendable {
case credentialsMissing
case requestFailed(String)
case decodeFailed
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)?
private let session: URLSession
public init(
credentials: ArkCredentials,
logger: (@Sendable (String) -> Void)? = nil,
session: URLSession = .shared
) {
self.credentials = credentials
self.logger = logger
self.session = session
}
public func polish(
rawTranscript: RawTranscript,
mode: PolishMode,
referenceExamples: [PolishReferenceExample] = [],
dictionaryEntries: [DictionaryEntry] = []
) async throws -> FinalText {
guard !rawTranscript.text.isEmpty else {
return FinalText(text: "", mode: mode)
}
var request = URLRequest(url: Self.chatCompletionsURL(from: credentials.endpoint))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(credentials.apiKey)", forHTTPHeaderField: "Authorization")
// 之前 30s 太短:DeepSeek V3.2 / R1 等模型即使关思考首 token 也可能 5-10s。
// 改成 10 分钟,等同"无上限"——polish 本身一定不会真挂这么久。
request.timeoutInterval = 600
let body: [String: Any] = [
"model": credentials.modelId,
"stream": false,
"temperature": 0.3,
// 关掉深度思考:V3.2 / R1 / Doubao Thinking 等模型默认会先生成一段"思考过程"
// 再给最终答案,对"整理一段口语"这种短任务是浪费——延迟 + 成本都翻几倍。
// 不支持 thinking 字段的模型(如 Doubao 普通版)会忽略这个参数。
"thinking": ["type": "disabled"],
"messages": [
["role": "system", "content": PolishPrompts.systemPrompt(for: mode)],
["role": "user", "content": PolishPrompts.userPrompt(
for: rawTranscript.text,
referenceExamples: referenceExamples,
dictionaryEntries: dictionaryEntries
)],
],
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
if let httpBody = request.httpBody, let bodyStr = String(data: httpBody, encoding: .utf8) {
logger?("[polish] request body: \(bodyStr.prefix(500))")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw PolishError.requestFailed("not http")
}
let respPreview = String(data: data, encoding: .utf8) ?? "<no body>"
logger?("[polish] HTTP \(http.statusCode) response: \(respPreview.prefix(500))")
guard (200..<300).contains(http.statusCode) else {
throw PolishError.requestFailed("HTTP \(http.statusCode): \(respPreview.prefix(300))")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String else {
throw PolishError.decodeFailed
}
let trimmed = PolishOutputCleaner.clean(content)
guard !trimmed.isEmpty else { throw PolishError.modelReturnedEmpty }
return FinalText(text: trimmed, mode: mode)
}
/// Coding Plan 文档给的是 base URL `https://ark.cn-beijing.volces.com/api/coding/v3`,
/// OpenAI SDK 会自动追加 `/chat/completions`。我们裸 URLRequest 得手动补。
/// 用户已经填好完整 URL(含 /chat/completions)时不重复追加。
static func chatCompletionsURL(from endpoint: URL) -> URL {
let s = endpoint.absoluteString
if s.hasSuffix("/chat/completions") {
return endpoint
}
let trimmed = s.hasSuffix("/") ? String(s.dropLast()) : s
return URL(string: trimmed + "/chat/completions") ?? endpoint
}
}
enum PolishOutputCleaner {
private static let leadingBoilerplatePatterns: [String] = [
#"(?s)^根据[你您]?(?:给的|提供的)?内容[,,\s]*(?:我)?(?:已经|已)?(?:整理|优化)(?:好|完成)?(?:如下)?[::\s]*"#,
#"(?s)^以下(?:是|为)?(?:整理|优化|结构化整理)后?的?内容(?:如下)?[::\s]*"#,
#"(?s)^(?:整理|优化|结构化整理)(?:后?的?内容)?(?:如下)?[::\s]*"#,
]
static func clean(_ content: String) -> String {
var output = content.trimmingCharacters(in: .whitespacesAndNewlines)
output = stripMarkdownFence(from: output)
var changed = true
while changed {
changed = false
for pattern in leadingBoilerplatePatterns {
if let range = output.range(of: pattern, options: .regularExpression),
range.lowerBound == output.startIndex {
output.removeSubrange(range)
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
changed = true
}
}
}
return output
}
private static func stripMarkdownFence(from text: String) -> String {
guard text.hasPrefix("```"), text.hasSuffix("```") else { return text }
var lines = text.components(separatedBy: .newlines)
guard lines.count >= 2 else { return text }
lines.removeFirst()
lines.removeLast()
return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -144,6 +144,8 @@ public final class OpenAICompatibleLLMProvider: LLMProvider, @unchecked Sendable
guard let content = message["content"] as? String else {
throw LLMError.parseError("message.content is not a string")
}
return content
// 即便 prompt 明令禁止"根据您给的内容,整理如下..."这类引导句,部分模型仍会顽固加;
// 客户端再扫一道,让插入到光标位置的永远是干净正文。
return PolishOutputCleaner.clean(content)
}
}
@@ -0,0 +1,48 @@
import Foundation
/// 清理 LLM 返回内容里常见的"引导套话"和 markdown 代码栅栏。
///
/// 即使我们在 prompt 里明确禁止"根据您给的内容,整理如下"等开场白,仍有部分模型会顽固加上;
/// 在客户端再扫一遍确保插入到光标位置的就是干净正文。
///
/// 这是公共类型——之前藏在 DoubaoPolishClient 内部,B-3 抽成独立文件后被
/// `OpenAICompatibleLLMProvider` 复用,并保留 `PolishOutputCleanerTests` 直接测试入口。
public enum PolishOutputCleaner {
/// 已知会被加在正文最前面的引导套话正则集合(顺序无关,循环匹配)。
private static let leadingBoilerplatePatterns: [String] = [
#"(?s)^根据[你您]?(?:给的|提供的)?内容[,,\s]*(?:我)?(?:已经|已)?(?:整理|优化)(?:好|完成)?(?:如下)?[::\s]*"#,
#"(?s)^以下(?:是|为)?(?:整理|优化|结构化整理)后?的?内容(?:如下)?[::\s]*"#,
#"(?s)^(?:整理|优化|结构化整理)(?:后?的?内容)?(?:如下)?[::\s]*"#,
]
public static func clean(_ content: String) -> String {
var output = content.trimmingCharacters(in: .whitespacesAndNewlines)
output = stripMarkdownFence(from: output)
// 多模型可能叠加多次套话;循环到不再变化为止。
var changed = true
while changed {
changed = false
for pattern in leadingBoilerplatePatterns {
if let range = output.range(of: pattern, options: .regularExpression),
range.lowerBound == output.startIndex {
output.removeSubrange(range)
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
changed = true
}
}
}
return output
}
/// 去掉首尾的 ```...``` 围栏(含语言标记行如 ```text)。
private static func stripMarkdownFence(from text: String) -> String {
guard text.hasPrefix("```"), text.hasSuffix("```") else { return text }
var lines = text.components(separatedBy: .newlines)
guard lines.count >= 2 else { return text }
lines.removeFirst()
lines.removeLast()
return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,86 @@
import XCTest
@testable import OpenLessCore
/// LLMProviderRegistry 的静态健康检查:5 家供应商各 1 条,id 不重复,URL 合法。
///
/// 这些断言看起来很无聊,但 registry 直接被设置 UI 和 vault 兜底逻辑用,
/// 漏一条都会变成"用户加不了 provider / baseURL 解不出来"的运行期 bug。
final class LLMProviderRegistryTests: XCTestCase {
func test_presets_haveExpectedCount() {
// Arrange
let presets = LLMProviderRegistry.presets
// Act / Assert
XCTAssertEqual(presets.count, 5, "B-3 计划列出的预设是 ark / openai / aliyun-dashscope / deepseek / moonshot")
}
func test_presets_includeAllExpectedProviderIds() {
// Arrange
let expected: Set<String> = ["ark", "openai", "aliyun-dashscope", "deepseek", "moonshot"]
// Act
let actualIds = Set(LLMProviderRegistry.presets.map(\.providerId))
// Assert
XCTAssertEqual(actualIds, expected)
}
func test_presets_haveUniqueProviderIds() {
// Arrange
let ids = LLMProviderRegistry.presets.map(\.providerId)
// Act / Assert
XCTAssertEqual(Set(ids).count, ids.count, "providerId 必须唯一,否则 vault 会被互相覆盖")
}
func test_presets_haveValidBaseURLs() {
for preset in LLMProviderRegistry.presets {
// Arrange
let url = preset.defaultBaseURL
// Act / Assert:URL 必须是 https + 有 host
XCTAssertEqual(url.scheme, "https", "preset \(preset.providerId) 的 baseURL 必须是 https,避免网络层降级")
XCTAssertNotNil(url.host, "preset \(preset.providerId) 的 baseURL 必须有 host")
}
}
func test_arkPreset_hasEmptyDefaultModel() {
// Ark 的 defaultModel 故意留空——endpoint id 是用户在控制台拿到的 hash,
// 不能预设。如果哪天默认填了一个看起来像 model 的字符串,UI 会误导用户。
let ark = LLMProviderRegistry.preset(for: "ark")
XCTAssertNotNil(ark)
XCTAssertEqual(ark?.defaultModel, "")
}
func test_nonArkPresets_haveDefaultModel() {
// OpenAI / DeepSeek / Moonshot / DashScope 都给了一个开箱即用的 model。
for preset in LLMProviderRegistry.presets where preset.providerId != "ark" {
XCTAssertFalse(preset.defaultModel.isEmpty, "preset \(preset.providerId) 应当有 defaultModel")
}
}
func test_presets_haveNonEmptyHelpText() {
for preset in LLMProviderRegistry.presets {
XCTAssertFalse(preset.helpText.isEmpty, "preset \(preset.providerId) 的 helpText 不能为空")
}
}
func test_preset_lookup_returnsRegistered() {
for preset in LLMProviderRegistry.presets {
let resolved = LLMProviderRegistry.preset(for: preset.providerId)
XCTAssertEqual(resolved, preset)
}
}
func test_preset_lookup_unknownReturnsNil() {
XCTAssertNil(LLMProviderRegistry.preset(for: "no-such-provider-xyz"))
}
func test_customSentinels_areDistinctFromPresetIds() {
// 添加 sheet 用 customProviderId 区分"预设 vs 自定义"——它必须不在预设列表里,
// 否则会被点击事件混淆。
let presetIds = Set(LLMProviderRegistry.presets.map(\.providerId))
XCTAssertFalse(presetIds.contains(LLMProviderRegistry.customProviderId))
}
}
@@ -0,0 +1,253 @@
import XCTest
import OpenLessCore
@testable import OpenLessPersistence
/// CredentialsVault 多 LLM provider 扩展(B-3)的集成测试。
///
/// 用例策略与 CredentialsMigrationTests 一致:每个 case 独立 temp 目录,
/// 通过 vault 的 public API 验证多 provider 行为。
final class CredentialsVaultMultiProviderTests: XCTestCase {
// MARK: - 辅助
private func makeTempDirectory() -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("openless-multi-provider-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
addTeardownBlock {
try? FileManager.default.removeItem(at: dir)
}
return dir
}
/// 写一份 v0 字典文件(点号风格 key),让 vault 自动迁移。
private func writeV0File(_ dict: [String: String], to dir: URL) throws {
let url = dir.appendingPathComponent("credentials.json")
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted])
try data.write(to: url)
}
// MARK: - active provider id
func test_freshInstall_activeLLMProviderId_defaultsToArk() {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
let active = vault.activeLLMProviderId
// Assert:B-2 的默认 active.llm 是 "ark",B-3 改成多 provider 后语义不变。
XCTAssertEqual(active, "ark")
}
func test_v0Migrated_activeLLMProviderId_isArk() throws {
// Arrange:典型迁移场景——从 v0 翻译过来 active.llm 应是 ark。
let dir = makeTempDirectory()
try writeV0File([
"ark.api_key": "sk-ark",
"ark.model_id": "deepseek-v3-2"
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert
XCTAssertEqual(vault.activeLLMProviderId, "ark")
}
func test_setActive_persists() throws {
// Arrange
let dir = makeTempDirectory()
// 先写一个 deepseek provider 进去(可以是空字段)。
let vault = CredentialsVault(directoryURL: dir)
vault.setLLMProviderConfig(OpenAICompatibleConfig(
providerId: "deepseek",
displayName: "DeepSeek",
baseURL: URL(string: "https://api.deepseek.com/v1")!,
apiKey: "sk-deepseek",
model: "deepseek-chat"
))
// Act
vault.activeLLMProviderId = "deepseek"
// Assert:内存视图
XCTAssertEqual(vault.activeLLMProviderId, "deepseek")
// 再开一个新 vault 实例确认落盘。
let vault2 = CredentialsVault(directoryURL: dir)
XCTAssertEqual(vault2.activeLLMProviderId, "deepseek")
}
// MARK: - configuredLLMProviderIds
func test_configuredIds_emptyVault_returnsActive() {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
let ids = vault.configuredLLMProviderIds
// Assert:vault 没有任何条目时,至少要返回 active id(picker 不能空)。
XCTAssertEqual(ids, ["ark"])
}
func test_configuredIds_returnsAllConfigured() throws {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
vault.setLLMProviderConfig(OpenAICompatibleConfig(
providerId: "ark",
displayName: "豆包 (Ark)",
baseURL: URL(string: "https://ark.cn-beijing.volces.com/api/v3")!,
apiKey: "ark-key",
model: "ep-1"
))
vault.setLLMProviderConfig(OpenAICompatibleConfig(
providerId: "openai",
displayName: "OpenAI",
baseURL: URL(string: "https://api.openai.com/v1")!,
apiKey: "sk-openai",
model: "gpt-4o-mini"
))
vault.setLLMProviderConfig(OpenAICompatibleConfig(
providerId: "deepseek",
displayName: "DeepSeek",
baseURL: URL(string: "https://api.deepseek.com/v1")!,
apiKey: "sk-deepseek",
model: "deepseek-chat"
))
// Act
let ids = vault.configuredLLMProviderIds
// Assert:3 家全在;顺序 sorted。
XCTAssertEqual(ids, ["ark", "deepseek", "openai"])
}
// MARK: - setLLMProviderConfig / llmProviderConfig 往返
func test_setAndGetConfig_roundTrips() {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
let cfg = OpenAICompatibleConfig(
providerId: "deepseek",
displayName: "DeepSeek",
baseURL: URL(string: "https://api.deepseek.com/v1")!,
apiKey: "sk-deep-12345",
model: "deepseek-chat",
extraHeaders: ["X-Custom": "value"],
temperature: 0.5
)
// Act
vault.setLLMProviderConfig(cfg)
let back = vault.llmProviderConfig(for: "deepseek")
// Assert:所有字段都应一一回读。
XCTAssertEqual(back?.providerId, cfg.providerId)
XCTAssertEqual(back?.displayName, cfg.displayName)
XCTAssertEqual(back?.baseURL, cfg.baseURL)
XCTAssertEqual(back?.apiKey, cfg.apiKey)
XCTAssertEqual(back?.model, cfg.model)
XCTAssertEqual(back?.temperature, cfg.temperature)
XCTAssertEqual(back?.extraHeaders, cfg.extraHeaders)
}
func test_getConfig_unknownProvider_returnsNilWithoutPreset() {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
let cfg = vault.llmProviderConfig(for: "unknown-no-preset-xyz")
// Assert:自定义 + 没有任何条目 + 没有预设 → 兜不出 baseURL,应返回 nil。
XCTAssertNil(cfg)
}
func test_getConfig_presetWithoutEntry_usesPresetDefaults() {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
// openai 没有 entry,但有 preset;llmProviderConfig 应 fall back 到 preset 的 baseURL。
let cfg = vault.llmProviderConfig(for: "openai")
// Assert
XCTAssertNotNil(cfg)
XCTAssertEqual(cfg?.baseURL.absoluteString, "https://api.openai.com/v1")
XCTAssertEqual(cfg?.displayName, "OpenAI")
XCTAssertEqual(cfg?.model, "gpt-4o-mini")
XCTAssertEqual(cfg?.apiKey, "")
}
// MARK: - removeLLMProvider
func test_removeLLMProvider_blocksDeletingActive() throws {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
// Act / Assert:默认 active 是 ark,不能删。
XCTAssertThrowsError(try vault.removeLLMProvider("ark")) { err in
guard case CredentialsError.cannotRemoveActiveProvider(let id) = err else {
XCTFail("应该抛 cannotRemoveActiveProvider,实际:\(err)")
return
}
XCTAssertEqual(id, "ark")
}
}
func test_removeLLMProvider_removesNonActive() throws {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
vault.setLLMProviderConfig(OpenAICompatibleConfig(
providerId: "deepseek",
displayName: "DeepSeek",
baseURL: URL(string: "https://api.deepseek.com/v1")!,
apiKey: "sk",
model: "deepseek-chat"
))
// Act
try vault.removeLLMProvider("deepseek")
// Assert
XCTAssertNil(vault.llmProviderConfig(for: "deepseek")?.apiKey == "sk" ? () : ()) // sanity
let ids = vault.configuredLLMProviderIds
XCTAssertFalse(ids.contains("deepseek"))
}
func test_removeLLMProvider_unknownIdIsNoop() {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
// Act / Assert:删一个根本不在的 id 不应抛错。
XCTAssertNoThrow(try vault.removeLLMProvider("never-existed"))
}
// MARK: - 兼容老 API
func test_legacyArkAccount_routesToActive() throws {
// Arrange
let dir = makeTempDirectory()
let vault = CredentialsVault(directoryURL: dir)
// 通过老 API 写入。
try vault.set("legacy-key", for: CredentialAccount.arkApiKey)
// Act:新的 multi-provider API 应能读出来。
let cfg = vault.llmProviderConfig(for: "ark")
// Assert
XCTAssertEqual(cfg?.apiKey, "legacy-key")
}
}