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:
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user