feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// AIModeLLMClientFactory.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// AI-keyboard LLM transport: prefer each provider's richest server-side
|
||||
// web-search path, then silently fall back to plain completion. Dictation
|
||||
// polish keeps using `LLMClientFactory` and never opts into search.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIModeLLMClientFactory {
|
||||
/// Build an AI-mode client. Thinking is always forced on for this path.
|
||||
/// When `allowWebSearch` is false, returns the plain polish-compatible client.
|
||||
public static func make(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
allowWebSearch: Bool = true,
|
||||
session: URLSession = .shared
|
||||
) -> any LLMClient {
|
||||
let plain = LLMClientFactory.make(
|
||||
providerId: providerId,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
thinkingEnabled: true,
|
||||
session: session
|
||||
)
|
||||
guard allowWebSearch else { return plain }
|
||||
|
||||
guard let searching = makeSearchingClient(
|
||||
providerId: providerId,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
session: session
|
||||
) else {
|
||||
return plain
|
||||
}
|
||||
|
||||
return AIModeSearchFallbackClient(primary: searching, fallback: plain)
|
||||
}
|
||||
|
||||
/// Providers with a documented server-side search path. Others stay on plain complete.
|
||||
private static func makeSearchingClient(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession
|
||||
) -> (any LLMClient)? {
|
||||
switch providerId {
|
||||
case "deepseek":
|
||||
guard AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: model) else {
|
||||
return nil
|
||||
}
|
||||
return ResponsesAPILLMClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
reasoningEffort: "high",
|
||||
session: session
|
||||
)
|
||||
case "openai", "xai":
|
||||
return ResponsesAPILLMClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
reasoningEffort: "medium",
|
||||
session: session
|
||||
)
|
||||
case "qwen", "alibabaCoding":
|
||||
return SearchAugmentedChatClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
augmentation: .qwenEnableSearch,
|
||||
session: session
|
||||
)
|
||||
case "zhipu":
|
||||
return SearchAugmentedChatClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
augmentation: .zhipuWebSearch,
|
||||
session: session
|
||||
)
|
||||
case "anthropic":
|
||||
return AnthropicMessagesClient(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
session: session,
|
||||
webSearchEnabled: true,
|
||||
thinkingEnabled: true
|
||||
)
|
||||
case "moonshot":
|
||||
// Kimi builtin `$web_search` via tools; degrade if the account/model rejects it.
|
||||
return SearchAugmentedChatClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
augmentation: .moonshotBuiltinWebSearch,
|
||||
session: session
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AIModeSearchSupport {
|
||||
static func deepSeekSupportsResponsesSearch(model: String) -> Bool {
|
||||
let lower = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return lower == "deepseek-v4-flash"
|
||||
|| lower.hasPrefix("deepseek-v4-flash")
|
||||
|| lower.contains("v4-flash")
|
||||
}
|
||||
}
|
||||
|
||||
/// Try the search-capable client once; on any failure retry plain completion once.
|
||||
struct AIModeSearchFallbackClient: LLMClient {
|
||||
let primary: any LLMClient
|
||||
let fallback: any LLMClient
|
||||
|
||||
var requestTimeout: TimeInterval { primary.requestTimeout }
|
||||
|
||||
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
|
||||
try await complete(
|
||||
messages: [.system(systemPrompt), .user(text)],
|
||||
timeout: timeout,
|
||||
options: .polishDefault
|
||||
)
|
||||
}
|
||||
|
||||
func polish(
|
||||
_ text: String,
|
||||
systemPrompt: String,
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions
|
||||
) async throws -> String {
|
||||
try await complete(
|
||||
messages: [.system(systemPrompt), .user(text)],
|
||||
timeout: timeout,
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
func complete(
|
||||
messages: [LLMRequest.Message],
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions
|
||||
) async throws -> String {
|
||||
do {
|
||||
return try await primary.complete(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options
|
||||
)
|
||||
} catch is CancellationError {
|
||||
throw LLMError.cancelled
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
throw LLMError.cancelled
|
||||
} catch let error as LLMError where error == .cancelled {
|
||||
throw error
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AIMode] search path failed, retrying without search: \(error)")
|
||||
#endif
|
||||
return try await fallback.complete(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func completeStreaming(
|
||||
messages: [LLMRequest.Message],
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions
|
||||
) -> AsyncThrowingStream<LLMStreamEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
for try await event in primary.completeStreaming(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options
|
||||
) {
|
||||
continuation.yield(event)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: LLMError.cancelled)
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
continuation.finish(throwing: LLMError.cancelled)
|
||||
} catch let error as LLMError where error == .cancelled {
|
||||
continuation.finish(throwing: error)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AIMode] search stream failed, retrying without search: \(error)")
|
||||
#endif
|
||||
// Drop any search-path draft before the plain retry.
|
||||
continuation.yield(.restart)
|
||||
do {
|
||||
for try await event in fallback.completeStreaming(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options
|
||||
) {
|
||||
continuation.yield(event)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: LLMError.cancelled)
|
||||
} catch let error as LLMError {
|
||||
continuation.finish(throwing: error)
|
||||
} catch {
|
||||
continuation.finish(throwing: LLMError.transport(String(describing: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user