Files
OSGKeyboard/OSGKeyboardShared/Services/SearchAugmentedChatClient.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

213 lines
7.2 KiB
Swift

// SearchAugmentedChatClient.swift
// OSGKeyboard · Shared
//
// Chat Completions client that injects provider-specific web-search fields
// (Qwen `enable_search`, Zhipu `tools.web_search`, Moonshot `$web_search`).
import Foundation
/// Provider-specific Chat Completions extras for AI-mode web search.
/// Kept as an enum so the client stays `Sendable` (no `[String: Any]` storage).
public enum SearchBodyAugmentation: Sendable, Equatable {
case qwenEnableSearch
case zhipuWebSearch
case moonshotBuiltinWebSearch
func apply(to body: inout [String: Any]) {
switch self {
case .qwenEnableSearch:
body["enable_search"] = true
case .zhipuWebSearch:
body["tools"] = [
[
"type": "web_search",
"web_search": ["enable": true]
]
]
case .moonshotBuiltinWebSearch:
body["tools"] = [
[
"type": "builtin_function",
"function": ["name": "$web_search"]
]
]
}
}
}
public struct SearchAugmentedChatClient: LLMClient {
public let baseURL: String
public let apiKey: String
public let model: String
public let providerId: String
public let augmentation: SearchBodyAugmentation
public let session: URLSession
public let requestTimeout: TimeInterval = 15
public init(
baseURL: String,
apiKey: String,
model: String,
providerId: String,
augmentation: SearchBodyAugmentation,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.apiKey = apiKey
self.model = model
self.providerId = providerId
self.augmentation = augmentation
self.session = session
}
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
try await complete(
messages: [.system(systemPrompt), .user(text)],
timeout: timeout,
options: .polishDefault
)
}
public 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
)
}
public func complete(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
let req = try makeSearchChatRequest(
messages: messages,
timeout: timeout,
options: options,
stream: false
)
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) {
LLMHTTPDiagnostics.logFailure(
providerId: providerId,
statusCode: http.statusCode,
responseByteCount: data.count,
response: http
)
if http.statusCode == 429 { throw LLMError.rateLimited }
throw LLMError.http(status: http.statusCode)
}
let decoded = try JSONDecoder().decode(LLMResponse.self, from: data)
return decoded.content.trimmingCharacters(in: .whitespacesAndNewlines)
} 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))
}
}
public func completeStreaming(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) -> AsyncThrowingStream<LLMStreamEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let req = try makeSearchChatRequest(
messages: messages,
timeout: timeout,
options: options,
stream: true
)
for try await event in LLMStreamingSession.mapSSE(
session: session,
request: req,
providerId: providerId,
parse: LLMStreamDeltaParser.chatCompletionsDelta(from:)
) {
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() }
}
}
private func makeSearchChatRequest(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions,
stream: Bool
) throws -> URLRequest {
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 omitSampling = LLMThinkingControl.shouldOmitSamplingParameters(
providerId: providerId,
baseURL: baseURL,
model: model,
thinkingEnabled: true
)
let request = LLMRequest(
model: model,
messages: messages,
temperature: omitSampling ? nil : options.temperature,
maxTokens: options.maxTokens ?? LLMRequest.outputTokenLimit(
for: messages.map(\.content).joined(separator: "\n")
),
topP: omitSampling ? nil : options.topP
)
let encoded = try JSONEncoder().encode(request)
guard var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
throw LLMError.decoding("chat body")
}
LLMThinkingControl.apply(
to: &body,
providerId: providerId,
baseURL: baseURL,
model: model,
enabled: true
)
augmentation.apply(to: &body)
if stream {
body["stream"] = true
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.timeoutInterval = timeout ?? requestTimeout
req.httpBody = try JSONSerialization.data(withJSONObject: body)
return req
}
}