diff --git a/Sources/AppFeature/AI/AppAIComposition.swift b/Sources/AppFeature/AI/AppAIComposition.swift index 84b1bc1..b957a7d 100644 --- a/Sources/AppFeature/AI/AppAIComposition.swift +++ b/Sources/AppFeature/AI/AppAIComposition.swift @@ -1,5 +1,6 @@ import Foundation import AIFabric +import DSKit /// The ONE composition touch-point that names MockAIProvider. When S2 ships real providers, /// only this file changes (mockProviders -> realProviders) — AIService, views, and intents stay put. @@ -23,4 +24,14 @@ public enum AppAIComposition { } ) } + + /// FU-B seam: the real S2 fabric (RemoteDS/LocalMLX/OnDevice/Specialized) via `makeDefaultRouter`, + /// fed S3's concrete `LiveDSAskClient`. The app default stays `mockRouter` (offline scaffold); + /// switching to live is THIS one call — AIService, views, and intents are unchanged. + public static func realRouter( + base: DSBaseURL = .publicTLS, + token: @escaping @Sendable () async -> String? = { nil } + ) -> AIRouter { + makeDefaultRouter(client: LiveDSAskClient(base: base, token: token)) + } } diff --git a/Sources/AppFeature/AI/LiveDSAskClient.swift b/Sources/AppFeature/AI/LiveDSAskClient.swift new file mode 100644 index 0000000..f18ae9b --- /dev/null +++ b/Sources/AppFeature/AI/LiveDSAskClient.swift @@ -0,0 +1,50 @@ +import Foundation +import AIFabric +import DSKit + +/// S3-owned concrete `DSAskClient` (the HTTP seam S2's `RemoteDSProvider` calls). Hits DS +/// `GET /search/ask?q=&backend=` and decodes **AIFabric.AskResponse** (a decode-only type that +/// RemoteDSProvider maps to AICompletionResponse). This is the piece S2's plan assigns to S3 +/// ("구체 DSAskClient(HTTP) = S3 소유"). FU-A: exercised only when the real router is wired. +public struct LiveDSAskClient: DSAskClient { + private let base: DSBaseURL + private let session: URLSession + private let decoder: JSONDecoder + private let token: @Sendable () async -> String? + + public init( + base: DSBaseURL = .publicTLS, + session: URLSession = .shared, + token: @escaping @Sendable () async -> String? = { nil } + ) { + self.base = base + self.session = session + self.decoder = DSDecoder.make() + self.token = token + } + + public func ask(query: String, backend: String) async throws -> AIFabric.AskResponse { + let raw = base.url.absoluteString + "/search/ask" + guard var comps = URLComponents(string: raw) else { + throw DSError.transport(underlying: "bad ask URL") + } + comps.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "backend", value: backend), + ] + guard let url = comps.url else { throw DSError.transport(underlying: "bad ask URL components") } + + var request = URLRequest(url: url) + if let token = await token() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw DSError.transport(underlying: "no HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw DSError.from(status: http.statusCode, data: data) + } + return try decoder.decode(AIFabric.AskResponse.self, from: data) + } +} diff --git a/Sources/DSKit/DSError.swift b/Sources/DSKit/DSError.swift index 300e2b0..26633ba 100644 --- a/Sources/DSKit/DSError.swift +++ b/Sources/DSKit/DSError.swift @@ -19,7 +19,7 @@ public enum DSError: Error, Sendable { return false } - static func from(status: Int, data: Data) -> DSError { + public static func from(status: Int, data: Data) -> DSError { let body = DSErrorBody.parse(data) switch status { case 401: return .unauthorized(message: body?.message)