Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

171 lines
5.8 KiB
Swift

import Foundation
import MacToolsPluginKit
struct OpenAICompatibleConfiguration: Equatable, Sendable {
static let defaultBaseURL = "https://api.openai.com"
static let defaultModel = "gpt-5.4-mini"
static var defaultPromptTemplate: String {
defaultPromptTemplate()
}
static func defaultPromptTemplate(
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
localization.string(
"settings.defaultPromptTemplate",
defaultValue: "请将下面的文本从 {{source_language}} 翻译为 {{target_language}},只返回译文。\n\n{{text}}"
)
}
var baseURL: String
var model: String
var promptTemplate: String
var normalizedBaseURL: String {
baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
}
var normalizedModel: String {
model.trimmingCharacters(in: .whitespacesAndNewlines)
}
init(
baseURL: String = Self.defaultBaseURL,
model: String = Self.defaultModel,
promptTemplate: String = Self.defaultPromptTemplate
) {
self.baseURL = baseURL
self.model = model
self.promptTemplate = promptTemplate
}
@MainActor
init(
storage: PluginStorage,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.init(
baseURL: storage.string(forKey: TranslatorConstants.StorageKey.openAIBaseURL) ?? Self.defaultBaseURL,
model: storage.string(forKey: TranslatorConstants.StorageKey.openAIModel) ?? Self.defaultModel,
promptTemplate: storage.string(forKey: TranslatorConstants.StorageKey.openAIPromptTemplate)
?? Self.defaultPromptTemplate(localization: localization)
)
}
var validationError: OpenAICompatibleConfigurationError? {
if normalizedBaseURL.isEmpty {
return .blankBaseURL
}
guard let components = URLComponents(string: normalizedBaseURL),
let host = components.host,
!host.isEmpty,
Self.isAllowedScheme(components.scheme, host: host)
else {
return .invalidBaseURL
}
if normalizedModel.isEmpty {
return .blankModel
}
if !promptTemplate.contains("{{text}}") {
return .missingTextPlaceholder
}
return nil
}
@MainActor
func save(to storage: PluginStorage) {
storage.set(normalizedBaseURL, forKey: TranslatorConstants.StorageKey.openAIBaseURL)
storage.set(normalizedModel, forKey: TranslatorConstants.StorageKey.openAIModel)
storage.set(promptTemplate, forKey: TranslatorConstants.StorageKey.openAIPromptTemplate)
}
func endpointURL() throws -> URL {
if let validationError {
throw validationError
}
guard var components = URLComponents(string: normalizedBaseURL),
let host = components.host,
!host.isEmpty,
Self.isAllowedScheme(components.scheme, host: host)
else {
throw OpenAICompatibleConfigurationError.invalidBaseURL
}
let basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let pathComponents = basePath.isEmpty ? [] : basePath.split(separator: "/").map(String.init)
let lowercasedPathComponents = pathComponents.map { $0.lowercased() }
let completionPathComponents: [String]
if Array(lowercasedPathComponents.suffix(2)) == ["chat", "completions"] {
completionPathComponents = pathComponents
} else if lowercasedPathComponents.last == "v1" {
completionPathComponents = pathComponents + ["chat", "completions"]
} else {
completionPathComponents = pathComponents + ["v1", "chat", "completions"]
}
components.path = "/" + completionPathComponents.joined(separator: "/")
guard let url = components.url else {
throw OpenAICompatibleConfigurationError.invalidBaseURL
}
return url
}
private static func isAllowedScheme(_ scheme: String?, host: String) -> Bool {
guard let scheme = scheme?.lowercased() else {
return false
}
if scheme == "https" {
return true
}
return scheme == "http" && isLoopbackHost(host)
}
private static func isLoopbackHost(_ host: String) -> Bool {
let normalizedHost = host
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
.lowercased()
return normalizedHost == "localhost"
|| normalizedHost == "127.0.0.1"
|| normalizedHost == "::1"
}
}
enum OpenAICompatibleConfigurationError: Error, Equatable, Sendable {
case missingTextPlaceholder
case blankBaseURL
case invalidBaseURL
case blankModel
}
extension OpenAICompatibleConfigurationError: LocalizedError {
var errorDescription: String? {
errorDescription()
}
func errorDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case .missingTextPlaceholder:
return localization.string(
"openAIConfiguration.error.missingTextPlaceholder",
defaultValue: "提示词必须包含 {{text}}。"
)
case .blankBaseURL:
return localization.string("openAIConfiguration.error.blankBaseURL", defaultValue: "Base URL 不能为空。")
case .invalidBaseURL:
return localization.string("openAIConfiguration.error.invalidBaseURL", defaultValue: "Base URL 无效。")
case .blankModel:
return localization.string("openAIConfiguration.error.blankModel", defaultValue: "模型不能为空。")
}
}
}