diff --git a/Sources/OpenLessCore/LLMProviderRegistry.swift b/Sources/OpenLessCore/LLMProviderRegistry.swift new file mode 100644 index 00000000..6c65d896 --- /dev/null +++ b/Sources/OpenLessCore/LLMProviderRegistry.swift @@ -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 } + } +} diff --git a/Sources/OpenLessPersistence/CredentialsError.swift b/Sources/OpenLessPersistence/CredentialsError.swift index f12a2746..6069f41a 100644 --- a/Sources/OpenLessPersistence/CredentialsError.swift +++ b/Sources/OpenLessPersistence/CredentialsError.swift @@ -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) } diff --git a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift index 64cd2803..c3503858 100644 --- a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift +++ b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift @@ -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`。 diff --git a/Sources/OpenLessPersistence/CredentialsVault.swift b/Sources/OpenLessPersistence/CredentialsVault.swift index 9f511bb8..b5898882 100644 --- a/Sources/OpenLessPersistence/CredentialsVault.swift +++ b/Sources/OpenLessPersistence/CredentialsVault.swift @@ -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。 diff --git a/Sources/OpenLessPolish/DoubaoPolishClient.swift b/Sources/OpenLessPolish/DoubaoPolishClient.swift deleted file mode 100644 index c638b35c..00000000 --- a/Sources/OpenLessPolish/DoubaoPolishClient.swift +++ /dev/null @@ -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) ?? "" - 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) - } -} diff --git a/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift index 0c690f90..da10df7d 100644 --- a/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift +++ b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift @@ -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) } } diff --git a/Sources/OpenLessPolish/PolishOutputCleaner.swift b/Sources/OpenLessPolish/PolishOutputCleaner.swift new file mode 100644 index 00000000..bb386df7 --- /dev/null +++ b/Sources/OpenLessPolish/PolishOutputCleaner.swift @@ -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) + } +} diff --git a/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift b/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift new file mode 100644 index 00000000..23dffab7 --- /dev/null +++ b/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift @@ -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 = ["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)) + } +} diff --git a/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift b/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift new file mode 100644 index 00000000..1f3f42c6 --- /dev/null +++ b/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift @@ -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") + } +}