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:
@@ -1,7 +1,7 @@
|
||||
// AnthropicLLMClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Anthropic Messages API client for polish / translation prompts.
|
||||
// Anthropic Messages API client for polish / translation / AI-mode prompts.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -9,16 +9,22 @@ public struct AnthropicMessagesClient: LLMClient {
|
||||
public let apiKey: String
|
||||
public let model: String
|
||||
public let session: URLSession
|
||||
public let webSearchEnabled: Bool
|
||||
public let thinkingEnabled: Bool
|
||||
public let requestTimeout: TimeInterval = 15
|
||||
|
||||
public init(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
session: URLSession = .shared
|
||||
session: URLSession = .shared,
|
||||
webSearchEnabled: Bool = false,
|
||||
thinkingEnabled: Bool = false
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
self.webSearchEnabled = webSearchEnabled
|
||||
self.thinkingEnabled = thinkingEnabled
|
||||
}
|
||||
|
||||
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
|
||||
@@ -36,31 +42,27 @@ public struct AnthropicMessagesClient: LLMClient {
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
||||
|
||||
let url = URL(string: "https://api.anthropic.com/v1/messages")!
|
||||
var body: [String: Any] = [
|
||||
"model": model,
|
||||
"max_tokens": options.maxTokens ?? LLMRequest.outputTokenLimit(for: text),
|
||||
"system": systemPrompt,
|
||||
"messages": [
|
||||
["role": "user", "content": text],
|
||||
try await complete(
|
||||
messages: [
|
||||
.system(systemPrompt),
|
||||
.user(text),
|
||||
],
|
||||
]
|
||||
if let temperature = options.temperature {
|
||||
body["temperature"] = temperature
|
||||
}
|
||||
if let topP = options.topP {
|
||||
body["top_p"] = topP
|
||||
}
|
||||
timeout: timeout,
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
public func complete(
|
||||
messages: [LLMRequest.Message],
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions
|
||||
) async throws -> String {
|
||||
let request = try makeMessagesRequest(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options,
|
||||
stream: false
|
||||
)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
@@ -72,24 +74,130 @@ public struct AnthropicMessagesClient: LLMClient {
|
||||
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 {
|
||||
let content = json["content"] as? [[String: Any]] else {
|
||||
throw LLMError.decoding("anthropic content")
|
||||
}
|
||||
let textBlocks = content.compactMap { block -> String? in
|
||||
guard (block["type"] as? String) == "text",
|
||||
let text = block["text"] as? String else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
let joined = textBlocks.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !joined.isEmpty else {
|
||||
throw LLMError.decoding("anthropic text")
|
||||
}
|
||||
let usage = json["usage"] as? [String: Any]
|
||||
LLMCacheMetricsStore.record(
|
||||
providerId: "anthropic",
|
||||
promptTokens: usage?["input_tokens"] as? Int,
|
||||
cachedTokens: usage?["cache_read_input_tokens"] as? Int
|
||||
)
|
||||
return textBlock.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return joined
|
||||
} 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 request = try makeMessagesRequest(
|
||||
messages: messages,
|
||||
timeout: timeout,
|
||||
options: options,
|
||||
stream: true
|
||||
)
|
||||
for try await event in LLMStreamingSession.mapSSE(
|
||||
session: session,
|
||||
request: request,
|
||||
parse: LLMStreamDeltaParser.anthropicTextDelta(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 makeMessagesRequest(
|
||||
messages: [LLMRequest.Message],
|
||||
timeout: TimeInterval?,
|
||||
options: LLMGenerationOptions,
|
||||
stream: Bool
|
||||
) throws -> URLRequest {
|
||||
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
||||
|
||||
let url = URL(string: "https://api.anthropic.com/v1/messages")!
|
||||
let systemPrompt = messages.first(where: { $0.role == "system" })?.content ?? ""
|
||||
let conversation = messages
|
||||
.filter { $0.role != "system" }
|
||||
.map { ["role": $0.role, "content": $0.content] }
|
||||
let combinedText = messages.map(\.content).joined(separator: "\n")
|
||||
let answerTokens = options.maxTokens ?? LLMRequest.outputTokenLimit(for: combinedText)
|
||||
let thinkingBudget = 4_000
|
||||
var body: [String: Any] = [
|
||||
"model": model,
|
||||
// Anthropic requires max_tokens > thinking.budget_tokens.
|
||||
"max_tokens": thinkingEnabled ? answerTokens + thinkingBudget : answerTokens,
|
||||
"system": systemPrompt,
|
||||
"messages": conversation,
|
||||
]
|
||||
if thinkingEnabled {
|
||||
// Extended thinking; sampling knobs are ignored while thinking runs.
|
||||
body["thinking"] = [
|
||||
"type": "enabled",
|
||||
"budget_tokens": thinkingBudget,
|
||||
]
|
||||
} else {
|
||||
if let temperature = options.temperature {
|
||||
body["temperature"] = temperature
|
||||
}
|
||||
if let topP = options.topP {
|
||||
body["top_p"] = topP
|
||||
}
|
||||
}
|
||||
if webSearchEnabled {
|
||||
// Basic server-side search; newer tool revisions also work when the account allows.
|
||||
body["tools"] = [
|
||||
[
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
"max_uses": 3,
|
||||
],
|
||||
]
|
||||
}
|
||||
if stream {
|
||||
body["stream"] = true
|
||||
}
|
||||
|
||||
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)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user