@independo/capacitor-inderun 1.1.0-dev.1 → 1.1.0-dev.10

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/dist/web.js CHANGED
@@ -10,13 +10,27 @@ export class IndeRunWeb extends WebPlugin {
10
10
  startingStreams = new Set();
11
11
  pendingCancels = new Map();
12
12
  async configure(options) {
13
- if (!options?.openAI) {
14
- throw createUnavailable("Capacitor web execution requires OpenAI provider registration. Configure with openAI bootstrap options before calling run(request).").toContractError();
13
+ // The web SDK requires at least one provider, not specifically the cloud one. Held
14
+ // here as well as there so the failure is an `Unavailable` contract error rather than
15
+ // a raw SDK throw, which is what the facade's callers expect.
16
+ if (!options?.openAI && !options?.systemModel && !options?.onnx) {
17
+ throw createUnavailable("Capacitor web execution requires at least one provider registration. Configure with openAI, systemModel and/or onnx bootstrap options before calling run(request).").toContractError();
15
18
  }
16
19
  try {
17
- const webOptions = {
18
- openAI: compactOpenAIOptions(options)
19
- };
20
+ // The SDK's own option type, not a hand-written copy of it: an upstream rename
21
+ // then fails at the type level instead of silently dropping a field. This is
22
+ // deliberately the *outbound* shape only — `ConfigureOptions` stays a narrower,
23
+ // JSON-serializable subset, because it has to cross the native bridge hop.
24
+ const webOptions = {};
25
+ if (options.openAI !== undefined) {
26
+ webOptions.openAI = compactOpenAIOptions(options.openAI);
27
+ }
28
+ if (options.systemModel !== undefined) {
29
+ webOptions.systemModel = compactSystemModelOptions(options.systemModel);
30
+ }
31
+ if (options.onnx !== undefined) {
32
+ webOptions.onnx = compactOnnxOptions(options.onnx);
33
+ }
20
34
  if (options.allowDirectOpenAIEndpoint !== undefined) {
21
35
  webOptions.allowDirectOpenAIEndpoint = options.allowDirectOpenAIEndpoint;
22
36
  }
@@ -37,6 +51,20 @@ export class IndeRunWeb extends WebPlugin {
37
51
  throw toIndeRunException(error).toContractError();
38
52
  }
39
53
  }
54
+ async checkCapabilities() {
55
+ if (!this.engine) {
56
+ throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().").toContractError();
57
+ }
58
+ try {
59
+ // Deliberately uncast: the engine's snapshots are assigned straight into the
60
+ // bridge's own `ProviderCapabilitySnapshot`, so the two declarations staying
61
+ // identical is a compile error rather than a convention. See definitions.ts.
62
+ return { providers: await this.engine.checkCapabilities() };
63
+ }
64
+ catch (error) {
65
+ throw toIndeRunException(error).toContractError();
66
+ }
67
+ }
40
68
  /**
41
69
  * The web path deliberately round-trips through `notifyListeners` rather than
42
70
  * handing the engine's `StreamRun` straight back.
@@ -101,8 +129,17 @@ export class IndeRunWeb extends WebPlugin {
101
129
  }
102
130
  }
103
131
  }
104
- function compactOpenAIOptions(options) {
105
- const openAI = options.openAI;
132
+ /**
133
+ * Assigns only the fields the caller actually set. `exactOptionalPropertyTypes` makes
134
+ * this incremental style mandatory rather than cosmetic: assigning `undefined` to an
135
+ * optional property is an error, and an explicit `undefined` on the wire is not the same
136
+ * thing as an absent key to the provider that reads it.
137
+ *
138
+ * `id`, `healthCheckTimeoutMs` and `healthCheckCacheMs` are deliberately not bridged —
139
+ * all optional, and the bridge has no reason to expose provider-tuning knobs it cannot
140
+ * also offer on native.
141
+ */
142
+ function compactOpenAIOptions(openAI) {
106
143
  const result = {
107
144
  model: openAI.model
108
145
  };
@@ -120,3 +157,25 @@ function compactOpenAIOptions(options) {
120
157
  }
121
158
  return result;
122
159
  }
160
+ function compactSystemModelOptions(systemModel) {
161
+ const result = {};
162
+ if (systemModel.id !== undefined) {
163
+ result.id = systemModel.id;
164
+ }
165
+ if (systemModel.timeoutMs !== undefined) {
166
+ result.timeoutMs = systemModel.timeoutMs;
167
+ }
168
+ return result;
169
+ }
170
+ function compactOnnxOptions(onnx) {
171
+ const result = {
172
+ modelPackage: onnx.modelPackage
173
+ };
174
+ if (onnx.id !== undefined) {
175
+ result.id = onnx.id;
176
+ }
177
+ if (onnx.timeoutMs !== undefined) {
178
+ result.timeoutMs = onnx.timeoutMs;
179
+ }
180
+ return result;
181
+ }
@@ -49,6 +49,22 @@ struct CapacitorCancelStreamOptions: Codable {
49
49
  let reason: String?
50
50
  }
51
51
 
52
+ /// IndeRunCore's `ProviderCapabilitySnapshot` is `Sendable` but not `Codable`, even
53
+ /// though both of its members are. This mirror exists only to get it through
54
+ /// `JSONEncoder`; see the upstream follow-up asking for `Codable` (and a schema) on
55
+ /// the snapshot itself.
56
+ private struct EncodableCapabilitySnapshot: Encodable {
57
+ let providerId: String
58
+ let descriptor: ProviderDescriptor
59
+ let capabilities: ProviderDynamicCapabilities
60
+ }
61
+
62
+ /// A Capacitor plugin method must resolve an object, never a top-level array, so the
63
+ /// snapshots travel wrapped. `IndeRunCapacitor` unwraps them on the JS side.
64
+ private struct CapabilitiesEnvelope: Encodable {
65
+ let providers: [EncodableCapabilitySnapshot]
66
+ }
67
+
52
68
  /// Called with an already-encoded event/error for one run. The plugin turns these
53
69
  /// into `notifyListeners` calls; taking them as closures keeps the pump testable
54
70
  /// without Capacitor.
@@ -82,6 +98,16 @@ final class IndeRunCapacitorBridge {
82
98
  return try encode(result)
83
99
  }
84
100
 
101
+ func checkCapabilities() async throws -> JSObject {
102
+ guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
103
+ throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().")
104
+ }
105
+
106
+ // IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
107
+ let engine = IndeRun(registry: registry, hostServices: hostServices)
108
+ return try encode(capabilities: await engine.checkCapabilities())
109
+ }
110
+
85
111
  func startStream(
86
112
  options: JSObject,
87
113
  onEvent: @escaping StreamEventSink,
@@ -173,6 +199,24 @@ final class IndeRunCapacitorBridge {
173
199
  try encodeObject(handle)
174
200
  }
175
201
 
202
+ /// The descriptor's enums carry explicit raw values (`in_process`, not `inProcess`),
203
+ /// and JSONEncoder omits nil optionals — so an unset `streamingAvailable` arrives as
204
+ /// an absent key rather than a null, which is what lets the JS side read absence as
205
+ /// "inherit `descriptor.supports.streaming`".
206
+ func encode(capabilities: [ProviderCapabilitySnapshot]) throws -> JSObject {
207
+ try encodeObject(
208
+ CapabilitiesEnvelope(
209
+ providers: capabilities.map {
210
+ EncodableCapabilitySnapshot(
211
+ providerId: $0.providerId,
212
+ descriptor: $0.descriptor,
213
+ capabilities: $0.capabilities
214
+ )
215
+ }
216
+ )
217
+ )
218
+ }
219
+
176
220
  private func makeRegistry(openAI: OpenAIProviderBootstrapOptions?) throws -> ProviderRegistry {
177
221
  let registry = ProviderRegistry()
178
222
  try registry.register(AppleFoundationModelsProvider())
@@ -222,10 +266,26 @@ final class IndeRunCapacitorBridge {
222
266
  private func encodeObject<T: Encodable>(_ value: T) throws -> JSObject {
223
267
  let data = try JSONEncoder().encode(value)
224
268
  let object = try JSONSerialization.jsonObject(with: data, options: [])
269
+
270
+ #if canImport(Capacitor)
271
+ // Capacitor's JSObject is [String: any JSValue], and a plain `as?` cast cannot
272
+ // produce one: JSONSerialization hands back NSDictionary/NSArray values, which do
273
+ // not conform to JSValue, so anything with a nested object or array — a TaskResult's
274
+ // `output`, a stream event's `payload`, the providers array — failed the cast and
275
+ // surfaced as "Capacitor bridge failed to encode a JSON object". JSTypes coerces the
276
+ // tree recursively, which is what the cast was standing in for.
277
+ guard let dictionary = object as? [AnyHashable: Any],
278
+ let coerced = JSTypes.coerceDictionaryToJSObject(dictionary) else {
279
+ throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
280
+ }
281
+ return coerced
282
+ #else
283
+ // Standalone builds alias JSObject to [String: Any], where the cast is exact.
225
284
  guard let dictionary = object as? JSObject else {
226
285
  throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
227
286
  }
228
287
  return dictionary
288
+ #endif
229
289
  }
230
290
 
231
291
  func mapAuthMode(_ value: String?) -> OpenAIAuthMode {
@@ -10,6 +10,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
10
10
  public let pluginMethods: [CAPPluginMethod] = [
11
11
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
12
12
  CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "checkCapabilities", returnType: CAPPluginReturnPromise),
13
14
  CAPPluginMethod(name: "startStream", returnType: CAPPluginReturnPromise),
14
15
  CAPPluginMethod(name: "cancelStream", returnType: CAPPluginReturnPromise)
15
16
  ]
@@ -25,7 +26,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
25
26
 
26
27
  @objc func configure(_ call: CAPPluginCall) {
27
28
  do {
28
- try implementation.configure(options: call.options)
29
+ try implementation.configure(options: call.jsObjectRepresentation)
29
30
  call.resolve()
30
31
  } catch let error as IndeRunException {
31
32
  let contractError = error.toContractError()
@@ -42,7 +43,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
42
43
  @objc func run(_ call: CAPPluginCall) {
43
44
  Task {
44
45
  do {
45
- let result = try await implementation.run(requestObject: call.options)
46
+ let result = try await implementation.run(requestObject: call.jsObjectRepresentation)
46
47
  call.resolve(result)
47
48
  } catch let error as IndeRunException {
48
49
  let contractError = error.toContractError()
@@ -57,6 +58,26 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
57
58
  }
58
59
  }
59
60
 
61
+ /// Reports every registered provider's static declaration and live availability
62
+ /// without executing a task. Availability changes between calls — a local model can
63
+ /// unload, cloud credentials can expire — so callers must not cache it across a run.
64
+ @objc func checkCapabilities(_ call: CAPPluginCall) {
65
+ Task {
66
+ do {
67
+ call.resolve(try await implementation.checkCapabilities())
68
+ } catch let error as IndeRunException {
69
+ let contractError = error.toContractError()
70
+ let details = try? implementation.encode(error: contractError)
71
+ call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
72
+ } catch {
73
+ let normalized = toIndeRunException(error)
74
+ let contractError = normalized.toContractError()
75
+ let details = try? implementation.encode(error: contractError)
76
+ call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
77
+ }
78
+ }
79
+ }
80
+
60
81
  /// Resolves with the run handle. Only validation and route-selection failures
61
82
  /// reject here; a provider failure, a cancellation, or completion all arrive as
62
83
  /// the single terminal event on `indeRunStreamEvent`.
@@ -69,7 +90,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
69
90
  guard let self else { return }
70
91
  do {
71
92
  let handle = try await self.implementation.startStream(
72
- options: call.options,
93
+ options: call.jsObjectRepresentation,
73
94
  onEvent: { [weak self] streamId, event in
74
95
  self?.notifyListeners(
75
96
  "indeRunStreamEvent",
@@ -103,7 +124,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
103
124
  /// is a no-op by contract, not an error.
104
125
  @objc func cancelStream(_ call: CAPPluginCall) {
105
126
  do {
106
- try implementation.cancelStream(options: call.options)
127
+ try implementation.cancelStream(options: call.jsObjectRepresentation)
107
128
  call.resolve()
108
129
  } catch let error as IndeRunException {
109
130
  let contractError = error.toContractError()
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@independo/capacitor-inderun",
3
- "version": "1.1.0-dev.1",
4
- "description": "Thin Capacitor bridge for IndeRun across web, iOS, and Android.",
3
+ "version": "1.1.0-dev.10",
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",
7
7
  "repository": {
@@ -13,9 +13,21 @@
13
13
  },
14
14
  "keywords": [
15
15
  "capacitor",
16
- "plugin",
16
+ "capacitor-plugin",
17
+ "ionic",
17
18
  "inderun",
18
- "ai"
19
+ "ai",
20
+ "llm",
21
+ "on-device",
22
+ "on-device-ai",
23
+ "local-llm",
24
+ "offline",
25
+ "cloud-fallback",
26
+ "streaming",
27
+ "openai",
28
+ "gemini-nano",
29
+ "ml-kit",
30
+ "foundation-models"
19
31
  ],
20
32
  "engines": {
21
33
  "node": ">=24"
@@ -58,18 +70,18 @@
58
70
  },
59
71
  "dependencies": {
60
72
  "@capacitor/core": "^8.0.0",
61
- "@independo/inderun-contracts": "0.3.0-dev.14",
62
- "@independo/inderun-web": "0.3.0-dev.14"
73
+ "@independo/inderun-contracts": "0.3.0",
74
+ "@independo/inderun-web": "0.3.0"
63
75
  },
64
76
  "devDependencies": {
65
77
  "@saithodev/semantic-release-backmerge": "^4.0.1",
66
- "@semantic-release/changelog": "^6.0.3",
67
- "@semantic-release/git": "^10.0.1",
78
+ "@semantic-release/changelog": "^7.0.0",
79
+ "@semantic-release/git": "^11.0.1",
68
80
  "@types/node": "^24.13.2",
69
81
  "conventional-changelog-conventionalcommits": "^9.3.1",
70
82
  "semantic-release": "^25.0.5",
71
83
  "typescript": "^7.0.2",
72
- "vitest": "^3.2.6"
84
+ "vitest": "^4.1.11"
73
85
  },
74
86
  "peerDependencies": {
75
87
  "@capacitor/core": "^8.0.0"