Files
hyungi_document_server/Tests/AITests/MockURLProtocol.swift
T
hyungi 5383a93f98 feat(ai-fabric): S2 LLM 패브릭 4 provider 결선 + 컴포지션 루트
risk-first 채움(RemoteDS→LocalMLX→OnDevice→Specialized) + makeDefaultRouter 컴포지션 루트.
동결 인터페이스(AIProvider/AIRouter/MockAIProvider) 무변경. SPM AIFabric 단독 빌드·테스트(46 PASS).

- RemoteDS: DSAskClient seam + AskResponse(ask.json) 매핑 + backend exhaustive switch(qwen/cloud TODO)
- LocalMLX: GET /v1/models probe + OpenAI /v1/chat/completions system/user call-shape + non-200 backendError
- OnDevice: FoundationModels 라이브(M5 Max) availability + respond() + GenerationError 9-case 매핑 + stateless/prewarm
- Specialized: scaffold-only(명시 unavailable, vision 폴백 가시화), cloud='claude-cloud' 503
- config 단일소스(env override) + 타임아웃/취소(URLSession 자동 honor, OnDevice 협조적)

실측 동결(S2-3a, M5 Max): availability=available · 취소=COOPERATIVE(~33ms) · 오버플로=exceededContextWindowSize
  · GenerationError 9-case(refusal·concurrentRequests 추가 발견, plan 정정).
한계: LocalMLX fixture=PROVISIONAL_SYNTHETIC(맥미니 offline → 라이브 재캡처 S2-Ff 대기).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:20:10 +09:00

87 lines
3.2 KiB
Swift

import Foundation
/// URLProtocol URLSession canned / , .
/// 0 LocalMLX probe/complete call-shape .
final class MockURLProtocol: URLProtocol {
/// (request) -> (response, body). throw URLSession .
nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
/// (body ) .
nonisolated(unsafe) static var recorder = RequestRecorder()
static func reset() {
handler = nil
recorder = RequestRecorder()
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.recorder.record(request)
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
// MARK: helpers
static func session() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
static func ok(_ url: URL, json: Data) -> (HTTPURLResponse, Data) {
(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, json)
}
static func status(_ url: URL, _ code: Int, body: String = "") -> (HTTPURLResponse, Data) {
(HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil)!, Data(body.utf8))
}
}
/// (body httpBody httpBodyStream URLProtocol stream ).
final class RequestRecorder: @unchecked Sendable {
private(set) var lastURL: URL?
private(set) var lastMethod: String?
private(set) var lastBody: Data?
private(set) var callCount = 0
func record(_ request: URLRequest) {
callCount += 1
lastURL = request.url
lastMethod = request.httpMethod
lastBody = request.bodyData
}
}
extension URLRequest {
/// URLProtocol body httpBody nil httpBodyStream .
var bodyData: Data? {
if let httpBody { return httpBody }
guard let stream = httpBodyStream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
let bufSize = 8192
var buffer = [UInt8](repeating: 0, count: bufSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufSize)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}