@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.
@@ -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
+ }