@dvai-bridge/ios-shared-core 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +51 -0
- package/Package.swift +62 -0
- package/README.md +199 -0
- package/ios/Sources/DVAISharedCore/HandlerContext.swift +121 -0
- package/ios/Sources/DVAISharedCore/HandlerDispatch.swift +132 -0
- package/ios/Sources/DVAISharedCore/HttpServer.swift +469 -0
- package/ios/Tests/DVAISharedCoreTests/HandlerDispatchTest.swift +160 -0
- package/ios/Tests/DVAISharedCoreTests/HttpServerIntegrationTest.swift +163 -0
- package/ios/Tests/DVAISharedCoreTests/HttpServerTest.swift +94 -0
- package/package.json +18 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// Internal/HttpServer.swift
|
|
2
|
+
import Foundation
|
|
3
|
+
#if !COCOAPODS
|
|
4
|
+
import Hummingbird
|
|
5
|
+
import HTTPTypes
|
|
6
|
+
import NIOCore
|
|
7
|
+
import NIOPosix
|
|
8
|
+
import ServiceLifecycle
|
|
9
|
+
#else
|
|
10
|
+
import Telegraph
|
|
11
|
+
#endif
|
|
12
|
+
|
|
13
|
+
/// Wraps a Hummingbird `Application` with port-fallback bind logic.
|
|
14
|
+
///
|
|
15
|
+
/// We migrated off Telegraph in v3.2.0 — Telegraph 0.40 buffers SSE
|
|
16
|
+
/// response bodies server-side, and its private `HTTPParserC` clang
|
|
17
|
+
/// module collides with `swift-nio`'s `CNIOLLHTTP` whenever a downstream
|
|
18
|
+
/// target imports both. Hummingbird (built on swift-nio) gives us
|
|
19
|
+
/// proper streaming SSE plus a single, consistent C-module footprint
|
|
20
|
+
/// across DVAISharedCore and DVAIBridge.
|
|
21
|
+
///
|
|
22
|
+
/// Public lifecycle (unchanged from the Telegraph era so existing
|
|
23
|
+
/// PluginState call sites compile without edits):
|
|
24
|
+
///
|
|
25
|
+
/// ```swift
|
|
26
|
+
/// let server = HttpServer()
|
|
27
|
+
/// await server.installRoutes(handlers: ..., ctx: ..., corsConfig: ...)
|
|
28
|
+
/// let port = try await server.tryBind(basePort: 38883, maxAttempts: 16, host: "127.0.0.1")
|
|
29
|
+
/// // …
|
|
30
|
+
/// await server.stop()
|
|
31
|
+
/// ```
|
|
32
|
+
///
|
|
33
|
+
/// Hummingbird requires the router at `Application` construction time,
|
|
34
|
+
/// so `installRoutes` captures the handlers/ctx/cors triple and defers
|
|
35
|
+
/// the actual `Application` build to `tryBind`.
|
|
36
|
+
|
|
37
|
+
#if !COCOAPODS
|
|
38
|
+
public actor HttpServer {
|
|
39
|
+
|
|
40
|
+
/// Captured handler triple — populated by `installRoutes`,
|
|
41
|
+
/// consumed by `tryBind`.
|
|
42
|
+
private struct PendingRoutes {
|
|
43
|
+
let handlers: DVAIHandlers
|
|
44
|
+
let ctx: HandlerContext
|
|
45
|
+
let corsConfig: CORSConfig
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Live application + service-group handle. `nil` before the
|
|
49
|
+
/// first successful `tryBind` and after `stop()`.
|
|
50
|
+
private struct Live {
|
|
51
|
+
let port: Int
|
|
52
|
+
let serviceGroup: ServiceGroup
|
|
53
|
+
let runTask: Task<Void, Error>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private var pending: PendingRoutes?
|
|
57
|
+
private var live: Live?
|
|
58
|
+
|
|
59
|
+
/// Most recently bound port. `nil` before bind / after stop.
|
|
60
|
+
public var boundPort: Int? { live?.port }
|
|
61
|
+
|
|
62
|
+
public init() {}
|
|
63
|
+
|
|
64
|
+
/// Capture the handler / context / CORS triple. Must be called
|
|
65
|
+
/// before `tryBind`. Idempotent — the most recent call wins.
|
|
66
|
+
public func installRoutes(
|
|
67
|
+
handlers: DVAIHandlers,
|
|
68
|
+
ctx: HandlerContext,
|
|
69
|
+
corsConfig: CORSConfig
|
|
70
|
+
) {
|
|
71
|
+
self.pending = PendingRoutes(handlers: handlers, ctx: ctx, corsConfig: corsConfig)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Try to bind to `basePort`, falling back to `basePort+1`, ..., up
|
|
75
|
+
/// to `maxAttempts` ports. Returns the port that bound successfully.
|
|
76
|
+
/// Throws if all ports in the range are unavailable, or if
|
|
77
|
+
/// `installRoutes` was never called.
|
|
78
|
+
public func tryBind(basePort: Int, maxAttempts: Int, host: String) async throws -> Int {
|
|
79
|
+
guard let pending = self.pending else {
|
|
80
|
+
throw NSError(
|
|
81
|
+
domain: "DVAIBridge.HttpServer",
|
|
82
|
+
code: 1,
|
|
83
|
+
userInfo: [NSLocalizedDescriptionKey: "tryBind() called before installRoutes()."]
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let lastPort = basePort + maxAttempts - 1
|
|
88
|
+
|
|
89
|
+
for i in 0..<maxAttempts {
|
|
90
|
+
let port = basePort + i
|
|
91
|
+
do {
|
|
92
|
+
let live = try await startApplication(
|
|
93
|
+
port: port,
|
|
94
|
+
host: host,
|
|
95
|
+
pending: pending
|
|
96
|
+
)
|
|
97
|
+
self.live = live
|
|
98
|
+
return port
|
|
99
|
+
} catch {
|
|
100
|
+
// Most likely "address in use"; try the next port.
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let msg = "[DVAI] Could not bind HTTP transport to any port in range " +
|
|
106
|
+
"\(basePort)..\(lastPort) (all in use). " +
|
|
107
|
+
"Another local AI server may already be running."
|
|
108
|
+
throw NSError(
|
|
109
|
+
domain: "DVAIBridgeLlama",
|
|
110
|
+
code: 2,
|
|
111
|
+
userInfo: [NSLocalizedDescriptionKey: msg]
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Stops the server. Idempotent — safe to call multiple times.
|
|
116
|
+
public func stop() async {
|
|
117
|
+
guard let live else { return }
|
|
118
|
+
await live.serviceGroup.triggerGracefulShutdown()
|
|
119
|
+
// Wait for the run task to wind down. Errors during shutdown
|
|
120
|
+
// (cancellation etc.) are expected and ignored.
|
|
121
|
+
_ = try? await live.runTask.value
|
|
122
|
+
self.live = nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - Private
|
|
126
|
+
|
|
127
|
+
private func startApplication(
|
|
128
|
+
port: Int,
|
|
129
|
+
host: String,
|
|
130
|
+
pending: PendingRoutes
|
|
131
|
+
) async throws -> Live {
|
|
132
|
+
// Fast-fail port-availability probe. We try to bind a transient
|
|
133
|
+
// BSD socket to the same host:port; if `bind(2)` fails the port
|
|
134
|
+
// is in use and we can move on without ever standing up
|
|
135
|
+
// Hummingbird (which would otherwise take seconds to error out).
|
|
136
|
+
// There's a tiny TOCTOU race between this probe close and
|
|
137
|
+
// Hummingbird's bind; that's the same window Telegraph + Ktor +
|
|
138
|
+
// Kestrel have, and is acceptable for our use case.
|
|
139
|
+
guard Self.portAvailable(host: host, port: port) else {
|
|
140
|
+
throw NSError(
|
|
141
|
+
domain: "DVAIBridge.HttpServer",
|
|
142
|
+
code: 5,
|
|
143
|
+
userInfo: [NSLocalizedDescriptionKey: "port \(port) in use"]
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let handlers = pending.handlers
|
|
148
|
+
let ctx = pending.ctx
|
|
149
|
+
let corsConfig = pending.corsConfig
|
|
150
|
+
|
|
151
|
+
let router = Router()
|
|
152
|
+
|
|
153
|
+
// OpenAI-compatible API surface — every route delegates to
|
|
154
|
+
// dispatchRoute, which produces a framework-neutral DVAIResponse.
|
|
155
|
+
// Hummingbird's trie-based router doesn't fall through from a
|
|
156
|
+
// specific path with mismatched method to a wildcard route, so
|
|
157
|
+
// we register OPTIONS explicitly for each known endpoint AS
|
|
158
|
+
// WELL AS the wildcard catch-all. CORS preflights against
|
|
159
|
+
// unknown paths still hit the wildcard and get a CORS-aware
|
|
160
|
+
// response.
|
|
161
|
+
let routeHandler: @Sendable (Request, BasicRequestContext) async throws -> Response = { req, _ in
|
|
162
|
+
try await Self.handle(req, handlers: handlers, ctx: ctx, corsConfig: corsConfig)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
router.post("/v1/chat/completions", use: routeHandler)
|
|
166
|
+
router.post("/v1/completions", use: routeHandler)
|
|
167
|
+
router.post("/v1/embeddings", use: routeHandler)
|
|
168
|
+
router.get("/v1/models", use: routeHandler)
|
|
169
|
+
|
|
170
|
+
// Explicit OPTIONS preflight for each known endpoint so the
|
|
171
|
+
// trie matches BEFORE falling to 405 / 404.
|
|
172
|
+
router.on("/v1/chat/completions", method: .options, use: routeHandler)
|
|
173
|
+
router.on("/v1/completions", method: .options, use: routeHandler)
|
|
174
|
+
router.on("/v1/embeddings", method: .options, use: routeHandler)
|
|
175
|
+
router.on("/v1/models", method: .options, use: routeHandler)
|
|
176
|
+
|
|
177
|
+
// Catch-alls for unknown paths — CORS-aware 404 / 204 body.
|
|
178
|
+
router.on("/**", method: .options, use: routeHandler)
|
|
179
|
+
router.on("/**", method: .get, use: routeHandler)
|
|
180
|
+
router.on("/**", method: .post, use: routeHandler)
|
|
181
|
+
|
|
182
|
+
let app = Application(
|
|
183
|
+
router: router,
|
|
184
|
+
configuration: .init(
|
|
185
|
+
address: .hostname(host, port: port),
|
|
186
|
+
serverName: "DVAIBridge"
|
|
187
|
+
),
|
|
188
|
+
logger: .init(label: "DVAIBridge.HttpServer")
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// ServiceGroup gives us a clean graceful-shutdown handle.
|
|
192
|
+
// We disable the default signal handlers so embedding apps
|
|
193
|
+
// keep ownership of SIGINT / SIGTERM.
|
|
194
|
+
let group = ServiceGroup(
|
|
195
|
+
services: [app],
|
|
196
|
+
gracefulShutdownSignals: [],
|
|
197
|
+
cancellationSignals: [],
|
|
198
|
+
logger: .init(label: "DVAIBridge.ServiceGroup")
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// Spin the application on a detached task; probe TCP to detect
|
|
202
|
+
// bind success (Hummingbird 2.x has no public did-bind hook).
|
|
203
|
+
let runTask = Task<Void, Error> {
|
|
204
|
+
try await group.run()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let probeStart = Date()
|
|
208
|
+
while Date().timeIntervalSince(probeStart) < 5.0 {
|
|
209
|
+
if runTask.isCancelled {
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
if await Self.tcpProbe(host: host, port: port) {
|
|
213
|
+
return Live(port: port, serviceGroup: group, runTask: runTask)
|
|
214
|
+
}
|
|
215
|
+
try await Task.sleep(nanoseconds: 25_000_000) // 25ms
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Probe timed out — assume bind failed. Trigger shutdown so the
|
|
219
|
+
// run task winds down before we return the error to the caller.
|
|
220
|
+
await group.triggerGracefulShutdown()
|
|
221
|
+
_ = try? await runTask.value
|
|
222
|
+
throw NSError(
|
|
223
|
+
domain: "DVAIBridge.HttpServer",
|
|
224
|
+
code: 4,
|
|
225
|
+
userInfo: [NSLocalizedDescriptionKey: "Hummingbird app failed to bind \(host):\(port)"]
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Lightweight TCP probe used to detect "bind succeeded" since
|
|
230
|
+
/// Hummingbird 2.x doesn't expose a did-bind hook. Returns true
|
|
231
|
+
/// if the connection establishes; false on any error.
|
|
232
|
+
private static func tcpProbe(host: String, port: Int) async -> Bool {
|
|
233
|
+
await withCheckedContinuation { cont in
|
|
234
|
+
DispatchQueue.global().async {
|
|
235
|
+
let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
|
|
236
|
+
guard fd >= 0 else { cont.resume(returning: false); return }
|
|
237
|
+
defer { close(fd) }
|
|
238
|
+
|
|
239
|
+
var addr = sockaddr_in()
|
|
240
|
+
addr.sin_family = sa_family_t(AF_INET)
|
|
241
|
+
addr.sin_port = UInt16(port).bigEndian
|
|
242
|
+
inet_pton(AF_INET, host == "0.0.0.0" ? "127.0.0.1" : host, &addr.sin_addr)
|
|
243
|
+
|
|
244
|
+
let res = withUnsafePointer(to: &addr) { ptr -> Int32 in
|
|
245
|
+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
246
|
+
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
cont.resume(returning: res == 0)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Synchronous port-availability check via BSD `bind(2)`. We open
|
|
255
|
+
/// a transient TCP socket, set SO_REUSEADDR off (the default), and
|
|
256
|
+
/// attempt to bind. Success means the port is free; we close the
|
|
257
|
+
/// socket and let the caller proceed. Failure means the port is
|
|
258
|
+
/// occupied (EADDRINUSE etc.) and the caller should try the next.
|
|
259
|
+
private static func portAvailable(host: String, port: Int) -> Bool {
|
|
260
|
+
let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
|
|
261
|
+
guard fd >= 0 else { return false }
|
|
262
|
+
defer { close(fd) }
|
|
263
|
+
|
|
264
|
+
var addr = sockaddr_in()
|
|
265
|
+
addr.sin_family = sa_family_t(AF_INET)
|
|
266
|
+
addr.sin_port = UInt16(port).bigEndian
|
|
267
|
+
let hostStr = (host == "0.0.0.0") ? "0.0.0.0" : host
|
|
268
|
+
inet_pton(AF_INET, hostStr, &addr.sin_addr)
|
|
269
|
+
|
|
270
|
+
let bindResult = withUnsafePointer(to: &addr) { ptr -> Int32 in
|
|
271
|
+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
272
|
+
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return bindResult == 0
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Translate a Hummingbird `Request` into a `DVAIRequest`, dispatch,
|
|
279
|
+
/// then translate the `DVAIResponse` back into a Hummingbird
|
|
280
|
+
/// `Response`. Buffered → in-memory body; streaming → ResponseBody
|
|
281
|
+
/// closure that flushes each `String` chunk as a `ByteBuffer`.
|
|
282
|
+
private static func handle(
|
|
283
|
+
_ req: Request,
|
|
284
|
+
handlers: DVAIHandlers,
|
|
285
|
+
ctx: HandlerContext,
|
|
286
|
+
corsConfig: CORSConfig
|
|
287
|
+
) async throws -> Response {
|
|
288
|
+
// Materialise the body up to a generous cap (chat messages can
|
|
289
|
+
// include base64 images). 16 MiB matches the Telegraph default.
|
|
290
|
+
let bodyBuffer = try await req.body.collect(upTo: 16 * 1024 * 1024)
|
|
291
|
+
let bodyData = Data(buffer: bodyBuffer)
|
|
292
|
+
|
|
293
|
+
// Translate headers into a Swift dictionary. HTTPFields preserves
|
|
294
|
+
// first-occurrence order; if a header repeats, comma-join the
|
|
295
|
+
// values (RFC 7230 §3.2.2).
|
|
296
|
+
var headers: [String: String] = [:]
|
|
297
|
+
for field in req.headers {
|
|
298
|
+
let name = field.name.canonicalName
|
|
299
|
+
if let existing = headers[name] {
|
|
300
|
+
headers[name] = existing + ", " + field.value
|
|
301
|
+
} else {
|
|
302
|
+
headers[name] = field.value
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let dvaiRequest = DVAIRequest(
|
|
307
|
+
method: DVAIHttpMethod.from(req.method.rawValue),
|
|
308
|
+
path: req.uri.path,
|
|
309
|
+
headers: headers,
|
|
310
|
+
body: bodyData
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
let dvaiResponse = await dispatchRoute(
|
|
314
|
+
request: dvaiRequest,
|
|
315
|
+
handlers: handlers,
|
|
316
|
+
ctx: ctx,
|
|
317
|
+
corsConfig: corsConfig
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return Self.toHummingbirdResponse(dvaiResponse)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Convert a `DVAIResponse` into a Hummingbird `Response`. Shared
|
|
324
|
+
/// helper so the streaming + buffered paths stay symmetric.
|
|
325
|
+
private static func toHummingbirdResponse(_ resp: DVAIResponse) -> Response {
|
|
326
|
+
switch resp {
|
|
327
|
+
case .buffered(let status, let headers, let body):
|
|
328
|
+
var fields = HTTPFields()
|
|
329
|
+
for (k, v) in headers {
|
|
330
|
+
if let name = HTTPField.Name(k) {
|
|
331
|
+
fields.append(HTTPField(name: name, value: v))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return Response(
|
|
335
|
+
status: .init(code: status),
|
|
336
|
+
headers: fields,
|
|
337
|
+
body: ResponseBody(byteBuffer: ByteBuffer(data: body))
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
case .streaming(let status, let headers, let stream):
|
|
341
|
+
var fields = HTTPFields()
|
|
342
|
+
for (k, v) in headers {
|
|
343
|
+
if let name = HTTPField.Name(k) {
|
|
344
|
+
fields.append(HTTPField(name: name, value: v))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return Response(
|
|
348
|
+
status: .init(code: status),
|
|
349
|
+
headers: fields,
|
|
350
|
+
body: ResponseBody { writer in
|
|
351
|
+
for await chunk in stream {
|
|
352
|
+
if let bytes = chunk.data(using: .utf8), !bytes.isEmpty {
|
|
353
|
+
try await writer.write(ByteBuffer(data: bytes))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
try await writer.finish(nil)
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
#else
|
|
363
|
+
public actor HttpServer {
|
|
364
|
+
private struct PendingRoutes {
|
|
365
|
+
let handlers: DVAIHandlers
|
|
366
|
+
let ctx: HandlerContext
|
|
367
|
+
let corsConfig: CORSConfig
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private var server: Server?
|
|
371
|
+
private var pending: PendingRoutes?
|
|
372
|
+
|
|
373
|
+
public var boundPort: Int? { server?.port }
|
|
374
|
+
|
|
375
|
+
public init() {}
|
|
376
|
+
|
|
377
|
+
public func installRoutes(handlers: DVAIHandlers, ctx: HandlerContext, corsConfig: CORSConfig) {
|
|
378
|
+
self.pending = PendingRoutes(handlers: handlers, ctx: ctx, corsConfig: corsConfig)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
public func tryBind(basePort: Int, maxAttempts: Int, host: String) async throws -> Int {
|
|
382
|
+
guard let pending = self.pending else {
|
|
383
|
+
throw NSError(domain: "DVAIBridge.HttpServer", code: 1, userInfo: [NSLocalizedDescriptionKey: "tryBind() called before installRoutes()."])
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for i in 0..<maxAttempts {
|
|
387
|
+
let port = basePort + i
|
|
388
|
+
let server = Server()
|
|
389
|
+
|
|
390
|
+
// Map routes to dispatchRoute
|
|
391
|
+
let handler: (HTTPRequest) -> HTTPResponse = { req in
|
|
392
|
+
var headers: [String: String] = [:]
|
|
393
|
+
for (name, value) in req.headers {
|
|
394
|
+
headers[name.description] = value
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let dvaiRequest = DVAIRequest(
|
|
398
|
+
method: DVAIHttpMethod.from("\(req.method)"),
|
|
399
|
+
path: req.uri.path,
|
|
400
|
+
headers: headers,
|
|
401
|
+
body: req.body
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
// Telegraph doesn't support async handlers natively in the same way,
|
|
405
|
+
// so we run the async dispatch in a blocking-safe way for the fallback.
|
|
406
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
407
|
+
var dvaiResponse: DVAIResponse?
|
|
408
|
+
|
|
409
|
+
Task {
|
|
410
|
+
dvaiResponse = await dispatchRoute(
|
|
411
|
+
request: dvaiRequest,
|
|
412
|
+
handlers: pending.handlers,
|
|
413
|
+
ctx: pending.ctx,
|
|
414
|
+
corsConfig: pending.corsConfig
|
|
415
|
+
)
|
|
416
|
+
semaphore.signal()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
semaphore.wait()
|
|
420
|
+
|
|
421
|
+
switch dvaiResponse! {
|
|
422
|
+
case .buffered(let status, let headers, let body):
|
|
423
|
+
var tHeaders = HTTPHeaders()
|
|
424
|
+
for (k, v) in headers { tHeaders[HTTPHeaderName(stringLiteral: k)] = v }
|
|
425
|
+
return HTTPResponse(HTTPStatus(code: status, phrase: ""), headers: tHeaders, body: body)
|
|
426
|
+
case .streaming(let status, let headers, let stream):
|
|
427
|
+
// Simplified fallback: buffer the stream
|
|
428
|
+
let runLoop = RunLoop.current
|
|
429
|
+
var fullBody = Data()
|
|
430
|
+
var finished = false
|
|
431
|
+
Task {
|
|
432
|
+
for await chunk in stream {
|
|
433
|
+
if let data = chunk.data(using: .utf8) { fullBody.append(data) }
|
|
434
|
+
}
|
|
435
|
+
finished = true
|
|
436
|
+
}
|
|
437
|
+
while !finished {
|
|
438
|
+
runLoop.run(until: Date(timeIntervalSinceNow: 0.01))
|
|
439
|
+
}
|
|
440
|
+
var tHeaders = HTTPHeaders()
|
|
441
|
+
for (k, v) in headers { tHeaders[HTTPHeaderName(stringLiteral: k)] = v }
|
|
442
|
+
return HTTPResponse(HTTPStatus(code: status, phrase: ""), headers: tHeaders, body: fullBody)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
server.route(.POST, "/v1/chat/completions", handler)
|
|
447
|
+
server.route(.POST, "/v1/completions", handler)
|
|
448
|
+
server.route(.POST, "/v1/embeddings", handler)
|
|
449
|
+
server.route(.GET, "/v1/models", handler)
|
|
450
|
+
server.route(.OPTIONS, "/**", handler)
|
|
451
|
+
|
|
452
|
+
do {
|
|
453
|
+
try server.start(port: port, interface: host)
|
|
454
|
+
self.server = server
|
|
455
|
+
return port
|
|
456
|
+
} catch {
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
throw NSError(domain: "DVAIBridgeLlama", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not bind to any port"])
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
public func stop() async {
|
|
465
|
+
server?.stop()
|
|
466
|
+
server = nil
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
#endif
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAISharedCore
|
|
3
|
+
|
|
4
|
+
/// A fake DVAIHandlers implementation that returns canned responses.
|
|
5
|
+
final class FakeHandlers: DVAIHandlers, @unchecked Sendable {
|
|
6
|
+
func handleChatCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
7
|
+
return .json(200, ["id": "chatcmpl-fake", "object": "chat.completion"])
|
|
8
|
+
}
|
|
9
|
+
func handleCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
10
|
+
return .json(200, ["object": "text_completion"])
|
|
11
|
+
}
|
|
12
|
+
func handleEmbeddings(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
13
|
+
return .json(200, ["object": "list"])
|
|
14
|
+
}
|
|
15
|
+
func handleModels(ctx: HandlerContext) async throws -> HandlerResponse {
|
|
16
|
+
return .json(200, ["object": "list", "data": [["id": ctx.modelId]]])
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
final class HandlerDispatchTest: XCTestCase {
|
|
21
|
+
let ctx = HandlerContext(modelId: "test", backendName: "llama")
|
|
22
|
+
|
|
23
|
+
func testCorsPreflightReturns204WithPNA() async {
|
|
24
|
+
let req = DVAIRequest(method: .options, path: "/v1/chat/completions")
|
|
25
|
+
let resp = await dispatchRoute(
|
|
26
|
+
request: req,
|
|
27
|
+
handlers: FakeHandlers(),
|
|
28
|
+
ctx: ctx,
|
|
29
|
+
corsConfig: .wildcard
|
|
30
|
+
)
|
|
31
|
+
XCTAssertEqual(resp.status, 204)
|
|
32
|
+
XCTAssertEqual(resp.headers["Access-Control-Allow-Private-Network"], "true")
|
|
33
|
+
XCTAssertEqual(resp.headers["Access-Control-Allow-Origin"], "*")
|
|
34
|
+
XCTAssertNotNil(resp.headers["Access-Control-Allow-Methods"])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testUnknownPathReturns404() async {
|
|
38
|
+
let req = DVAIRequest(method: .get, path: "/v1/unknown")
|
|
39
|
+
let resp = await dispatchRoute(
|
|
40
|
+
request: req,
|
|
41
|
+
handlers: FakeHandlers(),
|
|
42
|
+
ctx: ctx,
|
|
43
|
+
corsConfig: .wildcard
|
|
44
|
+
)
|
|
45
|
+
XCTAssertEqual(resp.status, 404)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testGetModelsReturnsCannedResponse() async throws {
|
|
49
|
+
let req = DVAIRequest(method: .get, path: "/v1/models")
|
|
50
|
+
let resp = await dispatchRoute(
|
|
51
|
+
request: req,
|
|
52
|
+
handlers: FakeHandlers(),
|
|
53
|
+
ctx: ctx,
|
|
54
|
+
corsConfig: .wildcard
|
|
55
|
+
)
|
|
56
|
+
XCTAssertEqual(resp.status, 200)
|
|
57
|
+
XCTAssertEqual(resp.headers["Content-Type"], "application/json")
|
|
58
|
+
|
|
59
|
+
let json = try JSONSerialization.jsonObject(with: resp.body) as? [String: Any]
|
|
60
|
+
XCTAssertEqual(json?["object"] as? String, "list")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testPostChatCompletionRoutes() async throws {
|
|
64
|
+
let body = try JSONSerialization.data(
|
|
65
|
+
withJSONObject: ["messages": [["role": "user", "content": "hi"]]]
|
|
66
|
+
)
|
|
67
|
+
let req = DVAIRequest(
|
|
68
|
+
method: .post,
|
|
69
|
+
path: "/v1/chat/completions",
|
|
70
|
+
headers: ["Content-Type": "application/json"],
|
|
71
|
+
body: body
|
|
72
|
+
)
|
|
73
|
+
let resp = await dispatchRoute(
|
|
74
|
+
request: req,
|
|
75
|
+
handlers: FakeHandlers(),
|
|
76
|
+
ctx: ctx,
|
|
77
|
+
corsConfig: .wildcard
|
|
78
|
+
)
|
|
79
|
+
XCTAssertEqual(resp.status, 200)
|
|
80
|
+
|
|
81
|
+
let json = try JSONSerialization.jsonObject(with: resp.body) as? [String: Any]
|
|
82
|
+
XCTAssertEqual(json?["id"] as? String, "chatcmpl-fake")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func testCorsAllowlistMatchesOrigin() async {
|
|
86
|
+
let req = DVAIRequest(
|
|
87
|
+
method: .get,
|
|
88
|
+
path: "/v1/models",
|
|
89
|
+
headers: ["Origin": "https://app.example.com"]
|
|
90
|
+
)
|
|
91
|
+
let resp = await dispatchRoute(
|
|
92
|
+
request: req,
|
|
93
|
+
handlers: FakeHandlers(),
|
|
94
|
+
ctx: ctx,
|
|
95
|
+
corsConfig: .allowlist([
|
|
96
|
+
"https://app.example.com",
|
|
97
|
+
"https://other.example.com",
|
|
98
|
+
])
|
|
99
|
+
)
|
|
100
|
+
XCTAssertEqual(resp.headers["Access-Control-Allow-Origin"], "https://app.example.com")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func testCorsAllowlistRejectsUnlistedOrigin() async {
|
|
104
|
+
let req = DVAIRequest(
|
|
105
|
+
method: .get,
|
|
106
|
+
path: "/v1/models",
|
|
107
|
+
headers: ["Origin": "https://evil.example.com"]
|
|
108
|
+
)
|
|
109
|
+
let resp = await dispatchRoute(
|
|
110
|
+
request: req,
|
|
111
|
+
handlers: FakeHandlers(),
|
|
112
|
+
ctx: ctx,
|
|
113
|
+
corsConfig: .allowlist(["https://app.example.com"])
|
|
114
|
+
)
|
|
115
|
+
// Allow-Origin header should be missing entirely (browser will block).
|
|
116
|
+
XCTAssertNil(resp.headers["Access-Control-Allow-Origin"])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func testStreamingResponseRoundTrips() async throws {
|
|
120
|
+
// SSE handlers return AsyncStream<String>; dispatchRoute should
|
|
121
|
+
// return .streaming so the transport flushes chunks live.
|
|
122
|
+
final class StreamingHandlers: DVAIHandlers, @unchecked Sendable {
|
|
123
|
+
func handleChatCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
124
|
+
let (stream, cont) = AsyncStream<String>.makeStream()
|
|
125
|
+
Task {
|
|
126
|
+
cont.yield("data: chunk1\n\n")
|
|
127
|
+
cont.yield("data: chunk2\n\n")
|
|
128
|
+
cont.yield("data: [DONE]\n\n")
|
|
129
|
+
cont.finish()
|
|
130
|
+
}
|
|
131
|
+
return .sse(stream)
|
|
132
|
+
}
|
|
133
|
+
func handleCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse { .json(200, [:]) }
|
|
134
|
+
func handleEmbeddings(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse { .json(200, [:]) }
|
|
135
|
+
func handleModels(ctx: HandlerContext) async throws -> HandlerResponse { .json(200, [:]) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let req = DVAIRequest(method: .post, path: "/v1/chat/completions")
|
|
139
|
+
let resp = await dispatchRoute(
|
|
140
|
+
request: req,
|
|
141
|
+
handlers: StreamingHandlers(),
|
|
142
|
+
ctx: ctx,
|
|
143
|
+
corsConfig: .wildcard
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
guard case .streaming(let status, let headers, let stream) = resp else {
|
|
147
|
+
XCTFail("expected .streaming response, got \(resp)")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
XCTAssertEqual(status, 200)
|
|
151
|
+
XCTAssertEqual(headers["Content-Type"], "text/event-stream")
|
|
152
|
+
XCTAssertEqual(headers["Cache-Control"], "no-cache")
|
|
153
|
+
|
|
154
|
+
var collected = ""
|
|
155
|
+
for await chunk in stream { collected += chunk }
|
|
156
|
+
XCTAssertTrue(collected.contains("chunk1"))
|
|
157
|
+
XCTAssertTrue(collected.contains("chunk2"))
|
|
158
|
+
XCTAssertTrue(collected.contains("[DONE]"))
|
|
159
|
+
}
|
|
160
|
+
}
|