@independo/inderun-web 0.3.3 → 0.4.0-rc.2

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.
Files changed (52) hide show
  1. package/README.md +46 -9
  2. package/dist/core/engine.d.ts +26 -2
  3. package/dist/core/engine.d.ts.map +1 -1
  4. package/dist/core/engine.js +78 -34
  5. package/dist/core/errors.d.ts +13 -1
  6. package/dist/core/errors.d.ts.map +1 -1
  7. package/dist/core/errors.js +32 -0
  8. package/dist/core/model-profiles.d.ts +34 -0
  9. package/dist/core/model-profiles.d.ts.map +1 -0
  10. package/dist/core/model-profiles.js +49 -0
  11. package/dist/core/provenance.d.ts +32 -0
  12. package/dist/core/provenance.d.ts.map +1 -0
  13. package/dist/core/provenance.js +71 -0
  14. package/dist/core/provider.d.ts +13 -0
  15. package/dist/core/provider.d.ts.map +1 -1
  16. package/dist/core/route-planner.d.ts +1 -1
  17. package/dist/core/route-planner.d.ts.map +1 -1
  18. package/dist/core/route-planner.js +2 -1
  19. package/dist/core/router.d.ts +4 -3
  20. package/dist/core/router.d.ts.map +1 -1
  21. package/dist/core/router.js +4 -3
  22. package/dist/facade/text.d.ts +123 -0
  23. package/dist/facade/text.d.ts.map +1 -0
  24. package/dist/facade/text.js +234 -0
  25. package/dist/index.d.ts +36 -45
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +28 -38
  28. package/dist/provider.d.ts +34 -0
  29. package/dist/provider.d.ts.map +1 -0
  30. package/dist/provider.js +33 -0
  31. package/dist/providers/onnx/provider.d.ts.map +1 -1
  32. package/dist/providers/onnx/provider.js +1 -0
  33. package/dist/providers/openai/provider.d.ts +41 -11
  34. package/dist/providers/openai/provider.d.ts.map +1 -1
  35. package/dist/providers/openai/provider.js +168 -58
  36. package/dist/web-sdk.d.ts +21 -3
  37. package/dist/web-sdk.d.ts.map +1 -1
  38. package/dist/web-sdk.js +23 -6
  39. package/package.json +7 -3
  40. package/src/core/engine.ts +114 -35
  41. package/src/core/errors.ts +35 -1
  42. package/src/core/model-profiles.ts +70 -0
  43. package/src/core/provenance.ts +90 -0
  44. package/src/core/provider.ts +13 -0
  45. package/src/core/route-planner.ts +3 -1
  46. package/src/core/router.ts +12 -3
  47. package/src/facade/text.ts +349 -0
  48. package/src/index.ts +49 -95
  49. package/src/provider.ts +77 -0
  50. package/src/providers/onnx/provider.ts +1 -0
  51. package/src/providers/openai/provider.ts +232 -68
  52. package/src/web-sdk.ts +47 -10
@@ -19,6 +19,14 @@ import {
19
19
  } from "./errors.js";
20
20
  import { type TelemetryEvent, type TelemetryService, NoOpTelemetryService } from "./telemetry.js";
21
21
  import { EventGate } from "./event-gate.js";
22
+ import {
23
+ AUTO_MODEL_PROFILE,
24
+ type ModelProfiles,
25
+ type ResolvedModelProfile,
26
+ resolveModelProfile,
27
+ validateModelProfiles
28
+ } from "./model-profiles.js";
29
+ import { ProvenanceRecorder } from "./provenance.js";
22
30
 
23
31
  /**
24
32
  * @deprecated Renamed to `StreamRun`, which is the name the generated
@@ -27,6 +35,22 @@ import { EventGate } from "./event-gate.js";
27
35
  */
28
36
  export type StreamHandleResult = StreamRun;
29
37
 
38
+ /**
39
+ * Optional engine configuration.
40
+ */
41
+ export interface IndeRunOptions {
42
+ /**
43
+ * Telemetry listener. Defaults to the host's telemetry service, then to a no-op.
44
+ */
45
+ telemetryService?: TelemetryService;
46
+ /**
47
+ * Named model profiles requests can route within (`TaskRequest.modelProfile`).
48
+ * Each maps a profile name to an ordered list of target (provider instance) ids.
49
+ * `"auto"` is built in and cannot be redefined. See `docs/architecture/api-layering.md`.
50
+ */
51
+ modelProfiles?: ModelProfiles;
52
+ }
53
+
30
54
  /**
31
55
  * Main orchestrator SDK entrypoint class.
32
56
  * Validates tasks, executes routing rules based on request constraints and connectivity,
@@ -35,21 +59,48 @@ export type StreamHandleResult = StreamRun;
35
59
  export class IndeRun implements IndeRunApi {
36
60
  private router: Router;
37
61
  private telemetryService: TelemetryService;
62
+ private modelProfiles: ModelProfiles;
38
63
 
39
64
  /**
40
65
  * Initializes the IndeRun instance.
41
66
  * @param registry - Registry filled with active providers.
42
67
  * @param hostServices - Services wrapping OS interfaces and indicators.
43
- * @param telemetryService - Optional custom telemetry service listener.
68
+ * @param options - Optional telemetry listener and model profiles.
69
+ * @throws {Error} If a model profile is malformed (see {@link validateModelProfiles}).
44
70
  */
45
71
  constructor(
46
72
  private registry: ProviderRegistry,
47
73
  private hostServices: HostServices,
48
- telemetryService?: TelemetryService
74
+ options: IndeRunOptions = {}
49
75
  ) {
50
76
  this.router = new Router(this.registry);
51
77
  this.telemetryService =
52
- telemetryService || this.hostServices.telemetry || new NoOpTelemetryService();
78
+ options.telemetryService || this.hostServices.telemetry || new NoOpTelemetryService();
79
+ this.modelProfiles = options.modelProfiles ?? {};
80
+ validateModelProfiles(this.modelProfiles);
81
+ }
82
+
83
+ /**
84
+ * Validates the request against the schema and resolves its model profile.
85
+ * Both failures are request validation errors: they are reported before any
86
+ * provider is probed, with the offending fields in `details.validationIssues`.
87
+ */
88
+ private validateRequest(request: TaskRequest, runId: string): ResolvedModelProfile {
89
+ const validationIssues = getTaskRequestValidationIssues(request);
90
+ if (validationIssues.length === 0) {
91
+ const resolved = resolveModelProfile(this.modelProfiles, request.modelProfile);
92
+ if (resolved.known) {
93
+ return resolved.profile;
94
+ }
95
+ validationIssues.push({
96
+ path: "/modelProfile",
97
+ message: `unknown model profile "${request.modelProfile}"; configure it in the engine's modelProfiles`
98
+ });
99
+ }
100
+ const message = `Validation failed for TaskRequest: ${validationIssues
101
+ .map((i) => `${i.path} - ${i.message}`)
102
+ .join("; ")}`;
103
+ throw createInternal(message, { runId, details: { validationIssues } });
53
104
  }
54
105
 
55
106
  /**
@@ -81,6 +132,7 @@ export class IndeRun implements IndeRunApi {
81
132
  rejectedProviders: routeSelection.routePlan.rejectedProviders,
82
133
  fallbackAvailable: providerCount > 1,
83
134
  taskKind: request.task.kind,
135
+ modelProfile: request.modelProfile ?? "auto",
84
136
  constraints: request.constraints ?? null,
85
137
  preferences: request.preferences ?? null,
86
138
  explanation: routeSelection.explanation
@@ -137,23 +189,21 @@ export class IndeRun implements IndeRunApi {
137
189
  const startTime = this.now();
138
190
 
139
191
  const runId = request.requestId || `run_${Math.random().toString(36).substring(2, 11)}`;
192
+ const provenance = new ProvenanceRecorder(request.modelProfile ?? AUTO_MODEL_PROFILE);
140
193
 
141
194
  try {
142
- // 1. Validate request payload using schema contracts
143
- const validationIssues = getTaskRequestValidationIssues(request);
144
- if (validationIssues.length > 0) {
145
- const message = `Validation failed for TaskRequest: ${validationIssues
146
- .map((i) => `${i.path} - ${i.message}`)
147
- .join("; ")}`;
148
- throw createInternal(message, {
149
- runId,
150
- details: { validationIssues }
151
- });
152
- }
153
-
154
- // 2. Select the route based on constraints and host capabilities
155
- const routeSelection = await this.router.selectRoute(request, this.hostServices, "run");
195
+ // 1. Validate request payload using schema contracts and resolve its model profile
196
+ const modelProfile = this.validateRequest(request, runId);
197
+
198
+ // 2. Select the route based on constraints, model profile, and host capabilities
199
+ const routeSelection = await this.router.selectRoute(
200
+ request,
201
+ this.hostServices,
202
+ "run",
203
+ modelProfile
204
+ );
156
205
  const providers = [routeSelection.provider, ...routeSelection.fallbackProviders];
206
+ provenance.routed(routeSelection.routePlan);
157
207
 
158
208
  this.emitRouteDecided(runId, request, routeSelection, providers.length);
159
209
 
@@ -179,6 +229,8 @@ export class IndeRun implements IndeRunApi {
179
229
  providerUsed: providerId,
180
230
  totalMs
181
231
  };
232
+ provenance.attempted(providerId, "succeeded");
233
+ result.provenance = provenance.build(provider.describe(), result.telemetry.reportedModel);
182
234
 
183
235
  this.safeEmit({
184
236
  type: "attempt_succeeded",
@@ -194,7 +246,7 @@ export class IndeRun implements IndeRunApi {
194
246
 
195
247
  return result;
196
248
  } catch (err) {
197
- lastError = toIndeRunException(err, {
249
+ const attemptError = toIndeRunException(err, {
198
250
  providerId,
199
251
  runId,
200
252
  details: {
@@ -203,6 +255,8 @@ export class IndeRun implements IndeRunApi {
203
255
  routePlan: routeSelection.routePlan
204
256
  }
205
257
  });
258
+ provenance.attempted(providerId, "failed", attemptError.errorClass);
259
+ lastError = attemptError;
206
260
  }
207
261
  }
208
262
 
@@ -213,14 +267,14 @@ export class IndeRun implements IndeRunApi {
213
267
  details: {
214
268
  totalMs,
215
269
  attemptedProviderIds,
216
- fallbackOccurred: providers.length > 1,
270
+ fallbackOccurred: attemptedProviderIds.length > 1,
217
271
  routePlan: routeSelection.routePlan
218
272
  }
219
273
  });
220
274
 
221
275
  throw exception;
222
276
  } catch (error) {
223
- const exception = toIndeRunException(error, {
277
+ const normalized = toIndeRunException(error, {
224
278
  runId,
225
279
  details: {
226
280
  totalMs:
@@ -229,6 +283,10 @@ export class IndeRun implements IndeRunApi {
229
283
  : this.now() - startTime
230
284
  }
231
285
  });
286
+ // A routing refusal throws before any attempt; its rejections travel on the details.
287
+ provenance.routingFailed(normalized.details);
288
+ // No attempt produced the result, so the outcome is attributed to no provider.
289
+ const exception = normalized.withProvenance(provenance.build());
232
290
  const endTime = this.now();
233
291
  const totalMs =
234
292
  typeof (exception as { details?: { totalMs?: number } }).details?.totalMs === "number"
@@ -273,13 +331,13 @@ export class IndeRun implements IndeRunApi {
273
331
  const startTime = this.now();
274
332
  const now = () => this.now();
275
333
  const runId = request.requestId || `run_${Math.random().toString(36).substring(2, 11)}`;
334
+ const provenance = new ProvenanceRecorder(request.modelProfile ?? AUTO_MODEL_PROFILE);
276
335
 
277
- const validationIssues = getTaskRequestValidationIssues(request);
278
- if (validationIssues.length > 0) {
279
- const message = `Validation failed for TaskRequest: ${validationIssues
280
- .map((i) => `${i.path} - ${i.message}`)
281
- .join("; ")}`;
282
- throw createInternal(message, { runId, details: { validationIssues } });
336
+ let modelProfile: ResolvedModelProfile;
337
+ try {
338
+ modelProfile = this.validateRequest(request, runId);
339
+ } catch (error) {
340
+ throw toIndeRunException(error, { runId }).withProvenance(provenance.build());
283
341
  }
284
342
 
285
343
  // Route selection failures are the one class of stream error that surfaces as a
@@ -287,11 +345,19 @@ export class IndeRun implements IndeRunApi {
287
345
  // runId — there is no handle for the caller to correlate them with otherwise.
288
346
  let routeSelection: RouteSelection;
289
347
  try {
290
- routeSelection = await this.router.selectRoute(request, this.hostServices, "stream");
348
+ routeSelection = await this.router.selectRoute(
349
+ request,
350
+ this.hostServices,
351
+ "stream",
352
+ modelProfile
353
+ );
291
354
  } catch (error) {
292
- throw toIndeRunException(error, { runId });
355
+ const exception = toIndeRunException(error, { runId });
356
+ provenance.routingFailed(exception.details);
357
+ throw exception.withProvenance(provenance.build());
293
358
  }
294
359
  const providers = [routeSelection.provider, ...routeSelection.fallbackProviders];
360
+ provenance.routed(routeSelection.routePlan);
295
361
 
296
362
  this.emitRouteDecided(runId, request, routeSelection, providers.length);
297
363
 
@@ -322,15 +388,22 @@ export class IndeRun implements IndeRunApi {
322
388
  const attemptedProviderIds: string[] = [];
323
389
  let committed = false;
324
390
 
325
- const cancelledOutcome = (partialText: string): StreamTerminalOutcome => {
391
+ const cancelledOutcome = (
392
+ partialText: string,
393
+ cancelledProvider?: ProviderAdapter
394
+ ): StreamTerminalOutcome => {
326
395
  const reason =
327
396
  typeof controller.signal.reason === "string" ? controller.signal.reason : undefined;
397
+ if (cancelledProvider !== undefined) {
398
+ provenance.attempted(cancelledProvider.describe().id, "cancelled");
399
+ }
328
400
  return {
329
401
  outcome: "cancelled",
330
402
  runId,
331
403
  schemaVersion: "1.0",
332
404
  partialText,
333
- ...(reason !== undefined ? { reason } : {})
405
+ ...(reason !== undefined ? { reason } : {}),
406
+ provenance: provenance.build(cancelledProvider?.describe())
334
407
  };
335
408
  };
336
409
 
@@ -387,6 +460,7 @@ export class IndeRun implements IndeRunApi {
387
460
  }
388
461
 
389
462
  // ev.kind === "done"
463
+ provenance.attempted(providerId, "succeeded");
390
464
  const outcome: StreamTerminalOutcome = {
391
465
  outcome: "completed",
392
466
  runId,
@@ -394,7 +468,8 @@ export class IndeRun implements IndeRunApi {
394
468
  finalText: ev.finalText,
395
469
  telemetry: { providerUsed: providerId, totalMs: now() - startTime },
396
470
  ...(ev.finishReason !== undefined ? { finishReason: ev.finishReason } : {}),
397
- ...(ev.usage !== undefined ? { usage: ev.usage } : {})
471
+ ...(ev.usage !== undefined ? { usage: ev.usage } : {}),
472
+ provenance: provenance.build(provider.describe(), ev.reportedModel)
398
473
  };
399
474
  const terminal = gate.terminate(outcome, now());
400
475
  safeEmit({
@@ -413,7 +488,7 @@ export class IndeRun implements IndeRunApi {
413
488
  }
414
489
 
415
490
  if (controller.signal.aborted) {
416
- const terminal = gate.terminate(cancelledOutcome(partialText), now());
491
+ const terminal = gate.terminate(cancelledOutcome(partialText, provider), now());
417
492
  safeEmit({
418
493
  type: "stream_cancelled",
419
494
  runId,
@@ -428,7 +503,7 @@ export class IndeRun implements IndeRunApi {
428
503
  throw new Error(`Provider '${providerId}' stream ended without a terminal event.`);
429
504
  } catch (err) {
430
505
  if (controller.signal.aborted) {
431
- const terminal = gate.terminate(cancelledOutcome(partialText), now());
506
+ const terminal = gate.terminate(cancelledOutcome(partialText, provider), now());
432
507
  safeEmit({
433
508
  type: "stream_cancelled",
434
509
  runId,
@@ -444,6 +519,7 @@ export class IndeRun implements IndeRunApi {
444
519
  runId,
445
520
  details: { attemptedProviderIds }
446
521
  });
522
+ provenance.attempted(providerId, "failed", exception.errorClass);
447
523
 
448
524
  if (!committed) {
449
525
  safeEmit({
@@ -459,12 +535,14 @@ export class IndeRun implements IndeRunApi {
459
535
  continue;
460
536
  }
461
537
 
538
+ // Committed: the failure belongs to this provider, not to the chain.
462
539
  const outcome: StreamTerminalOutcome = {
463
540
  outcome: "error",
464
541
  runId,
465
542
  schemaVersion: "1.0",
466
543
  error: exception.toContractError(),
467
- partialText
544
+ partialText,
545
+ provenance: provenance.build(provider.describe())
468
546
  };
469
547
  const terminal = gate.terminate(outcome, now());
470
548
  safeEmit({
@@ -505,7 +583,8 @@ export class IndeRun implements IndeRunApi {
505
583
  runId,
506
584
  schemaVersion: "1.0",
507
585
  error: exception.toContractError(),
508
- partialText: ""
586
+ partialText: "",
587
+ provenance: provenance.build()
509
588
  };
510
589
  const terminal = gate.terminate(outcome, now());
511
590
  safeEmit({
@@ -1,4 +1,8 @@
1
- import type { IndeRunError, IndeRunErrorClass } from "@independo/inderun-contracts";
1
+ import type {
2
+ ExecutionProvenance,
3
+ IndeRunError,
4
+ IndeRunErrorClass
5
+ } from "@independo/inderun-contracts";
2
6
 
3
7
  /**
4
8
  * Parameter structure required to initialize an IndeRunException.
@@ -32,6 +36,10 @@ export interface IndeRunExceptionParams {
32
36
  * Rich contextual metadata regarding the error.
33
37
  */
34
38
  details?: Record<string, unknown>;
39
+ /**
40
+ * How the failed execution played out, attached by the engine.
41
+ */
42
+ provenance?: ExecutionProvenance;
35
43
  }
36
44
 
37
45
  /**
@@ -53,6 +61,8 @@ export class IndeRunException extends Error {
53
61
  readonly retryAfterMs?: number;
54
62
  /** Custom details and metadata context. */
55
63
  readonly details?: Record<string, unknown>;
64
+ /** How the failed execution played out: attempts, rejections, the provider committed to. */
65
+ readonly provenance?: ExecutionProvenance;
56
66
 
57
67
  constructor(params: IndeRunExceptionParams) {
58
68
  super(params.message);
@@ -63,6 +73,7 @@ export class IndeRunException extends Error {
63
73
  if (params.retryable !== undefined) this.retryable = params.retryable;
64
74
  if (params.retryAfterMs !== undefined) this.retryAfterMs = params.retryAfterMs;
65
75
  if (params.details !== undefined) this.details = params.details;
76
+ if (params.provenance !== undefined) this.provenance = params.provenance;
66
77
 
67
78
  Object.setPrototypeOf(this, IndeRunException.prototype);
68
79
  }
@@ -81,8 +92,28 @@ export class IndeRunException extends Error {
81
92
  if (this.retryable !== undefined) err.retryable = this.retryable;
82
93
  if (this.retryAfterMs !== undefined) err.retryAfterMs = this.retryAfterMs;
83
94
  if (this.details !== undefined) err.details = this.details;
95
+ if (this.provenance !== undefined) err.provenance = this.provenance;
84
96
  return err;
85
97
  }
98
+
99
+ /**
100
+ * A copy of this exception carrying `provenance`. The engine attaches provenance once it
101
+ * knows how the whole execution ended, replacing anything set earlier.
102
+ */
103
+ withProvenance(provenance: ExecutionProvenance): IndeRunException {
104
+ return new IndeRunException({ ...this.params(), provenance });
105
+ }
106
+
107
+ private params(): IndeRunExceptionParams {
108
+ const params: IndeRunExceptionParams = { errorClass: this.errorClass, message: this.message };
109
+ if (this.runId !== undefined) params.runId = this.runId;
110
+ if (this.providerId !== undefined) params.providerId = this.providerId;
111
+ if (this.retryable !== undefined) params.retryable = this.retryable;
112
+ if (this.retryAfterMs !== undefined) params.retryAfterMs = this.retryAfterMs;
113
+ if (this.details !== undefined) params.details = this.details;
114
+ if (this.provenance !== undefined) params.provenance = this.provenance;
115
+ return params;
116
+ }
86
117
  }
87
118
 
88
119
  /**
@@ -189,6 +220,9 @@ export function toIndeRunException(
189
220
  };
190
221
  }
191
222
 
223
+ const provenance = error.provenance ?? fallbackParams.provenance;
224
+ if (provenance !== undefined) mergedParams.provenance = provenance;
225
+
192
226
  return new IndeRunException(mergedParams);
193
227
  }
194
228
  return error;
@@ -0,0 +1,70 @@
1
+ import type { SharedPlannerInput } from "./route-planner.js";
2
+
3
+ /**
4
+ * The built-in profile: every registered target, ranked by constraints and
5
+ * preferences alone. Requests that name no profile route within it.
6
+ */
7
+ export const AUTO_MODEL_PROFILE = "auto";
8
+
9
+ /**
10
+ * Application-defined model profiles: a profile name mapped to an ordered list of
11
+ * target ids (provider instance ids, each bound to one model). Requests name a
12
+ * profile through `TaskRequest.modelProfile`; the route planner treats the list as
13
+ * an allow-list and ranks its entries after placement and before preferences.
14
+ * Ids that name no registered provider are ignored by the planner.
15
+ */
16
+ export type ModelProfiles = Readonly<Record<string, readonly string[]>>;
17
+
18
+ /** A resolved profile as the shared planner consumes it; absent means `auto`. */
19
+ export type ResolvedModelProfile = SharedPlannerInput["modelProfile"];
20
+
21
+ /**
22
+ * Checks profile configuration once, when the engine is constructed, so a
23
+ * malformed profile is a setup error rather than a per-request surprise.
24
+ * @throws {Error} When a profile is named `auto`, is empty, or lists a target twice.
25
+ */
26
+ export function validateModelProfiles(profiles: ModelProfiles): void {
27
+ for (const [name, targetIds] of Object.entries(profiles)) {
28
+ if (name.length === 0) {
29
+ throw new Error("Model profile names must be non-empty.");
30
+ }
31
+ if (name === AUTO_MODEL_PROFILE) {
32
+ throw new Error(
33
+ `"${AUTO_MODEL_PROFILE}" is the built-in model profile and cannot be redefined.`
34
+ );
35
+ }
36
+ if (targetIds.length === 0) {
37
+ throw new Error(`Model profile "${name}" must list at least one target id.`);
38
+ }
39
+ const seen = new Set<string>();
40
+ for (const id of targetIds) {
41
+ if (id.length === 0) {
42
+ throw new Error(`Model profile "${name}" lists an empty target id.`);
43
+ }
44
+ if (seen.has(id)) {
45
+ throw new Error(`Model profile "${name}" lists target "${id}" more than once.`);
46
+ }
47
+ seen.add(id);
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Resolves a request's profile name against the configured profiles.
54
+ * @returns `{ known: true }` with the planner input (undefined for `auto`), or
55
+ * `{ known: false }` when the name matches no configured profile.
56
+ */
57
+ export function resolveModelProfile(
58
+ profiles: ModelProfiles,
59
+ name: string | undefined
60
+ ): { known: true; profile: ResolvedModelProfile } | { known: false } {
61
+ if (name === undefined || name === AUTO_MODEL_PROFILE) {
62
+ return { known: true, profile: undefined };
63
+ }
64
+ if (!Object.hasOwn(profiles, name)) {
65
+ return { known: false };
66
+ }
67
+ // validateModelProfiles guarantees every configured profile is non-empty.
68
+ const [first, ...rest] = profiles[name]!;
69
+ return { known: true, profile: { name, targetIds: [first!, ...rest] } };
70
+ }
@@ -0,0 +1,90 @@
1
+ import type { ExecutionProvenance, IndeRunErrorClass } from "@independo/inderun-contracts";
2
+ import type { ProviderDescriptor } from "./provider.js";
3
+ import type { SharedPlannerRoutePlan } from "./route-planner.js";
4
+ import { AUTO_MODEL_PROFILE } from "./model-profiles.js";
5
+
6
+ type ExecutionProvenanceAttempt = ExecutionProvenance["attempts"][number];
7
+ type RouteRejection = ExecutionProvenance["routeRejections"][number];
8
+
9
+ /**
10
+ * Accumulates what one execution did -- each attempt and how it ended -- and renders it as the
11
+ * normalized {@link ExecutionProvenance} carried on results, terminal outcomes, and errors.
12
+ *
13
+ * Everything recorded is either observed by the engine (attempts, their outcomes) or declared or
14
+ * reported by a provider (descriptor type and model, the model a response names). Nothing is
15
+ * inferred: an unknown is left absent or reported as `unknown`.
16
+ */
17
+ export class ProvenanceRecorder {
18
+ private readonly attempts: ExecutionProvenanceAttempt[] = [];
19
+ private routeRejections: RouteRejection[] = [];
20
+
21
+ constructor(private readonly modelProfile: string = AUTO_MODEL_PROFILE) {}
22
+
23
+ /** Records the planner's rejections; codes only, never the provider-supplied messages. */
24
+ routed(routePlan: SharedPlannerRoutePlan): void {
25
+ this.routeRejections = routeRejectionsFrom(routePlan.rejectedProviders);
26
+ }
27
+
28
+ /** Records the rejections a routing failure carried out on its exception's details. */
29
+ routingFailed(details: Record<string, unknown> | undefined): void {
30
+ const rejected = details?.rejectedProviders;
31
+ if (Array.isArray(rejected)) {
32
+ this.routeRejections = routeRejectionsFrom(
33
+ rejected as SharedPlannerRoutePlan["rejectedProviders"]
34
+ );
35
+ }
36
+ }
37
+
38
+ /** Records how one provider attempt ended. */
39
+ attempted(
40
+ providerId: string,
41
+ outcome: ExecutionProvenanceAttempt["outcome"],
42
+ errorClass?: IndeRunErrorClass
43
+ ): void {
44
+ this.attempts.push({
45
+ providerId,
46
+ outcome,
47
+ ...(errorClass !== undefined ? { errorClass } : {})
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Renders the record. `attributedTo` is the provider the outcome belongs to -- the one that
53
+ * produced the result, the one a stream was committed to, or the one running at cancellation --
54
+ * and is absent when no provider was reached or every attempt failed before a commit.
55
+ */
56
+ build(attributedTo?: ProviderDescriptor, reportedModel?: string): ExecutionProvenance {
57
+ return {
58
+ schemaVersion: "1.0",
59
+ modelProfile: this.modelProfile,
60
+ ...(attributedTo !== undefined ? { providerId: attributedTo.id } : {}),
61
+ locality: attributedTo !== undefined ? localityOf(attributedTo) : "unknown",
62
+ ...(attributedTo?.model !== undefined ? { configuredModel: attributedTo.model } : {}),
63
+ ...(attributedTo !== undefined && reportedModel !== undefined ? { reportedModel } : {}),
64
+ attemptCount: this.attempts.length,
65
+ fallbackOccurred: this.attempts.length > 1,
66
+ attempts: [...this.attempts],
67
+ routeRejections: [...this.routeRejections]
68
+ };
69
+ }
70
+ }
71
+
72
+ function localityOf(descriptor: ProviderDescriptor): ExecutionProvenance["locality"] {
73
+ switch (descriptor.type) {
74
+ case "local":
75
+ return "on_device";
76
+ case "edge":
77
+ return "edge";
78
+ case "cloud":
79
+ return "cloud";
80
+ }
81
+ }
82
+
83
+ function routeRejectionsFrom(
84
+ rejected: SharedPlannerRoutePlan["rejectedProviders"]
85
+ ): RouteRejection[] {
86
+ return rejected.map((provider) => ({
87
+ providerId: provider.providerId,
88
+ codes: provider.reasons.map((reason) => reason.code)
89
+ }));
90
+ }
@@ -19,6 +19,14 @@ export interface ProviderDescriptor {
19
19
  * Execution target type: 'local' (on-device), 'edge', or 'cloud'.
20
20
  */
21
21
  type: "local" | "edge" | "cloud";
22
+ /**
23
+ * The model this provider instance is bound to, when it can name one. A provider
24
+ * instance bound to one model is a *target* that model profiles list by `id`.
25
+ * Cloud providers report the model id they send; platform-managed models report
26
+ * an opaque platform alias rather than a guessed weight name. Absent means the
27
+ * provider cannot name its model.
28
+ */
29
+ model?: string;
22
30
  /**
23
31
  * Connection transport layer protocol.
24
32
  */
@@ -188,6 +196,11 @@ export type ProviderStreamEvent =
188
196
  */
189
197
  finishReason?: TaskResult["finishReason"];
190
198
  usage?: TaskResult["usage"];
199
+ /**
200
+ * The model identifier the provider's backend reported for this run, when it reports
201
+ * one. Carried into the terminal outcome's provenance; never inferred from configuration.
202
+ */
203
+ reportedModel?: string;
191
204
  }
192
205
  | { kind: "error"; error: unknown };
193
206
 
@@ -177,7 +177,8 @@ export function buildSharedPlannerInput(
177
177
  request: TaskRequest,
178
178
  snapshots: ProviderRuntimeSnapshot[],
179
179
  networkOnline: boolean,
180
- interactionMode: InteractionMode = "run"
180
+ interactionMode: InteractionMode = "run",
181
+ modelProfile?: SharedPlannerInput["modelProfile"]
181
182
  ): SharedPlannerInput {
182
183
  const constraints = request.constraints ?? {};
183
184
  const preferences = request.preferences ?? {};
@@ -196,6 +197,7 @@ export function buildSharedPlannerInput(
196
197
  preferences: {
197
198
  optimizeFor: preferences.optimizeFor ?? "balanced"
198
199
  },
200
+ ...(modelProfile ? { modelProfile } : {}),
199
201
  providers: snapshots.map((snapshot) => ({
200
202
  descriptor: snapshot.descriptor,
201
203
  capabilities: snapshot.capabilities
@@ -14,6 +14,7 @@ import {
14
14
  type InteractionMode,
15
15
  type ProviderRuntimeSnapshot,
16
16
  type RoutePlanner,
17
+ type SharedPlannerInput,
17
18
  type SharedPlannerRoutePlan,
18
19
  WasmRoutePlanner
19
20
  } from "./route-planner.js";
@@ -63,17 +64,25 @@ export class Router {
63
64
  *
64
65
  * The requested `interactionMode` is a planning input, not a post-filter: the
65
66
  * shared planner rejects providers that cannot satisfy it and says why, so the
66
- * whole returned chain is mode-compatible.
67
+ * whole returned chain is mode-compatible. The resolved model profile is a
68
+ * planning input in the same way: the planner rejects targets outside it.
67
69
  */
68
70
  async selectRoute(
69
71
  request: TaskRequest,
70
72
  hostServices: HostServices,
71
- interactionMode: InteractionMode = "run"
73
+ interactionMode: InteractionMode = "run",
74
+ modelProfile?: SharedPlannerInput["modelProfile"]
72
75
  ): Promise<RouteSelection> {
73
76
  const online = await hostServices.connectivity.isOnline();
74
77
  const snapshots = await collectProviderRuntimeSnapshots(this.registry.list(), hostServices);
75
78
 
76
- const planInput = buildSharedPlannerInput(request, snapshots, online, interactionMode);
79
+ const planInput = buildSharedPlannerInput(
80
+ request,
81
+ snapshots,
82
+ online,
83
+ interactionMode,
84
+ modelProfile
85
+ );
77
86
  const outcome = await this.planner.planRoute(planInput);
78
87
 
79
88
  if (!outcome.routePlan) {