17f8830d37
S1 = contract/CONTRACT.md + 14 fixtures + README + AI-ROUTING.
S2 = Sources/AI/{AIProvider,AIRouter,MockAIProvider} + Providers skeletons.
Baseline before S3 (device app) scaffold work begins.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
4.1 KiB
Swift
100 lines
4.1 KiB
Swift
// AIRouter.swift — 태스크 → provider 라우팅 정책 (3단 fallback: explicit > rule > error)
|
|
//
|
|
// 정책 = feedback_task_routing_hybrid + feedback_no_silent_fallback_explicit_opt_in:
|
|
// 1) 명시 provider 지정 → 그것만, 불가 시 **에러**(자동 fallback 금지).
|
|
// 2) 미지정 → 태스크별 선호 체인(rule), 정의된 fallback 은 routingNote 로 가시화.
|
|
// 3) 전부 불가 → noProviderAvailable.
|
|
|
|
import Foundation
|
|
|
|
public struct AIRoutingPolicy: Sendable {
|
|
/// 태스크별 provider 선호 순서. 앞쪽 우선, 불가/실패 시 다음으로(가시 fallback).
|
|
public var preference: [AITask: [AIProviderID]]
|
|
|
|
public init(preference: [AITask: [AIProviderID]]) {
|
|
self.preference = preference
|
|
}
|
|
|
|
public func chain(for task: AITask) -> [AIProviderID] {
|
|
preference[task] ?? [.onDevice, .localMLX, .remoteDS]
|
|
}
|
|
|
|
/// 기본 정책 — 디바이스 역할 표와 일치.
|
|
/// 빠른/사적 = 온디바이스 우선, 무거운 생성 = 맥미니, 코퍼스 = 원격 DS, 비전 = GPU 특화.
|
|
public static let `default` = AIRoutingPolicy(preference: [
|
|
.quickSummarize: [.onDevice, .localMLX],
|
|
.memoAssist: [.onDevice, .localMLX],
|
|
.askSelection: [.onDevice, .localMLX, .remoteDS],
|
|
.corpusAsk: [.remoteDS], // 코퍼스는 원격만 — 온디바이스로 폴백 불가
|
|
.classify: [.localMLX, .remoteDS, .onDevice],
|
|
.vision: [.specialized, .onDevice],
|
|
])
|
|
}
|
|
|
|
public enum AIRoutingError: Error, Sendable {
|
|
case providerNotConfigured(AIProviderID)
|
|
case explicitProviderUnavailable(AIProviderID) // 명시 지정인데 불가 → 자동 fallback 안 함
|
|
case noProviderAvailable(AITask)
|
|
}
|
|
|
|
public struct AIRouter: Sendable {
|
|
public let providers: [AIProviderID: any AIProvider]
|
|
public let policy: AIRoutingPolicy
|
|
/// 라우팅 로그 훅 (silent skip 방지 — 가시 fallback). 기본은 무시.
|
|
public let log: @Sendable (String) -> Void
|
|
|
|
public init(
|
|
providers: [AIProviderID: any AIProvider],
|
|
policy: AIRoutingPolicy = .default,
|
|
log: @escaping @Sendable (String) -> Void = { _ in }
|
|
) {
|
|
self.providers = providers
|
|
self.policy = policy
|
|
self.log = log
|
|
}
|
|
|
|
public func route(_ request: AICompletionRequest) async throws -> AICompletionResponse {
|
|
// 1) 명시 opt-in — 자동 fallback 금지.
|
|
if let explicit = request.explicitProvider {
|
|
guard let provider = providers[explicit] else {
|
|
throw AIRoutingError.providerNotConfigured(explicit)
|
|
}
|
|
guard await provider.isAvailable else {
|
|
log("explicit provider \(explicit.rawValue) unavailable — no fallback (opt-in)")
|
|
throw AIRoutingError.explicitProviderUnavailable(explicit)
|
|
}
|
|
return try await provider.complete(request)
|
|
}
|
|
|
|
// 2) rule-based 선호 체인 — 정의된 fallback 은 routingNote 로 가시화.
|
|
let chain = policy.chain(for: request.task)
|
|
var attempted: [AIProviderID] = []
|
|
var lastError: Error?
|
|
|
|
for id in chain {
|
|
guard let provider = providers[id] else { continue }
|
|
guard await provider.isAvailable else {
|
|
log("provider \(id.rawValue) unavailable for \(request.task.rawValue) — trying next")
|
|
attempted.append(id)
|
|
continue
|
|
}
|
|
do {
|
|
var response = try await provider.complete(request)
|
|
if !attempted.isEmpty {
|
|
response.routingNote = "fallback from \(attempted.map(\.rawValue).joined(separator: ",")) → \(id.rawValue)"
|
|
}
|
|
return response
|
|
} catch {
|
|
log("provider \(id.rawValue) failed for \(request.task.rawValue): \(error) — trying next")
|
|
attempted.append(id)
|
|
lastError = error
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 3) 전부 불가.
|
|
if let lastError { throw lastError }
|
|
throw AIRoutingError.noProviderAvailable(request.task)
|
|
}
|
|
}
|