@independo/capacitor-inderun 1.1.0-dev.4 → 1.1.0-dev.5

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.
@@ -1,6 +1,7 @@
1
1
  import XCTest
2
2
  import Foundation
3
3
  import IndeRunContracts
4
+ import IndeRunCore
4
5
  @testable import IndeRunCapacitorPlugin
5
6
 
6
7
  final class IndeRunCapacitorBridgeTests: XCTestCase {
@@ -86,6 +87,22 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
86
87
  XCTAssertEqual(options.allowDirectOpenAIEndpoint, true)
87
88
  }
88
89
 
90
+ /// `systemModel` and `onnx` are web-only bootstrap keys, like
91
+ /// `allowDirectOpenAIEndpoint`. Native registers its own on-device provider, so it must
92
+ /// ignore them rather than fail to decode the options object that carries them.
93
+ func testIgnoresTheWebOnlyProviderBootstrapKeys() throws {
94
+ let json: [String: Any] = [
95
+ "openAI": ["model": "gpt-5.2", "auth": "none"],
96
+ "systemModel": ["id": "local.system-model.web", "timeoutMs": 30_000],
97
+ "onnx": ["modelPackage": ["id": "demo", "format": "onnx"]]
98
+ ]
99
+ let data = try JSONSerialization.data(withJSONObject: json)
100
+ let options = try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
101
+
102
+ XCTAssertEqual(options.openAI?.model, "gpt-5.2")
103
+ XCTAssertEqual(options.openAI?.auth, "none")
104
+ }
105
+
89
106
  // MARK: - encode(error:)
90
107
 
91
108
  func testEncodesIndeRunErrorRequiredFieldsOnly() throws {
@@ -134,6 +151,223 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
134
151
  XCTAssertEqual(encoded["runId"] as? String, "run_abc")
135
152
  }
136
153
 
154
+ /// A routing refusal carries its plan diagnostics on `details`, and since inderun 0.3.0
155
+ /// it does so on iOS too, not just on the web SDK. The shape is nested — a
156
+ /// `rejectedProviders` array of objects each carrying its own `reasons` array — and it
157
+ /// crosses as `[String: JSONAny]`. This is the data a provider-refusal UI reads, so a
158
+ /// regression would be invisible until a demo showed an empty table.
159
+ func testEncodesNestedRoutePlanDiagnosticsInDetails() throws {
160
+ let bridge = IndeRunCapacitorBridge()
161
+ let error = IndeRunError(
162
+ details: [
163
+ "failureCode": JSONAny("CapabilityMismatch"),
164
+ "rejectedProviders": JSONAny([
165
+ [
166
+ "providerId": "apple.foundation-models",
167
+ "reasons": [["code": "capability_unavailable", "message": "Apple Intelligence unavailable."]]
168
+ ],
169
+ [
170
+ "providerId": "openai",
171
+ "reasons": [["code": "privacy_constraint", "message": "local_required forbids cloud."]]
172
+ ]
173
+ ])
174
+ ],
175
+ errorClass: .CapabilityMismatch,
176
+ message: "No eligible provider can stream this request.",
177
+ providerId: nil,
178
+ retryable: nil,
179
+ retryAfterMs: nil,
180
+ runId: nil,
181
+ schemaVersion: .the10
182
+ )
183
+
184
+ let encoded = try bridge.encode(error: error)
185
+ let details = try XCTUnwrap(encoded["details"] as? [String: Any])
186
+ XCTAssertEqual(details["failureCode"] as? String, "CapabilityMismatch")
187
+
188
+ let rejected = try XCTUnwrap(details["rejectedProviders"] as? [[String: Any]])
189
+ XCTAssertEqual(rejected.count, 2)
190
+ XCTAssertEqual(rejected[0]["providerId"] as? String, "apple.foundation-models")
191
+
192
+ let reasons = try XCTUnwrap(rejected[0]["reasons"] as? [[String: Any]])
193
+ XCTAssertEqual(reasons[0]["code"] as? String, "capability_unavailable")
194
+ XCTAssertEqual(
195
+ (try XCTUnwrap(rejected[1]["reasons"] as? [[String: Any]]))[0]["code"] as? String,
196
+ "privacy_constraint"
197
+ )
198
+ }
199
+
200
+ // MARK: - encode(capabilities:)
201
+
202
+ private func makeSnapshot(
203
+ providerId: String,
204
+ type: ProviderDescriptor.ProviderType,
205
+ transport: ProviderDescriptor.TransportType,
206
+ streamingStyle: ProviderDescriptor.StreamingStyle? = nil,
207
+ streaming: Bool,
208
+ cancel: ProviderDescriptor.CancelSemantics,
209
+ limits: ProviderDescriptor.ResourceLimits? = nil,
210
+ privacy: ProviderDescriptor.PrivacyDescriptor? = nil,
211
+ capabilities: ProviderDynamicCapabilities
212
+ ) -> ProviderCapabilitySnapshot {
213
+ ProviderCapabilitySnapshot(
214
+ providerId: providerId,
215
+ descriptor: ProviderDescriptor(
216
+ id: providerId,
217
+ type: type,
218
+ transport: transport,
219
+ streamingStyle: streamingStyle,
220
+ supports: ProviderDescriptor.SupportsCapabilities(
221
+ run: true,
222
+ streaming: streaming,
223
+ realtime: false,
224
+ tools: false,
225
+ reasoningEvents: false,
226
+ structuredOutput: false,
227
+ multimodal: false
228
+ ),
229
+ cancel: cancel,
230
+ tasks: ["text_to_text"],
231
+ limits: limits,
232
+ privacy: privacy
233
+ ),
234
+ capabilities: capabilities
235
+ )
236
+ }
237
+
238
+ func testEncodesCapabilitySnapshotsInsideTheProvidersEnvelope() throws {
239
+ let bridge = IndeRunCapacitorBridge()
240
+ let encoded = try bridge.encode(capabilities: [
241
+ makeSnapshot(
242
+ providerId: "openai",
243
+ type: .cloud,
244
+ transport: .http,
245
+ streamingStyle: .tokens,
246
+ streaming: true,
247
+ cancel: .hard,
248
+ limits: ProviderDescriptor.ResourceLimits(maxInputTokens: 128_000, maxOutputTokens: 4_096),
249
+ privacy: ProviderDescriptor.PrivacyDescriptor(dataLeavesDevice: true, regions: ["us"]),
250
+ capabilities: ProviderDynamicCapabilities(available: true)
251
+ )
252
+ ])
253
+
254
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
255
+ XCTAssertEqual(providers.count, 1)
256
+ XCTAssertEqual(providers[0]["providerId"] as? String, "openai")
257
+
258
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
259
+ XCTAssertEqual(descriptor["id"] as? String, "openai")
260
+ XCTAssertEqual(descriptor["type"] as? String, "cloud")
261
+ XCTAssertEqual(descriptor["transport"] as? String, "http")
262
+ XCTAssertEqual(descriptor["streamingStyle"] as? String, "tokens")
263
+ XCTAssertEqual(descriptor["cancel"] as? String, "hard")
264
+ XCTAssertEqual(descriptor["tasks"] as? [String], ["text_to_text"])
265
+
266
+ let supports = try XCTUnwrap(descriptor["supports"] as? [String: Any])
267
+ XCTAssertEqual(supports["run"] as? Bool, true)
268
+ XCTAssertEqual(supports["streaming"] as? Bool, true)
269
+ XCTAssertEqual(supports["multimodal"] as? Bool, false)
270
+
271
+ let limits = try XCTUnwrap(descriptor["limits"] as? [String: Any])
272
+ XCTAssertEqual(limits["maxInputTokens"] as? Int, 128_000)
273
+ XCTAssertNil(limits["maxImageBytes"])
274
+
275
+ let privacy = try XCTUnwrap(descriptor["privacy"] as? [String: Any])
276
+ XCTAssertEqual(privacy["dataLeavesDevice"] as? Bool, true)
277
+ XCTAssertEqual(privacy["regions"] as? [String], ["us"])
278
+ }
279
+
280
+ /// `in_process` is the one descriptor constant a future upstream rename to Swift
281
+ /// casing would silently corrupt on the wire, since the JS union spells it out.
282
+ func testEncodesTheInProcessTransportWithItsWireSpelling() throws {
283
+ let bridge = IndeRunCapacitorBridge()
284
+ let encoded = try bridge.encode(capabilities: [
285
+ makeSnapshot(
286
+ providerId: "local.onnx.genai.apple",
287
+ type: .local,
288
+ transport: .inProcess,
289
+ streaming: false,
290
+ cancel: .soft,
291
+ capabilities: ProviderDynamicCapabilities(available: false, reason: "No model package.")
292
+ )
293
+ ])
294
+
295
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
296
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
297
+ XCTAssertEqual(descriptor["transport"] as? String, "in_process")
298
+ XCTAssertNil(descriptor["streamingStyle"])
299
+
300
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
301
+ XCTAssertEqual(capabilities["available"] as? Bool, false)
302
+ XCTAssertEqual(capabilities["reason"] as? String, "No model package.")
303
+ }
304
+
305
+ /// Absence means "inherit the static declaration". A null would be a third state
306
+ /// consumers do not have, so the unset optionals must not reach the wire at all.
307
+ func testOmitsUnsetDynamicCapabilityFlagsRatherThanEncodingNull() throws {
308
+ let bridge = IndeRunCapacitorBridge()
309
+ let encoded = try bridge.encode(capabilities: [
310
+ makeSnapshot(
311
+ providerId: "apple.foundation-models",
312
+ type: .local,
313
+ transport: .systemService,
314
+ streamingStyle: .snapshots,
315
+ streaming: true,
316
+ cancel: .soft,
317
+ capabilities: ProviderDynamicCapabilities(available: true)
318
+ )
319
+ ])
320
+
321
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
322
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
323
+ XCTAssertEqual(descriptor["transport"] as? String, "system_service")
324
+ XCTAssertEqual(descriptor["streamingStyle"] as? String, "snapshots")
325
+ XCTAssertNil(descriptor["limits"])
326
+ XCTAssertNil(descriptor["privacy"])
327
+
328
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
329
+ XCTAssertNil(capabilities["streamingAvailable"])
330
+ XCTAssertNil(capabilities["streamingUnavailableReason"])
331
+ XCTAssertNil(capabilities["cancellationAvailable"])
332
+ XCTAssertNil(capabilities["reason"])
333
+ }
334
+
335
+ func testEncodesStreamingTakenAwayAtRuntime() throws {
336
+ let bridge = IndeRunCapacitorBridge()
337
+ let encoded = try bridge.encode(capabilities: [
338
+ makeSnapshot(
339
+ providerId: "openai",
340
+ type: .cloud,
341
+ transport: .http,
342
+ streamingStyle: .tokens,
343
+ streaming: true,
344
+ cancel: .hard,
345
+ capabilities: ProviderDynamicCapabilities(
346
+ available: true,
347
+ streamingAvailable: false,
348
+ streamingUnavailableReason: "Host has no streaming HTTP client.",
349
+ cancellationAvailable: true
350
+ )
351
+ )
352
+ ])
353
+
354
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
355
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
356
+ XCTAssertEqual(capabilities["streamingAvailable"] as? Bool, false)
357
+ XCTAssertEqual(
358
+ capabilities["streamingUnavailableReason"] as? String,
359
+ "Host has no streaming HTTP client."
360
+ )
361
+ XCTAssertEqual(capabilities["cancellationAvailable"] as? Bool, true)
362
+ }
363
+
364
+ func testEncodesAnEmptyRegistryAsAnEmptyProvidersArray() throws {
365
+ let bridge = IndeRunCapacitorBridge()
366
+ let encoded = try bridge.encode(capabilities: [])
367
+
368
+ XCTAssertEqual(try XCTUnwrap(encoded["providers"] as? [[String: Any]]).count, 0)
369
+ }
370
+
137
371
  // MARK: - mapAuthMode
138
372
 
139
373
  func testMapAuthModeNoneString() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@independo/capacitor-inderun",
3
- "version": "1.1.0-dev.4",
3
+ "version": "1.1.0-dev.5",
4
4
  "description": "On-device AI with cloud fallback for Capacitor apps. Apple Foundation Models on iOS, ML Kit GenAI (Gemini Nano) on Android, an OpenAI-compatible endpoint as fallback — one API, with streaming and cancellation.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/independo-gmbh/capacitor-inderun#readme",
@@ -70,8 +70,8 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@capacitor/core": "^8.0.0",
73
- "@independo/inderun-contracts": "0.3.0-dev.14",
74
- "@independo/inderun-web": "0.3.0-dev.14"
73
+ "@independo/inderun-contracts": "0.3.0",
74
+ "@independo/inderun-web": "0.3.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@saithodev/semantic-release-backmerge": "^4.0.1",