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

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 (47) hide show
  1. package/README.md +16 -7
  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 +12 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/providers/onnx/provider.d.ts.map +1 -1
  29. package/dist/providers/onnx/provider.js +1 -0
  30. package/dist/providers/openai/provider.d.ts.map +1 -1
  31. package/dist/providers/openai/provider.js +18 -0
  32. package/dist/web-sdk.d.ts +19 -2
  33. package/dist/web-sdk.d.ts.map +1 -1
  34. package/dist/web-sdk.js +10 -2
  35. package/package.json +3 -3
  36. package/src/core/engine.ts +114 -35
  37. package/src/core/errors.ts +35 -1
  38. package/src/core/model-profiles.ts +70 -0
  39. package/src/core/provenance.ts +90 -0
  40. package/src/core/provider.ts +13 -0
  41. package/src/core/route-planner.ts +3 -1
  42. package/src/core/router.ts +12 -3
  43. package/src/facade/text.ts +349 -0
  44. package/src/index.ts +28 -2
  45. package/src/providers/onnx/provider.ts +1 -0
  46. package/src/providers/openai/provider.ts +21 -0
  47. package/src/web-sdk.ts +31 -5
@@ -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) {
@@ -0,0 +1,349 @@
1
+ import type {
2
+ ExecutionProvenance,
3
+ StreamEvent,
4
+ StreamTerminalOutcome,
5
+ TaskRequest,
6
+ TaskResult
7
+ } from "@independo/inderun-contracts";
8
+ import { IndeRunException } from "../core/errors.js";
9
+ import type { IndeRunApi } from "../core/generated/inderun-api.js";
10
+ import { AUTO_MODEL_PROFILE } from "../core/model-profiles.js";
11
+
12
+ /**
13
+ * The primary text API: `generateText()` and `streamText()`.
14
+ *
15
+ * Both are translation only. Each call compiles to exactly one canonical `TaskRequest`
16
+ * ({@link toTaskRequest}) and calls `run()` or `stream()` on the `IndeRunApi` it is given, so
17
+ * routing, fallback, cancellation, credentials, telemetry, and error normalization are the
18
+ * engine's and identical to a direct call. Written against the generated interface rather than
19
+ * the engine class, so anything that implements `IndeRunApi` -- a bridge included -- gets the
20
+ * same facade. See `docs/architecture/api-layering.md`.
21
+ */
22
+
23
+ type Message = NonNullable<TaskRequest["messages"]>[number];
24
+
25
+ /**
26
+ * Compact execution policy. Reuses the canonical enum values, so moving a call between the
27
+ * facade and `run()` never changes what a constraint means.
28
+ */
29
+ export interface TextExecutionOptions {
30
+ /** `constraints.privacy`: how strict placement is, e.g. `"local_required"`. */
31
+ privacy?: NonNullable<TaskRequest["constraints"]>["privacy"];
32
+ /** `constraints.cloud`: `"forbidden"`, `"allowed"`, or `"required"`. */
33
+ cloud?: NonNullable<TaskRequest["constraints"]>["cloud"];
34
+ /** `constraints.timeoutMs`. */
35
+ timeoutMs?: number;
36
+ /** `preferences.optimizeFor`. */
37
+ optimizeFor?: NonNullable<TaskRequest["preferences"]>["optimizeFor"];
38
+ }
39
+
40
+ interface TextGenerationCommonOptions {
41
+ /**
42
+ * Model profile to route within, as configured on the engine. Omitted or `"auto"` routes
43
+ * across every registered target. Never a vendor model id.
44
+ */
45
+ model?: string;
46
+ /** Instructions sent as a leading `system` message. */
47
+ system?: string;
48
+ maxOutputTokens?: number;
49
+ temperature?: number;
50
+ topP?: number;
51
+ stop?: string[];
52
+ seed?: number;
53
+ execution?: TextExecutionOptions;
54
+ /** Secure-storage slot for provider credentials. Never a raw secret. */
55
+ authContextRef?: string;
56
+ telemetry?: TaskRequest["telemetry"];
57
+ /** Fixes the run id, for correlation. */
58
+ requestId?: string;
59
+ }
60
+
61
+ /** Options for {@link generateText} and {@link streamText}: a prompt or a message list. */
62
+ export type TextGenerationOptions = TextGenerationCommonOptions &
63
+ ({ prompt: string; messages?: never } | { messages: Message[]; prompt?: never });
64
+
65
+ /** What {@link generateText} returns. */
66
+ export interface GeneratedText {
67
+ text: string;
68
+ finishReason: TaskResult["finishReason"];
69
+ usage?: TaskResult["usage"];
70
+ runId: string;
71
+ /** How the run played out; see `ExecutionProvenance`. */
72
+ provenance?: ExecutionProvenance;
73
+ /** The canonical result, for anything the summary above leaves out. */
74
+ taskResult: TaskResult;
75
+ }
76
+
77
+ /** How a {@link streamText} run ended, when it did not fail. */
78
+ export interface StreamedText {
79
+ /** The full text, or the partial text a cancelled run had delivered. */
80
+ text: string;
81
+ /** `"cancelled"` when the run was cancelled; otherwise as the provider reported it. */
82
+ finishReason?: TaskResult["finishReason"];
83
+ usage?: TaskResult["usage"];
84
+ runId: string;
85
+ provenance?: ExecutionProvenance;
86
+ /** The canonical terminal outcome. */
87
+ outcome: StreamTerminalOutcome;
88
+ }
89
+
90
+ /** What {@link streamText} returns. */
91
+ export interface TextStream {
92
+ runId: string;
93
+ /**
94
+ * Text increments, in order. Ends when the run completes or is cancelled; throws the run's
95
+ * `IndeRunException` when it fails, and {@link TextStreamRetractedError} when the provider
96
+ * replaces delivered text (use `fullStream` to render retractions).
97
+ */
98
+ textStream: AsyncIterable<string>;
99
+ /** The canonical `StreamEvent` sequence, terminal included. */
100
+ fullStream: AsyncIterable<StreamEvent>;
101
+ /**
102
+ * Settles with the run's ending: resolves on completion or cancellation, rejects with the
103
+ * run's `IndeRunException` on failure.
104
+ */
105
+ result: Promise<StreamedText>;
106
+ /** Cancels the run; idempotent. */
107
+ cancel(reason?: string): void;
108
+ }
109
+
110
+ /**
111
+ * Thrown by `textStream` when the provider replaces text it already delivered with text that
112
+ * does not extend it, which an append-only text view cannot represent.
113
+ */
114
+ export class TextStreamRetractedError extends Error {
115
+ constructor(readonly runId: string) {
116
+ super(
117
+ `Run ${runId} replaced text it had already delivered; read fullStream to render retractions.`
118
+ );
119
+ this.name = "TextStreamRetractedError";
120
+ }
121
+ }
122
+
123
+ /** The canonical `TaskRequest` a facade call compiles to. */
124
+ export function toTaskRequest(options: TextGenerationOptions): TaskRequest {
125
+ const request: TaskRequest = { schemaVersion: "1.0", task: { kind: "text_to_text" } };
126
+
127
+ if (options.system !== undefined) {
128
+ const conversation =
129
+ options.messages ??
130
+ (options.prompt !== undefined ? [{ role: "user" as const, content: options.prompt }] : []);
131
+ request.messages = [{ role: "system", content: options.system }, ...conversation];
132
+ } else if (options.messages !== undefined) {
133
+ // An empty list is the engine's to reject, with the same validation error as run().
134
+ request.messages = options.messages as NonNullable<TaskRequest["messages"]>;
135
+ } else if (options.prompt !== undefined) {
136
+ request.prompt = options.prompt;
137
+ }
138
+
139
+ if (options.model !== undefined && options.model !== AUTO_MODEL_PROFILE) {
140
+ request.modelProfile = options.model;
141
+ }
142
+
143
+ const generation: NonNullable<TaskRequest["generation"]> = {};
144
+ if (options.maxOutputTokens !== undefined) generation.maxOutputTokens = options.maxOutputTokens;
145
+ if (options.temperature !== undefined) generation.temperature = options.temperature;
146
+ if (options.topP !== undefined) generation.topP = options.topP;
147
+ if (options.stop !== undefined) generation.stop = options.stop;
148
+ if (options.seed !== undefined) generation.seed = options.seed;
149
+ if (Object.keys(generation).length > 0) request.generation = generation;
150
+
151
+ const execution = options.execution ?? {};
152
+ const constraints: NonNullable<TaskRequest["constraints"]> = {};
153
+ if (execution.privacy !== undefined) constraints.privacy = execution.privacy;
154
+ if (execution.cloud !== undefined) constraints.cloud = execution.cloud;
155
+ if (execution.timeoutMs !== undefined) constraints.timeoutMs = execution.timeoutMs;
156
+ if (Object.keys(constraints).length > 0) request.constraints = constraints;
157
+ if (execution.optimizeFor !== undefined) {
158
+ request.preferences = { optimizeFor: execution.optimizeFor };
159
+ }
160
+
161
+ if (options.authContextRef !== undefined) request.authContextRef = options.authContextRef;
162
+ if (options.telemetry !== undefined) request.telemetry = options.telemetry;
163
+ if (options.requestId !== undefined) request.requestId = options.requestId;
164
+
165
+ return request;
166
+ }
167
+
168
+ /**
169
+ * Generates text in one call: `run()` over the canonical request. Not cancellable, for the same
170
+ * reason `run()` is not; use {@link streamText} when cancellation matters.
171
+ * @throws {IndeRunException} Exactly as `run()` does.
172
+ */
173
+ export async function generateText(
174
+ inderun: IndeRunApi,
175
+ options: TextGenerationOptions
176
+ ): Promise<GeneratedText> {
177
+ const taskResult = await inderun.run(toTaskRequest(options));
178
+ return {
179
+ text: taskResult.output.text,
180
+ finishReason: taskResult.finishReason,
181
+ ...(taskResult.usage !== undefined ? { usage: taskResult.usage } : {}),
182
+ runId: taskResult.runId,
183
+ ...(taskResult.provenance !== undefined ? { provenance: taskResult.provenance } : {}),
184
+ taskResult
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Streams text: `stream()` over the canonical request. The run starts immediately and is read
190
+ * once, into a replay buffer, so `textStream`, `fullStream`, and `result` can each be consumed
191
+ * in any order (or not at all) without stalling the others.
192
+ * @throws {IndeRunException} Exactly as `stream()` does: validation and routing refusals reject
193
+ * here, before there is a run.
194
+ */
195
+ export async function streamText(
196
+ inderun: IndeRunApi,
197
+ options: TextGenerationOptions
198
+ ): Promise<TextStream> {
199
+ const run = await inderun.stream(toTaskRequest(options));
200
+ const runId = run.handle.runId;
201
+ const replay = new ReplayBuffer<StreamEvent>();
202
+
203
+ const result = (async (): Promise<StreamedText> => {
204
+ // The terminal event is the last one a run delivers, so it settles everything; a source
205
+ // that stays open after it (a bridge subscription, say) must not hold the result back.
206
+ // Releasing the source is not awaited for the same reason.
207
+ const events = run.events[Symbol.asyncIterator]();
208
+ try {
209
+ for (let next = await events.next(); next.done !== true; next = await events.next()) {
210
+ replay.push(next.value);
211
+ if (next.value.type === "terminal") {
212
+ void events.return?.()?.catch(() => undefined);
213
+ break;
214
+ }
215
+ }
216
+ replay.close();
217
+ } catch (error) {
218
+ replay.fail(error);
219
+ throw error;
220
+ }
221
+ const terminal = replay.items.find((event) => event.type === "terminal");
222
+ if (terminal === undefined) {
223
+ throw new IndeRunException({
224
+ errorClass: "Internal",
225
+ message: `Run ${runId} ended without a terminal event.`,
226
+ runId
227
+ });
228
+ }
229
+ return settle(terminal.payload as unknown as StreamTerminalOutcome);
230
+ })();
231
+ // Consumers may read only one view; an unread rejection must not surface as unhandled.
232
+ result.catch(() => undefined);
233
+
234
+ return {
235
+ runId,
236
+ fullStream: { [Symbol.asyncIterator]: () => replay.iterate() },
237
+ textStream: { [Symbol.asyncIterator]: () => textIncrements(replay.iterate(), runId) },
238
+ result,
239
+ cancel: (reason?: string) => run.cancel(reason)
240
+ };
241
+ }
242
+
243
+ function settle(outcome: StreamTerminalOutcome): StreamedText {
244
+ const provenance = outcome.provenance !== undefined ? { provenance: outcome.provenance } : {};
245
+ switch (outcome.outcome) {
246
+ case "completed":
247
+ return {
248
+ text: outcome.finalText ?? "",
249
+ ...(outcome.finishReason !== undefined ? { finishReason: outcome.finishReason } : {}),
250
+ ...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
251
+ runId: outcome.runId,
252
+ ...provenance,
253
+ outcome
254
+ };
255
+ case "cancelled":
256
+ return {
257
+ text: outcome.partialText ?? "",
258
+ finishReason: "cancelled",
259
+ runId: outcome.runId,
260
+ ...provenance,
261
+ outcome
262
+ };
263
+ case "error": {
264
+ const error = outcome.error!;
265
+ throw new IndeRunException({
266
+ errorClass: error.errorClass,
267
+ message: error.message,
268
+ runId: outcome.runId,
269
+ ...(error.providerId !== undefined ? { providerId: error.providerId } : {}),
270
+ ...(error.retryable !== undefined ? { retryable: error.retryable } : {}),
271
+ ...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
272
+ ...(error.details !== undefined ? { details: error.details } : {}),
273
+ ...(outcome.provenance !== undefined ? { provenance: outcome.provenance } : {})
274
+ });
275
+ }
276
+ }
277
+ }
278
+
279
+ async function* textIncrements(
280
+ events: AsyncIterable<StreamEvent>,
281
+ runId: string
282
+ ): AsyncGenerator<string> {
283
+ let text = "";
284
+ for await (const event of events) {
285
+ if (event.type === "content_delta") {
286
+ const delta = (event.payload as { text: string }).text;
287
+ text += delta;
288
+ yield delta;
289
+ } else if (event.type === "content_snapshot") {
290
+ const snapshot = (event.payload as { text: string }).text;
291
+ if (!snapshot.startsWith(text)) {
292
+ throw new TextStreamRetractedError(runId);
293
+ }
294
+ const suffix = snapshot.slice(text.length);
295
+ text = snapshot;
296
+ if (suffix.length > 0) yield suffix;
297
+ } else if (event.type === "terminal") {
298
+ settle(event.payload as unknown as StreamTerminalOutcome);
299
+ return;
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Holds every item read from a single-use source so any number of readers can iterate it from
306
+ * the start, each at its own pace. Readers that catch up wait for the next item.
307
+ */
308
+ class ReplayBuffer<T> {
309
+ readonly items: T[] = [];
310
+ private done = false;
311
+ private failure: { error: unknown } | undefined;
312
+ private waiters: (() => void)[] = [];
313
+
314
+ push(item: T): void {
315
+ this.items.push(item);
316
+ this.wake();
317
+ }
318
+
319
+ close(): void {
320
+ this.done = true;
321
+ this.wake();
322
+ }
323
+
324
+ fail(error: unknown): void {
325
+ this.failure = { error };
326
+ this.close();
327
+ }
328
+
329
+ async *iterate(): AsyncGenerator<T> {
330
+ let index = 0;
331
+ while (true) {
332
+ if (index < this.items.length) {
333
+ yield this.items[index++]!;
334
+ continue;
335
+ }
336
+ if (this.done) {
337
+ if (this.failure !== undefined) throw this.failure.error;
338
+ return;
339
+ }
340
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
341
+ }
342
+ }
343
+
344
+ private wake(): void {
345
+ const waiters = this.waiters;
346
+ this.waiters = [];
347
+ for (const wake of waiters) wake();
348
+ }
349
+ }
package/src/index.ts CHANGED
@@ -87,7 +87,13 @@ export {
87
87
  } from "./core/errors.js";
88
88
 
89
89
  /** The engine itself, and the app-facing API surface it implements. */
90
- export { IndeRun, type StreamHandleResult } from "./core/engine.js";
90
+ export { IndeRun, type IndeRunOptions, type StreamHandleResult } from "./core/engine.js";
91
+
92
+ /**
93
+ * Model profiles: application-defined names for ordered sets of targets that a
94
+ * request can route within. `"auto"` is built in.
95
+ */
96
+ export { AUTO_MODEL_PROFILE, type ModelProfiles } from "./core/model-profiles.js";
91
97
  export type { IndeRunApi } from "./core/generated/inderun-api.js";
92
98
 
93
99
  /**
@@ -140,8 +146,28 @@ export {
140
146
  type FetchHttpClientOptions
141
147
  } from "./core/browser-host.js";
142
148
 
149
+ /**
150
+ * The primary text API: one canonical request per call, over any `IndeRunApi`.
151
+ * See `docs/architecture/api-layering.md`.
152
+ */
153
+ export {
154
+ generateText,
155
+ streamText,
156
+ toTaskRequest,
157
+ TextStreamRetractedError,
158
+ type GeneratedText,
159
+ type StreamedText,
160
+ type TextExecutionOptions,
161
+ type TextGenerationOptions,
162
+ type TextStream
163
+ } from "./facade/text.js";
164
+
143
165
  /**
144
166
  * The recommended way in: builds host services, registers the providers named in
145
167
  * the options, and returns a ready engine.
146
168
  */
147
- export { createIndeRunWeb, type CreateIndeRunWebOptions } from "./web-sdk.js";
169
+ export {
170
+ createIndeRunWeb,
171
+ type CreateIndeRunWebOptions,
172
+ type OpenAIFactoryOptions
173
+ } from "./web-sdk.js";
@@ -101,6 +101,7 @@ export class OnnxRuntimeWebProvider implements ProviderAdapter {
101
101
  return {
102
102
  id: this.id,
103
103
  type: "local",
104
+ model: this.modelPackage.id,
104
105
  transport: "in_process",
105
106
  supports: {
106
107
  run: true,
@@ -90,6 +90,7 @@ export interface OpenAIProviderOptions {
90
90
  }
91
91
 
92
92
  type OpenAIResponseBody = {
93
+ model?: unknown;
93
94
  output_text?: unknown;
94
95
  output?: unknown;
95
96
  status?: unknown;
@@ -146,6 +147,7 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
146
147
  return {
147
148
  id: this.id,
148
149
  type: "cloud",
150
+ model: this.options.model,
149
151
  transport: "http",
150
152
  streamingStyle: "tokens",
151
153
  supports: {
@@ -324,6 +326,11 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
324
326
  }
325
327
  };
326
328
 
329
+ const reportedModel = getReportedModel(responseBody);
330
+ if (reportedModel !== undefined) {
331
+ result.telemetry.reportedModel = reportedModel;
332
+ }
333
+
327
334
  const usage = getUsage(responseBody);
328
335
  if (usage) {
329
336
  result.usage = usage;
@@ -440,6 +447,10 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
440
447
  if (usage) {
441
448
  done.usage = usage;
442
449
  }
450
+ const reportedModel = getReportedModel(body);
451
+ if (reportedModel !== undefined) {
452
+ done.reportedModel = reportedModel;
453
+ }
443
454
  yield done;
444
455
  return;
445
456
  }
@@ -686,6 +697,16 @@ function getFinishReason(response: OpenAIResponseBody): TaskResult["finishReason
686
697
  return "stop";
687
698
  }
688
699
 
700
+ /**
701
+ * The model id the Responses API echoes on the response object. It names the model that
702
+ * served the request, which can be a dated snapshot of the configured alias.
703
+ */
704
+ function getReportedModel(responseBody: OpenAIResponseBody): string | undefined {
705
+ return typeof responseBody.model === "string" && responseBody.model.length > 0
706
+ ? responseBody.model
707
+ : undefined;
708
+ }
709
+
689
710
  function getUsage(response: OpenAIResponseBody): TaskResult["usage"] | undefined {
690
711
  const usage: TaskResult["usage"] = {};
691
712