79be7384dd
13 items, 555-line diff, build + 15/15 tests green.
ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.
ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.
ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.
ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.
RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.
TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).
TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.
TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.
TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
147 lines
5.3 KiB
Swift
147 lines
5.3 KiB
Swift
// LLMClient.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Protocol-based LLM client. Default implementation is the OpenAI-compatible
|
|
// chat completion client. Add other impls (Anthropic, Gemini) as needed.
|
|
|
|
import Foundation
|
|
|
|
public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
|
case invalidURL
|
|
case noAPIKey
|
|
case http(status: Int)
|
|
case decoding(String)
|
|
case transport(String)
|
|
case cancelled
|
|
case rateLimited
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "API 地址无效。请在设置中检查 Base URL。"
|
|
case .noAPIKey: return "未填写 API Key。"
|
|
case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。"
|
|
case .decoding: return "解析 API 响应失败。"
|
|
case .transport: return "网络错误,请检查连接后重试。"
|
|
case .rateLimited: return "API 调用过于频繁,请稍候再试。"
|
|
case .cancelled: return "请求已取消。"
|
|
}
|
|
}
|
|
}
|
|
|
|
public protocol LLMClient: Sendable {
|
|
func polish(_ text: String, systemPrompt: String) async throws -> String
|
|
|
|
/// Single source of truth for the upper bound on a single LLM HTTP
|
|
/// round-trip. Both the `URLRequest` we send and any wrapping
|
|
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
|
|
/// must read from this property so the two never disagree.
|
|
var requestTimeout: TimeInterval { get }
|
|
}
|
|
|
|
// MARK: - OpenAI-compatible implementation
|
|
|
|
public struct OpenAICompatibleClient: LLMClient {
|
|
public let baseURL: String
|
|
public let apiKey: String
|
|
public let model: String
|
|
public let session: URLSession
|
|
|
|
/// Canonical request timeout for a single LLM HTTP round-trip. Both
|
|
/// the `URLRequest.timeoutInterval` we set below and any external
|
|
/// race that wants to bound the total time spent waiting on the LLM
|
|
/// (e.g. `PolishingService`) should derive from this constant.
|
|
public let requestTimeout: TimeInterval = 15
|
|
|
|
public init(
|
|
baseURL: String,
|
|
apiKey: String,
|
|
model: String,
|
|
session: URLSession = .shared
|
|
) {
|
|
self.baseURL = baseURL
|
|
self.apiKey = apiKey
|
|
self.model = model
|
|
self.session = session
|
|
}
|
|
|
|
public func polish(_ text: String, systemPrompt: String) async throws -> String {
|
|
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
|
|
|
let urlString = baseURL.hasSuffix("/")
|
|
? "\(baseURL)chat/completions"
|
|
: "\(baseURL)/chat/completions"
|
|
guard let url = URL(string: urlString) else { throw LLMError.invalidURL }
|
|
|
|
let request = LLMRequest(
|
|
model: model,
|
|
messages: [
|
|
.system(systemPrompt),
|
|
.user(text)
|
|
],
|
|
temperature: 0.3,
|
|
maxTokens: nil
|
|
)
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
req.timeoutInterval = requestTimeout
|
|
|
|
let encoder = JSONEncoder()
|
|
req.httpBody = try encoder.encode(request)
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: req)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw LLMError.transport("non-HTTP response")
|
|
}
|
|
if !(200..<300).contains(http.statusCode) {
|
|
#if DEBUG
|
|
// Log full body for debugging — never expose to UI.
|
|
let body = String(data: data, encoding: .utf8) ?? ""
|
|
print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))")
|
|
#endif
|
|
if http.statusCode == 429 { throw LLMError.rateLimited }
|
|
throw LLMError.http(status: http.statusCode)
|
|
}
|
|
do {
|
|
let decoded = try JSONDecoder().decode(LLMResponse.self, from: data)
|
|
return decoded.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
} catch {
|
|
throw LLMError.decoding(String(describing: error))
|
|
}
|
|
} catch let err as LLMError {
|
|
throw err
|
|
} catch is CancellationError {
|
|
throw LLMError.cancelled
|
|
} catch let urlError as URLError where urlError.code == .cancelled {
|
|
throw LLMError.cancelled
|
|
} catch {
|
|
throw LLMError.transport(String(describing: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Factory
|
|
|
|
public enum LLMClientFactory {
|
|
/// Build a client from the current `ProviderConfig`.
|
|
public static func make(from config: ProviderConfig) -> LLMClient {
|
|
OpenAICompatibleClient(
|
|
baseURL: config.baseURL,
|
|
apiKey: config.apiKey,
|
|
model: config.model
|
|
)
|
|
}
|
|
|
|
/// Single source of truth for the LLM request timeout, shared by
|
|
/// `LLMClient.requestTimeout` implementations and any caller that
|
|
/// wants to bound total time spent waiting on the LLM (e.g.
|
|
/// `PolishingService`'s safety-net `withThrowingTaskGroup`). Use
|
|
/// this instead of hard-coding `15` so all timeouts stay aligned.
|
|
public static var defaultRequestTimeout: TimeInterval {
|
|
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout
|
|
}
|
|
}
|