feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding

- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
  global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
  provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
  diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
This commit is contained in:
Rocky
2026-07-11 19:10:20 +08:00
parent cdf833935a
commit cc8dd1070a
116 changed files with 6659 additions and 2634 deletions
@@ -0,0 +1,69 @@
// AnthropicLLMClient.swift
// OSGKeyboard · Shared
//
// Anthropic Messages API client for polish / translation prompts.
import Foundation
public struct AnthropicMessagesClient: LLMClient {
public let apiKey: String
public let model: String
public let session: URLSession
public let requestTimeout: TimeInterval = 15
public init(
apiKey: String,
model: String,
session: URLSession = .shared
) {
self.apiKey = apiKey
self.model = model
self.session = session
}
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
let url = URL(string: "https://api.anthropic.com/v1/messages")!
let body: [String: Any] = [
"model": model,
"max_tokens": 4_096,
"system": systemPrompt,
"messages": [
["role": "user", "content": text],
],
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
request.timeoutInterval = timeout ?? requestTimeout
request.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LLMError.transport("non-HTTP response")
}
if !(200..<300).contains(http.statusCode) {
if http.statusCode == 429 { throw LLMError.rateLimited }
throw LLMError.http(status: http.statusCode)
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let content = json["content"] as? [[String: Any]],
let first = content.first,
let textBlock = first["text"] as? String else {
throw LLMError.decoding("anthropic content")
}
return textBlock.trimmingCharacters(in: .whitespacesAndNewlines)
} catch let err as LLMError {
throw err
} catch is CancellationError {
throw LLMError.cancelled
} catch {
throw LLMError.transport(String(describing: error))
}
}
}
+7 -15
View File
@@ -66,6 +66,7 @@ public struct AppGroupStore: @unchecked Sendable {
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
public var isLocalEngine: Bool { configuration.isLocalEngine }
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
@@ -87,21 +88,7 @@ public struct AppGroupStore: @unchecked Sendable {
}
public func setEngineMode(_ mode: String) {
mutateConfiguration { config in
config.engineMode = mode
if mode == "cloud", config.providerId == "deepseek" {
let openAI = LLMProvider.provider(id: "openai")
config.providerId = openAI.id
config.baseURL = openAI.defaultBaseURL
config.model = openAI.defaultModel
}
if mode == "cloud", config.asrProviderId == "deepseek" {
let openAI = LLMProvider.provider(id: "openai")
config.asrProviderId = openAI.id
config.asrBaseURL = openAI.defaultBaseURL
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
}
}
mutateConfiguration { $0.engineMode = mode }
AppGroupConfigDarwin.postConfigChanged()
}
@@ -134,6 +121,11 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.polishIntensity = intensity }
}
public func setLLMThinkingEnabled(_ enabled: Bool) {
mutateConfiguration { $0.llmThinkingEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
@@ -0,0 +1,434 @@
// BailianRealtimeASRClient.swift
// OSGKeyboard · Shared
//
// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference
// WebSocket (`/api-ws/v1/inference`). Matches OpenLess' `bailian.rs` wire
// protocol: run-task PCM binary frames finish-task result events.
import Foundation
struct BailianRealtimeASRClient: CloudASRTranscribing {
let apiKey: String
let endpoint: String
let model: String
let vocabularyID: String?
let session: URLSession
/// 100 ms of 16 kHz / 16-bit / mono PCM.
private static let targetChunkBytes = 3_200
private static let startTimeout: TimeInterval = 8
private static let finalTimeout: TimeInterval = 12
private static let sessionTimeout: TimeInterval = startTimeout + finalTimeout + 4
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
guard sampleRate == 16_000 else {
throw CloudASRError.transport("Bailian realtime expects 16 kHz audio")
}
guard !samples.isEmpty else { throw CloudASRError.emptyTranscript }
let url = try resolvedEndpointURL()
let pcm = Self.pcm16Data(samples: samples)
let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.alibabaFunASRRealtime
: model.trimmingCharacters(in: .whitespacesAndNewlines)
var request = URLRequest(url: url)
request.timeoutInterval = 8
request.setValue(
"bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))",
forHTTPHeaderField: "Authorization"
)
let wsTask = session.webSocketTask(with: request)
wsTask.resume()
return try await withThrowingTaskGroup(of: String.self) { group in
let events = BailianEventStream(task: wsTask)
group.addTask {
defer { events.cancel() }
return try await Self.runSession(
taskID: taskID,
model: resolvedModel,
pcm: pcm,
wsTask: wsTask,
events: events
)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(Self.sessionTimeout * 1_000_000_000))
events.cancel()
wsTask.cancel(with: .goingAway, reason: nil)
throw CloudASRError.transport("session timed out")
}
guard let result = try await group.next() else {
throw CloudASRError.emptyTranscript
}
group.cancelAll()
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// Settings connection probe: handshake to `task-started` only.
///
/// Reaching `task-started` proves endpoint + `Authorization` + model are
/// all valid which is exactly what "validate connection" must check.
/// It deliberately sends NO audio: DashScope realtime rejects a short
/// silent probe with a `task-failed: emptyAudio`, which is a false
/// negative for a connectivity test. A real auth/quota/model failure
/// still arrives as `task-failed` before `task-started` and surfaces.
func probeConnection() async throws {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
let url = try resolvedEndpointURL()
let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.alibabaFunASRRealtime
: model.trimmingCharacters(in: .whitespacesAndNewlines)
var request = URLRequest(url: url)
request.timeoutInterval = 8
request.setValue(
"bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))",
forHTTPHeaderField: "Authorization"
)
let wsTask = session.webSocketTask(with: request)
wsTask.resume()
try await withThrowingTaskGroup(of: Void.self) { group in
let events = BailianEventStream(task: wsTask)
group.addTask {
defer { events.cancel() }
try await Self.sendText(
Self.runTaskMessage(taskID: taskID, model: resolvedModel, vocabularyID: nil),
task: wsTask
)
try await events.waitForStarted(timeout: Self.startTimeout)
// Politely end the task; the connection is already proven.
try? await Self.sendText(Self.finishTaskMessage(taskID: taskID), task: wsTask)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(Self.startTimeout * 1_000_000_000))
events.cancel()
wsTask.cancel(with: .goingAway, reason: nil)
throw CloudASRError.transport("connection probe timed out")
}
_ = try await group.next()
group.cancelAll()
}
}
private static func runSession(
taskID: String,
model: String,
pcm: Data,
wsTask: URLSessionWebSocketTask,
events: BailianEventStream
) async throws -> String {
try await sendText(
runTaskMessage(taskID: taskID, model: model, vocabularyID: nil),
task: wsTask
)
try await events.waitForStarted(timeout: startTimeout)
var offset = 0
while offset < pcm.count {
let end = min(offset + targetChunkBytes, pcm.count)
try await sendBinary(pcm.subdata(in: offset..<end), task: wsTask)
offset = end
}
// Let the server register the final frames before ending the task.
// Sending `finish-task` in the same instant as the last binary frame
// races the server's audio buffering (root cause of `emptyAudio` on
// very short clips).
try? await Task.sleep(nanoseconds: 120_000_000)
try await sendText(finishTaskMessage(taskID: taskID), task: wsTask)
return try await events.waitForFinalText(timeout: finalTimeout)
}
private func resolvedEndpointURL() throws -> URL {
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.bailianDefaultEndpoint
: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
return url
}
private static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.string(text))
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
}
private static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.data(data))
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
}
private static func pcm16Data(samples: [Float]) -> Data {
var data = Data()
data.reserveCapacity(samples.count * 2)
for sample in samples {
let scaled = sample * 32_767.0
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
var littleEndian = Int16(clipped.rounded()).littleEndian
withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) }
}
return data
}
/// Overlap-aware join to avoid cumulative duplicate text from interim replays.
static func mergeSegments(_ segments: [String]) -> String {
var result = ""
for segment in segments {
if result.isEmpty {
result = segment
continue
}
let resultChars = Array(result)
let segmentChars = Array(segment)
let maxOverlap = min(resultChars.count, segmentChars.count)
var overlap = 0
if maxOverlap >= 2 {
for length in stride(from: maxOverlap, through: 2, by: -1) {
let tail = resultChars.suffix(length)
let head = segmentChars.prefix(length)
if tail.elementsEqual(head) {
overlap = length
break
}
}
}
result.append(contentsOf: segmentChars.dropFirst(overlap))
}
return result
}
static func runTaskMessage(taskID: String, model: String, vocabularyID: String?) -> String {
var parameters: [String: Any] = [
"sample_rate": 16_000,
"format": "pcm",
]
if let vocabularyID = vocabularyID?.trimmingCharacters(in: .whitespacesAndNewlines),
!vocabularyID.isEmpty {
parameters["vocabulary_id"] = vocabularyID
}
let body: [String: Any] = [
"header": [
"action": "run-task",
"task_id": taskID,
"streaming": "duplex",
],
"payload": [
"task_group": "audio",
"task": "asr",
"function": "recognition",
"model": model,
"parameters": parameters,
"input": [:] as [String: Any],
],
]
guard let data = try? JSONSerialization.data(withJSONObject: body),
let json = String(data: data, encoding: .utf8) else {
return "{}"
}
return json
}
static func finishTaskMessage(taskID: String) -> String {
let body: [String: Any] = [
"header": [
"action": "finish-task",
"task_id": taskID,
"streaming": "duplex",
],
"payload": ["input": [:] as [String: Any]],
]
guard let data = try? JSONSerialization.data(withJSONObject: body),
let json = String(data: data, encoding: .utf8) else {
return "{}"
}
return json
}
}
// MARK: - Concurrent read loop
private final class BailianEventStream: @unchecked Sendable {
private let task: URLSessionWebSocketTask
private let lock = NSLock()
private var started = false
private var finalText: String?
private var failure: Error?
private var readTask: Task<Void, Never>?
init(task: URLSessionWebSocketTask) {
self.task = task
readTask = Task { [weak self] in
await self?.readLoop()
}
}
func cancel() {
readTask?.cancel()
task.cancel(with: .goingAway, reason: nil)
}
func waitForStarted(timeout: TimeInterval) async throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let failure = snapshotFailure() { throw failure }
if snapshotStarted() { return }
try await Task.sleep(nanoseconds: 20_000_000)
}
cancel()
throw CloudASRError.transport("task-started timed out")
}
func waitForFinalText(timeout: TimeInterval) async throws -> String {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let failure = snapshotFailure() { throw failure }
if let text = snapshotFinalText() { return text }
try await Task.sleep(nanoseconds: 20_000_000)
}
cancel()
throw CloudASRError.transport("final result timed out")
}
private func snapshotStarted() -> Bool {
lock.lock()
defer { lock.unlock() }
return started
}
private func snapshotFinalText() -> String? {
lock.lock()
defer { lock.unlock() }
return finalText
}
private func snapshotFailure() -> Error? {
lock.lock()
defer { lock.unlock() }
return failure
}
private func readLoop() async {
var finalSegments: [Int64: String] = [:]
var partialSegments: [Int64: String] = [:]
var lastResultText = ""
while !Task.isCancelled {
let message: URLSessionWebSocketTask.Message
do {
message = try await task.receive()
} catch {
publishFailure(CloudASRError.transport(error.localizedDescription))
return
}
let text: String
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8) ?? ""
@unknown default:
continue
}
guard !text.isEmpty else { continue }
guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
let header = json["header"] as? [String: Any] else {
continue
}
let event = header["event"] as? String ?? ""
switch event {
case "task-started":
publishStarted()
case "result-generated":
guard let payload = json["payload"] as? [String: Any],
let output = payload["output"] as? [String: Any],
let sentenceObj = output["sentence"] as? [String: Any] else {
continue
}
if sentenceObj["heartbeat"] as? Bool == true { continue }
guard let rawText = sentenceObj["text"] as? String else { continue }
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
lastResultText = trimmed
let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0
let sentenceEndValue = sentenceObj["sentence_end"]
let sentenceEnd = sentenceEndValue as? Bool ?? false
let endTime = sentenceObj["end_time"] as? Int64 ?? 0
let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0
if isFinal {
finalSegments[sentenceID] = trimmed
partialSegments.removeValue(forKey: sentenceID)
} else {
partialSegments[sentenceID] = trimmed
}
case "task-finished":
if finalSegments.isEmpty {
publishFinal(lastResultText)
} else {
let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] }
publishFinal(BailianRealtimeASRClient.mergeSegments(ordered))
}
return
case "task-failed":
let message = header["error_message"] as? String ?? "task failed"
publishFailure(CloudASRError.transport(message))
return
default:
break
}
}
}
private func publishStarted() {
lock.lock()
started = true
lock.unlock()
}
private func publishFinal(_ text: String) {
lock.lock()
finalText = text
lock.unlock()
}
private func publishFailure(_ error: Error) {
lock.lock()
failure = error
lock.unlock()
cancel()
}
}
@@ -13,6 +13,28 @@ public protocol CloudASRTranscribing: Sendable {
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String
/// Settings "validate connection" probe. Verifies transport + auth only.
func probeConnection() async throws
}
extension CloudASRTranscribing {
/// Default probe: transcribe ~1 s of near-silence. An empty transcript
/// counts as success HTTP/streaming providers only need to prove that
/// transport + auth work. Providers whose service rejects silent/short
/// audio (e.g. DashScope realtime returns `emptyAudio`) override this.
public func probeConnection() async throws {
do {
_ = try await transcribe(
samples: [Float](repeating: 0.01, count: 16_000),
sampleRate: 16_000,
locale: Locale(identifier: "zh-CN"),
dictionary: .empty
)
} catch CloudASRError.emptyTranscript {
return
}
}
}
public enum CloudASRClientFactory {
@@ -29,11 +51,12 @@ public enum CloudASRClientFactory {
model: asrModel,
session: session
)
case .alibabaVocabulary:
return AlibabaFunASRClient(
case .bailianStreaming:
return BailianRealtimeASRClient(
apiKey: store.asrApiKey,
endpoint: store.asrBaseURL,
model: asrModel,
persistence: store.cloudASRPersistence,
vocabularyID: nil,
session: session
)
case .prompt:
@@ -44,6 +67,22 @@ public enum CloudASRClientFactory {
model: asrModel,
session: session
)
case .openRouterJson:
return PromptCloudASRClient(
providerId: providerId,
baseURL: store.asrBaseURL,
apiKey: store.asrApiKey,
model: asrModel,
session: session,
requestFormat: .openRouterJson
)
case .volcengineStreaming:
return VolcengineCloudASRClient(
apiKey: store.asrApiKey,
endpoint: store.asrBaseURL,
resourceID: asrModel,
session: session
)
case .localFallback:
return UnsupportedCloudASRClient(providerId: providerId)
}
@@ -151,25 +190,14 @@ struct ZhipuCloudASRClient: CloudASRTranscribing {
}
}
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
// MARK: - Alibaba Fun-ASR Flash (HTTP sync, context text bias)
/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
struct AlibabaFunASRClient: CloudASRTranscribing {
let apiKey: String
let model: String
let persistence: UserDefaults
let session: URLSession
func prepare(dictionary: PersonalDictionary) async throws {
_ = try await AlibabaVocabularySync.ensureVocabularyID(
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: persistence,
session: session
)
}
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
@@ -179,14 +207,6 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID(
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: persistence,
session: session
)
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
@@ -211,13 +231,10 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
],
])
var parameters: [String: Any] = [
let parameters: [String: Any] = [
"format": "wav",
"sample_rate": "\(sampleRate)",
]
if let vocabularyID, !vocabularyID.isEmpty {
parameters["vocabulary_id"] = vocabularyID
}
let body: [String: Any] = [
"model": model,
@@ -235,11 +252,11 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
if let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return text
}
return text
return ""
}
private static func parseText(from data: Data) -> String? {
@@ -256,7 +273,13 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
}
}
// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom)
// MARK: - Prompt-biased transcription (OpenAI / MiMo / Groq / custom)
enum PromptCloudASRRequestFormat: Sendable {
case multipart
/// OpenRouter expects JSON `{ model, input_audio: { data, format } }`.
case openRouterJson
}
struct PromptCloudASRClient: CloudASRTranscribing {
let providerId: String
@@ -264,6 +287,26 @@ struct PromptCloudASRClient: CloudASRTranscribing {
let apiKey: String
let model: String
let session: URLSession
var requestFormat: PromptCloudASRRequestFormat = .multipart
/// Groq / OpenRouter batch uploads cap around 30 s per request.
private static let whisperCompatibleMaxDurationSeconds: TimeInterval = 30
init(
providerId: String,
baseURL: String,
apiKey: String,
model: String,
session: URLSession,
requestFormat: PromptCloudASRRequestFormat = .multipart
) {
self.providerId = providerId
self.baseURL = baseURL
self.apiKey = apiKey
self.model = model
self.session = session
self.requestFormat = requestFormat
}
func prepare(dictionary: PersonalDictionary) async throws {}
@@ -281,6 +324,13 @@ struct PromptCloudASRClient: CloudASRTranscribing {
dictionary: dictionary
)
}
if requestFormat == .openRouterJson {
return try await transcribeOpenRouterJSON(
samples: samples,
sampleRate: sampleRate,
dictionary: dictionary
)
}
return try await transcribeOpenAIStyle(
samples: samples,
sampleRate: sampleRate,
@@ -288,11 +338,21 @@ struct PromptCloudASRClient: CloudASRTranscribing {
)
}
private func enforceWhisperDuration(samples: [Float], sampleRate: Int) throws {
let duration = Double(samples.count) / Double(sampleRate)
guard duration <= Self.whisperCompatibleMaxDurationSeconds else {
throw CloudASRError.audioTooLong
}
}
private func transcribeOpenAIStyle(
samples: [Float],
sampleRate: Int,
dictionary: PersonalDictionary
) async throws -> String {
if providerId == "groq" || providerId == "openai" || providerId == "custom" {
try enforceWhisperDuration(samples: samples, sampleRate: sampleRate)
}
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let urlString = "\(trimmedBase)/audio/transcriptions"
@@ -335,6 +395,45 @@ struct PromptCloudASRClient: CloudASRTranscribing {
return text
}
private func transcribeOpenRouterJSON(
samples: [Float],
sampleRate: Int,
dictionary: PersonalDictionary
) async throws -> String {
try enforceWhisperDuration(samples: samples, sampleRate: sampleRate)
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let urlString = "\(trimmedBase)/audio/transcriptions"
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
var body: [String: Any] = [
"model": model,
"input_audio": [
"data": wav.base64EncodedString(),
"format": "wav",
],
]
let prompt = dictionary.asrPromptBias(maxCharacters: 600)
if !prompt.isEmpty {
body["prompt"] = prompt
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 90
let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw CloudASRError.emptyTranscript
}
return text
}
private func transcribeMiMo(
samples: [Float],
sampleRate: Int,
@@ -0,0 +1,19 @@
// CloudASRConnectionCheck.swift
// OSGKeyboard · Shared
//
// Settings "validate connection" probe shared by iOS and macOS.
import Foundation
public enum CloudASRConnectionCheck {
/// Verifies the active cloud ASR client can connect + authenticate.
///
/// Each backend decides how to probe (see `CloudASRTranscribing`):
/// HTTP/batch providers transcribe a short silence clip and treat an
/// empty transcript as success; DashScope realtime only handshakes to
/// `task-started` (pushing fake audio makes it fail with `emptyAudio`).
public static func validate(store: any ConfigurationStore) async throws {
let client = CloudASRClientFactory.make(store: store)
try await client.probeConnection()
}
}
@@ -0,0 +1,400 @@
// VolcengineCloudASRClient.swift
// OSGKeyboard · Shared
//
// Volcengine SAUC bigmodel ASR client. The service uses a WebSocket with a
// small custom binary frame wrapper; this file keeps that protocol isolated
// from the HTTP-style cloud ASR clients.
import Foundation
struct VolcengineCloudASRClient: CloudASRTranscribing {
let apiKey: String
let endpoint: String
let resourceID: String
let session: URLSession
private static let targetChunkBytes = 6_400 // 200 ms @ 16 kHz, 16-bit, mono.
private static let finalTimeout: TimeInterval = 12
private static let hotwordCap = 80
func prepare(dictionary: PersonalDictionary) async throws {}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
guard !samples.isEmpty else { throw CloudASRError.emptyTranscript }
let credentials = try VolcengineCredentials.parse(
apiKey: apiKey,
fallbackResourceID: resolvedResourceID
)
let url = try resolvedEndpointURL()
let pcm = Self.pcm16Data(samples: samples)
let connectID = UUID().uuidString
var request = URLRequest(url: url)
request.timeoutInterval = 8
request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key")
request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id")
request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
let task = session.webSocketTask(with: request)
task.resume()
defer {
task.cancel(with: .normalClosure, reason: nil)
}
let firstPayload = try Self.firstFramePayload(connectID: connectID, dictionary: dictionary)
try await send(
VolcengineFrame.build(
messageType: .fullClientRequest,
flags: .positiveSequence,
serialization: .json,
payload: firstPayload,
sequence: 1
),
task: task
)
var sequence = 2
var offset = 0
while offset < pcm.count {
let end = min(offset + Self.targetChunkBytes, pcm.count)
try await send(
VolcengineFrame.build(
messageType: .audioOnlyRequest,
flags: .positiveSequence,
serialization: .none,
payload: pcm.subdata(in: offset..<end),
sequence: Int32(sequence)
),
task: task
)
sequence += 1
offset = end
}
try await send(
VolcengineFrame.build(
messageType: .audioOnlyRequest,
flags: .negativeSequence,
serialization: .none,
payload: Data(),
sequence: -Int32(sequence)
),
task: task
)
let text = try await receiveFinalText(task: task)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CloudASRError.emptyTranscript }
return trimmed
}
private var resolvedResourceID: String {
resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.volcengineDefaultResourceID
: resourceID.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func resolvedEndpointURL() throws -> URL {
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.volcengineEndpoint
: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
return url
}
private func send(_ data: Data, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.data(data))
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
}
private func receiveFinalText(task: URLSessionWebSocketTask) async throws -> String {
try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
var lastPartial = ""
while true {
let message = try await task.receive()
let data: Data
switch message {
case .data(let payload):
data = payload
case .string(let string):
data = Data(string.utf8)
@unknown default:
continue
}
guard let frame = VolcengineFrame.parse(data) else { continue }
if frame.messageType == .errorMessage {
let body = String(data: frame.payload, encoding: .utf8) ?? ""
let code = frame.errorCode ?? 0
throw CloudASRError.transport("ASR error \(code): \(body)")
}
guard frame.messageType == .fullServerResponse else { continue }
let parsedText = Self.text(from: frame.payload)
if !parsedText.isEmpty {
lastPartial = parsedText
}
if frame.isFinal {
return parsedText.isEmpty ? lastPartial : parsedText
}
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(Self.finalTimeout * 1_000_000_000))
throw CloudASRError.transport("Volcengine final result timed out")
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func firstFramePayload(
connectID: String,
dictionary: PersonalDictionary
) throws -> Data {
var request: [String: Any] = [
"model_name": "bigmodel",
"enable_itn": true,
"enable_punc": true,
"show_utterances": true,
"enable_speaker_info": true,
]
if let context = hotwordContext(dictionary: dictionary) {
request["context"] = context
}
let payload: [String: Any] = [
"user": ["uid": connectID],
"audio": [
"format": "pcm",
"rate": 16_000,
"bits": 16,
"channel": 1,
"codec": "raw",
],
"request": request,
]
return try JSONSerialization.data(withJSONObject: payload)
}
private static func hotwordContext(dictionary: PersonalDictionary) -> String? {
var seen: [String] = []
for word in dictionary.asrHotwords() {
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard !seen.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) else {
continue
}
seen.append(trimmed)
if seen.count >= hotwordCap { break }
}
guard !seen.isEmpty else { return nil }
let words = seen.map { ["word": $0] }
guard let data = try? JSONSerialization.data(withJSONObject: ["hotwords": words]) else {
return nil
}
return String(data: data, encoding: .utf8)
}
private static func pcm16Data(samples: [Float]) -> Data {
var data = Data()
data.reserveCapacity(samples.count * 2)
for sample in samples {
let scaled = sample * 32_767.0
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
var littleEndian = Int16(clipped.rounded()).littleEndian
withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) }
}
return data
}
private static func text(from payload: Data) -> String {
guard let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let result = normalizedResult(from: json) else {
return ""
}
if let utterances = result["utterances"] as? [[String: Any]], !utterances.isEmpty {
let pieces = utterances.compactMap { $0["text"] as? String }
let joined = pieces.joined()
if !joined.isEmpty { return joined }
}
return result["text"] as? String ?? ""
}
private static func normalizedResult(from json: [String: Any]) -> [String: Any]? {
if let result = json["result"] as? [String: Any] {
return result
}
if let results = json["result"] as? [[String: Any]] {
return results.first
}
if json["text"] as? String != nil {
return json
}
return nil
}
}
private struct VolcengineCredentials {
let appID: String
let accessToken: String
let resourceID: String
static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials {
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey }
if let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let appID = string(json, keys: ["app_id", "appId", "appid"])
let token = string(json, keys: ["access_token", "accessToken", "token"])
let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"])
?? fallbackResourceID
guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey }
return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID)
}
let separators = CharacterSet(charactersIn: ":\n,")
let parts = trimmed
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard parts.count >= 2 else { throw CloudASRError.noAPIKey }
let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID
return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID)
}
private static func string(_ json: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = json[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
return nil
}
}
private enum VolcengineMessageType: UInt8 {
case fullClientRequest = 0b0001
case audioOnlyRequest = 0b0010
case fullServerResponse = 0b1001
case errorMessage = 0b1111
}
private enum VolcengineFlags: UInt8 {
case none = 0b0000
case positiveSequence = 0b0001
case lastPacket = 0b0010
case negativeSequence = 0b0011
}
private enum VolcengineSerialization: UInt8 {
case none = 0b0000
case json = 0b0001
}
private struct VolcengineFrame {
let messageType: VolcengineMessageType?
let flags: UInt8
let sequence: Int32?
let errorCode: UInt32?
let payload: Data
var isFinal: Bool {
flags == VolcengineFlags.lastPacket.rawValue
|| flags == VolcengineFlags.negativeSequence.rawValue
|| (sequence ?? 0) < 0
}
static func build(
messageType: VolcengineMessageType,
flags: VolcengineFlags,
serialization: VolcengineSerialization,
payload: Data,
sequence: Int32?
) -> Data {
var data = Data()
data.append(0x11)
data.append((messageType.rawValue << 4) | flags.rawValue)
data.append(serialization.rawValue << 4)
data.append(0x00)
if flags == .positiveSequence || flags == .negativeSequence, let sequence {
data.appendBE32(UInt32(bitPattern: sequence))
}
data.appendBE32(UInt32(payload.count))
data.append(payload)
return data
}
static func parse(_ data: Data) -> VolcengineFrame? {
guard data.count >= 8 else { return nil }
let bytes = [UInt8](data)
let headerSize = Int(bytes[0] & 0x0F) * 4
guard headerSize >= 4, data.count >= headerSize + 4 else { return nil }
let typeRaw = (bytes[1] >> 4) & 0x0F
let messageType = VolcengineMessageType(rawValue: typeRaw)
let flags = bytes[1] & 0x0F
let compression = bytes[2] & 0x0F
guard compression == 0 else { return nil }
var offset = headerSize
var sequence: Int32?
if flags == VolcengineFlags.positiveSequence.rawValue
|| flags == VolcengineFlags.negativeSequence.rawValue {
guard let value = data.readBE32(at: offset) else { return nil }
sequence = Int32(bitPattern: value)
offset += 4
}
if messageType == .errorMessage {
guard let code = data.readBE32(at: offset),
let size = data.readBE32(at: offset + 4) else { return nil }
offset += 8
guard data.count >= offset + Int(size) else { return nil }
return VolcengineFrame(
messageType: messageType,
flags: flags,
sequence: sequence,
errorCode: code,
payload: data.subdata(in: offset..<(offset + Int(size)))
)
}
guard let size = data.readBE32(at: offset) else { return nil }
offset += 4
guard data.count >= offset + Int(size) else { return nil }
return VolcengineFrame(
messageType: messageType,
flags: flags,
sequence: sequence,
errorCode: nil,
payload: data.subdata(in: offset..<(offset + Int(size)))
)
}
}
private extension Data {
mutating func appendBE32(_ value: UInt32) {
var bigEndian = value.bigEndian
Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) }
}
func readBE32(at offset: Int) -> UInt32? {
guard count >= offset + 4 else { return nil }
return self[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
}
@@ -93,7 +93,12 @@ public final class AppCloudSync {
let store = makeStore()
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store)
let toggles = ICloudSyncPreferences.load(from: kvs, store: store)
var toggles = ICloudSyncPreferences.load(from: kvs, store: store)
// Merged UI toggle: settings sync implies dictionary sync.
if toggles.settings, !toggles.dictionary {
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
toggles.dictionary = true
}
ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: toggles.settings,
dictionaryEnabled: toggles.dictionary,
@@ -62,9 +62,10 @@ public final class SettingsCloudSync {
public func enableSync() async throws {
let store = makeStore()
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: true,
dictionaryEnabled: store.personalDictionaryICloudSyncEnabled,
dictionaryEnabled: true,
store: store
)
@@ -93,7 +94,9 @@ public final class SettingsCloudSync {
public func disableSync() {
let store = makeStore()
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs)
store.setSettingsICloudSyncEnabled(false)
store.setPersonalDictionaryICloudSyncEnabled(false)
}
public func pullAndMerge(store: AppGroupStore) async {
+16 -1
View File
@@ -107,8 +107,11 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// Mirrored from App Group swaps delete / return on the bottom row.
/// Mirrored from App Group swaps delete / space on the bottom row.
@Published public var handednessPreference: HandednessPreference = .left
/// Mirrors the host field's return-key intent. The action stays a newline
/// insert; host apps decide whether that submits or creates a line break.
@Published public var returnKeyRole: ReturnKeyRole = .newline
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true
/// `true` while a cursor-drag pad is being pressed drives the hint
@@ -151,6 +154,18 @@ public final class KeyboardState: ObservableObject {
case openSettings
}
public enum ReturnKeyRole: Equatable {
case newline
case send
public var titleKey: String {
switch self {
case .newline: return "common.newline"
case .send: return "common.send"
}
}
}
// MARK: - Temporary Flow debug (remove after orange-mic investigation)
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
+171 -4
View File
@@ -61,6 +61,8 @@ public struct OpenAICompatibleClient: LLMClient {
public let baseURL: String
public let apiKey: String
public let model: String
public let providerId: String
public let thinkingEnabled: Bool
public let session: URLSession
/// Canonical request timeout for a single LLM HTTP round-trip. Both
@@ -73,11 +75,15 @@ public struct OpenAICompatibleClient: LLMClient {
baseURL: String,
apiKey: String,
model: String,
providerId: String = "",
thinkingEnabled: Bool = false,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.apiKey = apiKey
self.model = model
self.providerId = providerId
self.thinkingEnabled = thinkingEnabled
self.session = session
}
@@ -107,8 +113,13 @@ public struct OpenAICompatibleClient: LLMClient {
// the baseline when the caller does not supply one.
req.timeoutInterval = timeout ?? requestTimeout
let encoder = JSONEncoder()
req.httpBody = try encoder.encode(request)
req.httpBody = try Self.encodedBody(
request,
providerId: providerId,
baseURL: baseURL,
model: model,
thinkingEnabled: thinkingEnabled
)
do {
let (data, response) = try await session.data(for: req)
@@ -140,6 +151,27 @@ public struct OpenAICompatibleClient: LLMClient {
throw LLMError.transport(String(describing: error))
}
}
private static func encodedBody(
_ request: LLMRequest,
providerId: String,
baseURL: String,
model: String,
thinkingEnabled: Bool
) throws -> Data {
let encoded = try JSONEncoder().encode(request)
guard var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
return encoded
}
LLMThinkingControl.apply(
to: &body,
providerId: providerId,
baseURL: baseURL,
model: model,
enabled: thinkingEnabled
)
return try JSONSerialization.data(withJSONObject: body)
}
}
// MARK: - Factory
@@ -147,13 +179,51 @@ public struct OpenAICompatibleClient: LLMClient {
public enum LLMClientFactory {
/// Build a client from the current `ProviderConfig`.
public static func make(from config: ProviderConfig) -> LLMClient {
OpenAICompatibleClient(
make(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model
model: config.model,
thinkingEnabled: config.llmThinkingEnabled
)
}
/// Provider-aware factory used by `PolishingService`.
public static func make(
providerId: String,
baseURL: String,
apiKey: String,
model: String,
thinkingEnabled: Bool = false,
session: URLSession = .shared
) -> LLMClient {
switch providerId {
case "anthropic":
return AnthropicMessagesClient(apiKey: apiKey, model: model, session: session)
default:
let resolvedBase = resolvedOpenAICompatibleBaseURL(providerId: providerId, baseURL: baseURL)
return OpenAICompatibleClient(
baseURL: resolvedBase,
apiKey: apiKey,
model: model,
providerId: providerId,
thinkingEnabled: thinkingEnabled,
session: session
)
}
}
/// Gemini exposes an OpenAI-compatible shim under `/v1beta/openai`.
private static func resolvedOpenAICompatibleBaseURL(providerId: String, baseURL: String) -> String {
if !baseURL.isEmpty { return baseURL }
switch providerId {
case "gemini":
return "https://generativelanguage.googleapis.com/v1beta/openai"
default:
return baseURL
}
}
/// 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.
@@ -163,3 +233,100 @@ public enum LLMClientFactory {
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout
}
}
// MARK: - Provider-specific thinking controls
//
// Cloud polish defaults to thinking OFF (`llmThinkingEnabled == false`).
// DeepSeek V4 thinking defaults to *enabled* server-side, so we must send an
// explicit `thinking: { type: "disabled" }` merely omitting the field (or
// sending `reasoning_effort: "low"`, which DeepSeek maps to `high`) leaves
// CoT on and makes polish appear stuck.
enum LLMThinkingControl {
static func apply(
to body: inout [String: Any],
providerId: String,
baseURL: String,
model: String,
enabled: Bool
) {
switch control(providerId: providerId, baseURL: baseURL, model: model) {
case .deepSeek:
// Official toggle; do not send reasoning_effort when disabled
// DeepSeek maps low/medium high while thinking stays on.
body["thinking"] = ["type": enabled ? "enabled" : "disabled"]
if enabled {
body["reasoning_effort"] = "high"
} else {
body.removeValue(forKey: "reasoning_effort")
}
case .miniMax:
body["thinking"] = ["type": enabled ? "adaptive" : "disabled"]
case .gemini:
body["thinking_config"] = [
"thinking_budget": enabled ? -1 : 0
]
case .openAIReasoning:
// o-series / gpt-5: only touch the field when the user opts in,
// or when disabling an always-on reasoner with the lowest effort.
if enabled {
body["reasoning_effort"] = "medium"
} else {
body["reasoning_effort"] = "low"
}
case .none:
return
}
}
private enum Control {
/// DeepSeek / Ark: explicit thinking type toggle.
case deepSeek
case miniMax
case gemini
case openAIReasoning
}
private static func control(
providerId: String,
baseURL: String,
model: String
) -> Control? {
switch providerId {
case "deepseek", "ark":
return .deepSeek
case "minimax":
return .miniMax
case "gemini":
return .gemini
case "openai":
return isOpenAIReasoningModel(model) ? .openAIReasoning : nil
default:
return control(baseURL: baseURL, model: model)
}
}
private static func control(baseURL: String, model: String) -> Control? {
let lower = baseURL.lowercased()
if lower.contains("minimax") || lower.contains("minimaxi") {
return .miniMax
}
if lower.contains("generativelanguage.googleapis.com") {
return .gemini
}
// Hosted DeepSeek (SiliconFlow / OpenRouter / custom proxies).
if lower.contains("deepseek") || model.lowercased().contains("deepseek") {
return .deepSeek
}
return nil
}
private static func isOpenAIReasoningModel(_ model: String) -> Bool {
let lower = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return lower.hasPrefix("o1")
|| lower.hasPrefix("o3")
|| lower.hasPrefix("o4")
|| lower.hasPrefix("gpt-5")
|| lower.contains("reasoning")
}
}
@@ -170,7 +170,13 @@ public actor PolishingService {
} else {
apiKey = store.apiKey
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
client = LLMClientFactory.make(
providerId: effectiveProviderId,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: store.llmThinkingEnabled
)
}
let prompt: String
@@ -350,7 +356,7 @@ public actor PolishingService {
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
case "zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo":
return true
default:
return false
@@ -391,7 +397,7 @@ public actor PolishingService {
PreconfiguredKeys.isDeepseekConfigured {
return "deepseek"
}
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id
return id
}
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
@@ -0,0 +1,182 @@
// ProviderModelService.swift
// OSGKeyboard · Shared
//
// Lightweight provider tools used by Settings to validate endpoints and fetch
// model ids without coupling the UI to each vendor's response shape.
import Foundation
public enum ProviderModelServiceError: Error, LocalizedError, Sendable {
case invalidURL
case missingAPIKey
case http(Int)
case empty
case decoding
case transport(String)
public var errorDescription: String? {
switch self {
case .invalidURL:
return SharedL10n.string("providerTools.error.invalidURL")
case .missingAPIKey:
return SharedL10n.string("providerTools.error.missingAPIKey")
case .http(let status):
return SharedL10n.format("providerTools.error.http", status)
case .empty:
return SharedL10n.string("providerTools.error.empty")
case .decoding:
return SharedL10n.string("providerTools.error.decoding")
case .transport:
return SharedL10n.string("providerTools.error.transport")
}
}
}
public enum ProviderModelService {
public static func listLLMModels(
providerId: String,
baseURL: String,
apiKey: String,
currentModel: String,
session: URLSession = .shared
) async throws -> [String] {
if providerId == "anthropic" {
return try await fetchModels(
baseURL: "https://api.anthropic.com/v1",
apiKey: apiKey,
authorization: .anthropic,
session: session
)
}
return try await fetchModels(
baseURL: resolvedLLMBaseURL(providerId: providerId, baseURL: baseURL),
apiKey: apiKey,
authorization: .bearer,
session: session,
fallback: currentModel
)
}
public static func listASRModels(
providerId: String,
baseURL: String,
apiKey: String,
currentModel: String,
session: URLSession = .shared
) async throws -> [String] {
switch CloudASRModelCatalog.strategy(for: providerId) {
case .volcengineStreaming, .bailianStreaming:
return singleModel(currentModel, fallback: CloudASRModelCatalog.defaultModel(for: providerId))
case .localFallback:
return []
case .prompt, .openRouterJson, .zhipuHotwords:
return try await fetchModels(
baseURL: baseURL.isEmpty ? LLMProvider.provider(id: providerId).defaultBaseURL : baseURL,
apiKey: apiKey,
authorization: .bearer,
session: session,
fallback: currentModel
)
}
}
private enum Authorization {
case bearer
case anthropic
}
private static func fetchModels(
baseURL: String,
apiKey: String,
authorization: Authorization,
session: URLSession,
fallback: String = ""
) async throws -> [String] {
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ProviderModelServiceError.missingAPIKey
}
guard let url = URL(string: modelsEndpoint(baseURL: baseURL)) else {
throw ProviderModelServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 12
request.setValue("application/json", forHTTPHeaderField: "Accept")
switch authorization {
case .bearer:
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
case .anthropic:
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw ProviderModelServiceError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw ProviderModelServiceError.http(http.statusCode)
}
let models = try parseModels(from: data)
let resolved = models.isEmpty ? singleModel(fallback, fallback: "") : models
guard !resolved.isEmpty else { throw ProviderModelServiceError.empty }
return resolved
} catch let error as ProviderModelServiceError {
throw error
} catch {
throw ProviderModelServiceError.transport(String(describing: error))
}
}
private static func parseModels(from data: Data) throws -> [String] {
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderModelServiceError.decoding
}
if let data = root["data"] as? [[String: Any]] {
return normalize(data.compactMap { $0["id"] as? String ?? $0["name"] as? String })
}
if let models = root["models"] as? [[String: Any]] {
return normalize(models.compactMap { $0["id"] as? String ?? $0["name"] as? String })
}
if let models = root["models"] as? [String] {
return normalize(models)
}
return []
}
private static func normalize(_ models: [String]) -> [String] {
var seen = Set<String>()
return models
.map { model in
model
.replacingOccurrences(of: "models/", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
.filter { !$0.isEmpty }
.filter { seen.insert($0).inserted }
.sorted()
}
private static func modelsEndpoint(baseURL: String) -> String {
let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasSuffix("/models") { return trimmed }
return trimmed.hasSuffix("/") ? "\(trimmed)models" : "\(trimmed)/models"
}
private static func resolvedLLMBaseURL(providerId: String, baseURL: String) -> String {
if !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return baseURL }
if providerId == "gemini" {
return "https://generativelanguage.googleapis.com/v1beta/openai"
}
return LLMProvider.provider(id: providerId).defaultBaseURL
}
private static func singleModel(_ model: String, fallback: String) -> [String] {
let resolved = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? fallback
: model
return resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [resolved]
}
}
@@ -0,0 +1,130 @@
// ProviderToolRunnerState.swift
// OSGKeyboard · Shared
//
// Pure state machine for Settings provider tool rows (validate / fetch models).
import Foundation
public struct ProviderToolRunnerState: Equatable, Sendable {
public var isRunning: Bool
public var message: String?
public var failed: Bool
public var models: [String]
public init(
isRunning: Bool = false,
message: String? = nil,
failed: Bool = false,
models: [String] = []
) {
self.isRunning = isRunning
self.message = message
self.failed = failed
self.models = models
}
}
@MainActor
public enum ProviderToolRunner {
public static func runValidate(
runningMessage: String,
successMessage: String,
validate: () async throws -> Void
) async -> ProviderToolRunnerState {
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
do {
try await validate()
state.isRunning = false
state.message = successMessage
state.failed = false
} catch {
state.isRunning = false
state.failed = true
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
return state
}
public static func runFetchModels(
runningMessage: String,
loadedMessage: (Int) -> String,
emptyMessage: String,
currentModel: String,
fetchModels: () async throws -> [String]
) async -> (state: ProviderToolRunnerState, selectedModel: String?) {
var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false)
do {
let fetched = try await fetchModels()
guard !fetched.isEmpty else {
state.isRunning = false
state.failed = true
state.message = emptyMessage
state.models = []
return (state, nil)
}
var resolved = fetched
let trimmed = currentModel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !resolved.contains(trimmed) {
resolved.insert(trimmed, at: 0)
}
state.models = resolved
state.isRunning = false
state.failed = false
state.message = loadedMessage(resolved.count)
let selected: String?
if trimmed.isEmpty, let first = resolved.first {
selected = first
} else {
selected = nil
}
return (state, selected)
} catch {
state.isRunning = false
state.failed = true
state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)"
state.models = []
return (state, nil)
}
}
}
public enum HardTimeout {
/// Returns the first completed result; the losing task is cancelled.
public static func run<T: Sendable>(
seconds: TimeInterval,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw CancellationError()
}
guard let result = try await group.next() else {
throw CancellationError()
}
group.cancelAll()
return result
}
}
/// Non-throwing variant for tasks that should fall back when time elapses.
public static func value<T: Sendable>(
seconds: TimeInterval,
operation: @escaping @Sendable () async -> T,
onTimeout: @escaping @Sendable () -> T
) async -> T {
await withTaskGroup(of: T.self) { group in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
return onTimeout()
}
let result = await group.next() ?? onTimeout()
group.cancelAll()
return result
}
}
}
@@ -14,6 +14,13 @@ public final class UsageStatisticsStore: ObservableObject {
@Published public private(set) var dictationDurationSeconds: TimeInterval = 0
@Published public private(set) var dictationCharacterCount: Int = 0
@Published public private(set) var translationCharacterCount: Int = 0
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
/// the home page's 7-day chart.
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
/// chart window so a device that syncs in late still contributes recent days.
private static let dailyRetentionDays = 90
public let defaults: UserDefaults
@@ -53,6 +60,9 @@ public final class UsageStatisticsStore: ObservableObject {
slice.translationCharacterCount += count
} else {
slice.dictationCharacterCount += count
let dayKey = UsageStatisticsDayKey.key(for: Date())
slice.dailyDictationCharacters[dayKey, default: 0] += count
UsageStatisticsDayKey.prune(&slice.dailyDictationCharacters, keepingDays: Self.dailyRetentionDays)
}
slice.dictationDurationSeconds += max(0, duration)
slice.updatedAt = Date()
@@ -69,10 +79,42 @@ public final class UsageStatisticsStore: ObservableObject {
/// aggregated cross-device sum and NEVER writes it back (writing would
/// corrupt the per-device slices see `recordUtterance`).
public func reloadFromDisk() {
let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
let payload = SyncedUsageStatisticsStorage.load(from: defaults)
let aggregated = payload.aggregated
dictationDurationSeconds = aggregated.dictationDurationSeconds
dictationCharacterCount = aggregated.dictationCharacterCount
translationCharacterCount = aggregated.translationCharacterCount
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
}
// MARK: - 7-day chart data
/// One day's dictation total for the home page chart.
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
public let date: Date
public let value: Int
public var id: Date { date }
}
/// The trailing 7 local days (oldest newest), zero-filled for days with no
/// dictation, so the chart always renders a full week.
public var last7Days: [DailyUsagePoint] {
Self.last7Days(from: dailyDictationCharacters)
}
public static func last7Days(
from daily: [String: Int],
now: Date = Date(),
calendar: Calendar = .current
) -> [DailyUsagePoint] {
let startOfToday = calendar.startOfDay(for: now)
var points: [DailyUsagePoint] = []
for offset in stride(from: 6, through: 0, by: -1) {
guard let day = calendar.date(byAdding: .day, value: -offset, to: startOfToday) else { continue }
let key = UsageStatisticsDayKey.key(for: day, calendar: calendar)
points.append(DailyUsagePoint(date: day, value: daily[key] ?? 0))
}
return points
}
/// One-time cleanup: the pre-fix code overwrote a device slice with the