Files
OSGKeyboard/OSGKeyboardShared/Services/ProviderModelService.swift
T
Rocky d656bac8c3 feat: add streaming cloud ASR, polish routing, and settings card layout
Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
2026-07-28 20:25:17 +08:00

183 lines
6.7 KiB
Swift

// 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, .openaiRealtimeStreaming:
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]
}
}