ai-runtime-engine 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [1.3.0] — 2026-09-01
9
+
10
+ Token-by-token streaming. Additive and backward compatible — every new field is optional and, with
11
+ streaming unconfigured, behavior is identical to 1.2.0.
12
+
13
+ ### Added
14
+
15
+ - **Streaming responses end-to-end (`response.delta`)** — the reserved streaming event is now emitted for
16
+ real. Opt in with `stream: true` + `onDelta` on `AI.run()`/`RunRequest`, or `runtime.run({ stream: true })`
17
+ in chat mode. Text output only (a JSON/structured request never streams). Implemented through the one
18
+ router: optional `AIProvider.executeStream`, optional wire `buildStreamRequest`/`readDelta` for the
19
+ OpenAI-compatible and Anthropic shapes, and an SSE reader (`callHttpStream`) that buffers across network
20
+ boundaries. `AIResponse` is unchanged and remains the full aggregate — deltas ride the event channel.
21
+ - **Live rendering in the terminal** — the interactive REPL renders answers token-by-token (toggle with
22
+ `/stream`), and one-shot `ai-runtime run --stream` does the same. Streamed text is redacted like every
23
+ egress and is never reprinted after it streams.
24
+
25
+ ### Reliability
26
+
27
+ - A provider whose gateway doesn't support SSE **degrades gracefully to a buffered call** before the first
28
+ token, so enabling streaming never breaks a non-streaming endpoint. A mid-stream drop is categorized and
29
+ sanitized (never leaks the request URL) and falls back to the next candidate.
30
+
8
31
  ## [1.2.0] — 2026-08-31
9
32
 
10
33
  Closes the known gaps between what the CLI/REPL exposed and what the Runtime supported. Additive and
@@ -107,6 +130,7 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
107
130
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
108
131
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
109
132
 
133
+ [1.3.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.3.0
110
134
  [1.2.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.2.0
111
135
  [1.1.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.1
112
136
  [1.1.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.0
package/dist/cli/cli.js CHANGED
@@ -20,7 +20,7 @@ import { skillsCommand } from './commands/skills.js';
20
20
  import { startRepl } from './interactive/repl.js';
21
21
  import { printError } from './render.js';
22
22
  const program = new Command();
23
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.2.0');
23
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.3.0');
24
24
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
25
25
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
26
26
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -35,6 +35,7 @@ program
35
35
  .option(...configOpt)
36
36
  .option('--json', 'print the full RuntimeResult as JSON')
37
37
  .option('--dry-run', 'plan and report what would happen without making any changes')
38
+ .option('--stream', 'stream the answer token-by-token (text output only)')
38
39
  .action((input, o) => runCommand(input, o));
39
40
  program
40
41
  .command('executions')
@@ -7,5 +7,6 @@ export interface RunOptions {
7
7
  config?: string;
8
8
  json?: boolean;
9
9
  dryRun?: boolean;
10
+ stream?: boolean;
10
11
  }
11
12
  export declare function runCommand(input: string, options: RunOptions): Promise<void>;
@@ -3,13 +3,24 @@
3
3
  * the interactive REPL uses. Prints the response (or a structured result with --json).
4
4
  */
5
5
  import { Runtime } from '../../runtime/runtime.js';
6
- import { print } from '../render.js';
6
+ import { print, printChunk } from '../render.js';
7
7
  import { RUNTIME_MODES } from '../../runtime/types.js';
8
8
  export async function runCommand(input, options) {
9
9
  const rt = await Runtime.load({ ...(options.config ? { config: options.config } : {}) });
10
10
  const req = { input };
11
11
  if (options.dryRun)
12
12
  req.dryRun = true;
13
+ // --stream renders tokens live (incompatible with --json, which needs the whole object).
14
+ let streamedAny = false;
15
+ if (options.stream && !options.json) {
16
+ req.stream = true;
17
+ rt.on((e) => {
18
+ if (e.type === 'response.delta') {
19
+ printChunk(e.text);
20
+ streamedAny = true;
21
+ }
22
+ });
23
+ }
13
24
  if (options.mode) {
14
25
  if (!RUNTIME_MODES.includes(options.mode)) {
15
26
  print(`invalid mode '${options.mode}'. valid: ${RUNTIME_MODES.join(', ')}`);
@@ -19,9 +30,11 @@ export async function runCommand(input, options) {
19
30
  req.mode = options.mode;
20
31
  }
21
32
  const result = await rt.run(req);
33
+ if (streamedAny)
34
+ process.stdout.write('\n');
22
35
  if (options.json)
23
36
  return print(JSON.stringify(result, null, 2));
24
- if (result.response?.text)
37
+ if (result.response?.text && !result.response.streamed)
25
38
  print(result.response.text);
26
39
  else if (result.response?.json !== undefined)
27
40
  print(JSON.stringify(result.response.json, null, 2));
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createInterface } from 'node:readline';
6
6
  import { Runtime } from '../../runtime/runtime.js';
7
- import { print, printError } from '../render.js';
7
+ import { print, printChunk, printError } from '../render.js';
8
8
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
9
9
  import { ReplSession } from './session.js';
10
10
  function banner(rt) {
@@ -31,10 +31,16 @@ function progressLine(e) {
31
31
  /** Start the interactive session. Resolves when the user exits (or stdin closes). */
32
32
  export async function startRepl(configPath) {
33
33
  const rt = await Runtime.load({ ...(configPath ? { config: configPath } : {}) });
34
- const session = new ReplSession(rt);
35
- // Live progress: print concise routing/mode lines as they happen during a run. The events are already
36
- // redacted; a throwing observer can never break a run (the emitter swallows sink errors).
34
+ const session = new ReplSession(rt, { streaming: true });
35
+ // Tracks whether the CURRENT line produced any streamed output, so we can close the line cleanly.
36
+ let streamedThisRun = false;
37
+ // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
37
38
  rt.on((e) => {
39
+ if (e.type === 'response.delta') {
40
+ printChunk(e.text);
41
+ streamedThisRun = true;
42
+ return;
43
+ }
38
44
  const line = progressLine(e);
39
45
  if (line)
40
46
  print(line);
@@ -44,14 +50,19 @@ export async function startRepl(configPath) {
44
50
  rl.prompt();
45
51
  for await (const line of rl) {
46
52
  let result;
53
+ streamedThisRun = false;
47
54
  try {
48
55
  result = await session.handle(line);
49
56
  }
50
57
  catch (err) {
58
+ if (streamedThisRun)
59
+ process.stdout.write('\n');
51
60
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
52
61
  rl.prompt();
53
62
  continue;
54
63
  }
64
+ if (streamedThisRun)
65
+ process.stdout.write('\n'); // close the streamed line before printing result lines
55
66
  if (result.clear)
56
67
  process.stdout.write('\x1b[2J\x1b[H');
57
68
  for (const l of result.lines)
@@ -16,7 +16,10 @@ export declare class ReplSession {
16
16
  private viewCache?;
17
17
  private conversationId?;
18
18
  private dryRunMode;
19
- constructor(runtime: Runtime);
19
+ private streaming;
20
+ constructor(runtime: Runtime, opts?: {
21
+ streaming?: boolean;
22
+ });
20
23
  currentMode(): RuntimeMode;
21
24
  private views;
22
25
  handle(raw: string): Promise<HandleResult>;
@@ -42,6 +42,7 @@ const HELP = [
42
42
  ' /cancel <id> cancel an execution',
43
43
  ' /config show the resolved configuration',
44
44
  ' /dry-run toggle dry-run (plan only, no changes)',
45
+ ' /stream toggle token-by-token streaming of answers',
45
46
  ' /clear clear the screen',
46
47
  ' /exit leave the session',
47
48
  '',
@@ -53,8 +54,10 @@ export class ReplSession {
53
54
  viewCache;
54
55
  conversationId;
55
56
  dryRunMode = false;
56
- constructor(runtime) {
57
+ streaming;
58
+ constructor(runtime, opts = {}) {
57
59
  this.runtime = runtime;
60
+ this.streaming = opts.streaming ?? false;
58
61
  }
59
62
  currentMode() {
60
63
  return this.mode;
@@ -133,6 +136,9 @@ export class ReplSession {
133
136
  case 'dryrun':
134
137
  this.dryRunMode = !this.dryRunMode;
135
138
  return { lines: [`dry-run ${this.dryRunMode ? 'ON — plans will be shown, nothing executed' : 'OFF'}`] };
139
+ case 'stream':
140
+ this.streaming = !this.streaming;
141
+ return { lines: [`streaming ${this.streaming ? 'ON — answers render token-by-token' : 'OFF'}`] };
136
142
  case 'memory':
137
143
  return this.memory(args);
138
144
  case 'conversations':
@@ -161,9 +167,10 @@ export class ReplSession {
161
167
  this.conversationId = this.runtime.conversations.start();
162
168
  this.runtime.conversations.append(this.conversationId, 'user', input);
163
169
  }
164
- const result = await this.runtime.run({ input, mode: forceMode ?? this.mode, ...(this.dryRunMode ? { dryRun: true } : {}) });
170
+ const result = await this.runtime.run({ input, mode: forceMode ?? this.mode, ...(this.dryRunMode ? { dryRun: true } : {}), ...(this.streaming ? { stream: true } : {}) });
165
171
  const lines = [];
166
- if (result.response?.text)
172
+ // When the answer already streamed live (response.streamed), don't reprint it.
173
+ if (result.response?.text && !result.response.streamed)
167
174
  lines.push(result.response.text);
168
175
  else if (result.response?.json !== undefined)
169
176
  lines.push(JSON.stringify(result.response.json, null, 2));
@@ -4,4 +4,10 @@
4
4
  * directly from a command.
5
5
  */
6
6
  export declare function print(line: string): void;
7
+ /**
8
+ * Write a streamed chunk WITHOUT a trailing newline, redacted like every other egress. (Redaction is
9
+ * per-chunk, matching the lifecycle emitter; a secret split across chunk boundaries is the known limit of
10
+ * any token stream — but model output never contains the env-var secrets the redactor tracks.)
11
+ */
12
+ export declare function printChunk(chunk: string): void;
7
13
  export declare function printError(line: string): void;
@@ -8,6 +8,14 @@ export function print(line) {
8
8
  // eslint-disable-next-line no-console
9
9
  console.log(redactString(line));
10
10
  }
11
+ /**
12
+ * Write a streamed chunk WITHOUT a trailing newline, redacted like every other egress. (Redaction is
13
+ * per-chunk, matching the lifecycle emitter; a secret split across chunk boundaries is the known limit of
14
+ * any token stream — but model output never contains the env-var secrets the redactor tracks.)
15
+ */
16
+ export function printChunk(chunk) {
17
+ process.stdout.write(redactString(chunk));
18
+ }
11
19
  export function printError(line) {
12
20
  // eslint-disable-next-line no-console
13
21
  console.error(redactString(line));
@@ -19,6 +19,9 @@ export interface FallbackInput {
19
19
  signal?: AbortSignal;
20
20
  clock?: Clock;
21
21
  onAttempt?: (record: AttemptRecord) => void;
22
+ /** Phase-13 streaming: forwarded to each attempt's `executeOnce`; deltas ride this callback, the final
23
+ * aggregate rides the return value. Only meaningful when `template.stream` is set. */
24
+ onDelta?: (chunk: string) => void;
22
25
  /** Optional Phase-6 validation. A failing report drops this candidate and continues (no poisoning). */
23
26
  validate?: (response: AIResponse, model: string) => ValidationReport;
24
27
  /** Optional spend guardrail. When it cannot afford the next call, the run STOPS with BUDGET. */
@@ -34,7 +34,7 @@ export async function runWithFallback(input) {
34
34
  tried += 1;
35
35
  const started = clock.now();
36
36
  const request = buildRequest(input.template, model.id, input.signal);
37
- const outcome = await executeOnce(provider, request);
37
+ const outcome = await executeOnce(provider, request, input.onDelta);
38
38
  const latencyMs = clock.now() - started;
39
39
  input.budget?.recordCall(estCost);
40
40
  if (outcome.ok) {
@@ -13,4 +13,9 @@ export type ExecOutcome = {
13
13
  ok: false;
14
14
  error: AIError;
15
15
  };
16
- export declare function executeOnce(provider: AIProvider, request: AIRequest): Promise<ExecOutcome>;
16
+ /**
17
+ * Run one attempt. When `onDelta` is supplied AND the request asked to stream AND the provider supports
18
+ * `executeStream`, the answer streams token-by-token; otherwise the normal single-shot `execute()` runs.
19
+ * Either way the resolved `AIResponse` is the full aggregate.
20
+ */
21
+ export declare function executeOnce(provider: AIProvider, request: AIRequest, onDelta?: (chunk: string) => void): Promise<ExecOutcome>;
@@ -4,9 +4,16 @@
4
4
  * raw vendor error.
5
5
  */
6
6
  import { AIError, toAIError } from '../fallback/errors.js';
7
- export async function executeOnce(provider, request) {
7
+ /**
8
+ * Run one attempt. When `onDelta` is supplied AND the request asked to stream AND the provider supports
9
+ * `executeStream`, the answer streams token-by-token; otherwise the normal single-shot `execute()` runs.
10
+ * Either way the resolved `AIResponse` is the full aggregate.
11
+ */
12
+ export async function executeOnce(provider, request, onDelta) {
8
13
  try {
9
- const response = await provider.execute(request);
14
+ const response = onDelta && request.stream && provider.executeStream
15
+ ? await provider.executeStream(request, onDelta)
16
+ : await provider.execute(request);
10
17
  return { ok: true, response };
11
18
  }
12
19
  catch (e) {
@@ -22,6 +22,8 @@ export interface RequestTemplate {
22
22
  params?: AIRequest['params'];
23
23
  timeoutMs: number;
24
24
  sensitivity: Sensitivity;
25
+ /** Stream the answer token-by-token (Phase 13). Set only for text output; never for JSON. */
26
+ stream?: boolean;
25
27
  }
26
28
  export interface NormalizeResult {
27
29
  task: NormalizedTask;
@@ -15,6 +15,8 @@ export function buildRequest(template, model, signal) {
15
15
  req.tools = template.tools;
16
16
  if (template.params !== undefined)
17
17
  req.params = template.params;
18
+ if (template.stream)
19
+ req.stream = true;
18
20
  if (signal !== undefined)
19
21
  req.signal = signal;
20
22
  return req;
@@ -31,6 +31,11 @@ export class Router {
31
31
  // Phase 1 — normalize
32
32
  const { task, template } = normalize(req, tasks, config);
33
33
  const pin = { provider: req.provider, model: req.model };
34
+ // Phase 13 — streaming is opt-in and TEXT-ONLY: a JSON/structured request never streams (partial
35
+ // JSON is useless). The delta callback rides `req.onDelta`; the final aggregate rides the return value.
36
+ const wantsJsonOut = template.output?.format === 'json' || template.output?.format === 'structured_output';
37
+ if (req.stream && req.onDelta && !wantsJsonOut)
38
+ template.stream = true;
34
39
  // Team policy (org-level guardrails) merged over per-run constraints.
35
40
  const policy = config.policy;
36
41
  if (policy.requireLocal)
@@ -136,6 +141,7 @@ export class Router {
136
141
  maxFallbacks: config.maxFallbacks,
137
142
  clock: this.clock,
138
143
  onAttempt,
144
+ ...(template.stream && req.onDelta ? { onDelta: req.onDelta } : {}),
139
145
  validate: (response) => validateResponse({ response, ...(template.output ? { output: template.output } : {}), ...(template.tools ? { tools: template.tools } : {}) }),
140
146
  ...(budget ? { budget, costOf } : {}),
141
147
  });
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export type { FetchLike } from './providers/httpClient.js';
20
20
  export { PROVIDER_DEFAULTS } from './config/providerDefaults.js';
21
21
  export { resolveModelMetadata } from './discovery/modelCatalog.js';
22
22
  export { registerWire } from './providers/wire/registry.js';
23
- export type { WireModule, WireShape } from './providers/wire/types.js';
23
+ export type { WireDelta, WireModule, WireShape } from './providers/wire/types.js';
24
24
  export { AIError, isRetryable, statusToCategory, toAIError } from './core/fallback/errors.js';
25
25
  export { capabilitySatisfies, emptyProfile, getCapability, mergeProfiles, profileFromDeclared, rankOf, } from './core/capabilities/evidence.js';
26
26
  export { AGENT_CAPABILITIES, INPUT_MODALITIES, INTELLIGENCE_SKILLS, OUTPUT_MODALITIES, } from './core/capabilities/taxonomy.js';
@@ -11,7 +11,8 @@
11
11
  * caller built, never in anything this function stores or throws.
12
12
  */
13
13
  import type { Clock } from '../util/clock.js';
14
- import type { WireRequest } from './wire/types.js';
14
+ import type { FinishReason } from '../types.js';
15
+ import type { WireDelta, WireRequest } from './wire/types.js';
15
16
  export type FetchLike = typeof fetch;
16
17
  export interface CallHttpInput {
17
18
  build: (jsonMode: boolean) => WireRequest;
@@ -32,3 +33,26 @@ export interface CallHttpResult {
32
33
  jsonModeUsed: boolean;
33
34
  }
34
35
  export declare function callHttp(input: CallHttpInput): Promise<CallHttpResult>;
36
+ export interface CallHttpStreamInput {
37
+ build: (jsonMode: boolean) => WireRequest;
38
+ readDelta: (data: string) => WireDelta;
39
+ onDelta: (text: string) => void;
40
+ providerId: string;
41
+ model: string;
42
+ timeoutMs: number;
43
+ jsonMode: boolean;
44
+ fetchImpl?: FetchLike;
45
+ clock?: Clock;
46
+ signal?: AbortSignal;
47
+ }
48
+ export interface CallHttpStreamResult {
49
+ status: number;
50
+ text: string;
51
+ usage?: {
52
+ inputTokens?: number;
53
+ outputTokens?: number;
54
+ };
55
+ finishReason: FinishReason;
56
+ durationMs: number;
57
+ }
58
+ export declare function callHttpStream(input: CallHttpStreamInput): Promise<CallHttpStreamResult>;
@@ -78,3 +78,96 @@ export async function callHttp(input) {
78
78
  }
79
79
  throw lastError ?? new AIError(`${providerId} request failed`, { category: 'NETWORK', retryable: true, providerId, model });
80
80
  }
81
+ export async function callHttpStream(input) {
82
+ const fetchImpl = input.fetchImpl ?? fetch;
83
+ const clock = input.clock ?? systemClock;
84
+ const { providerId, model } = input;
85
+ const started = clock.now();
86
+ const req = input.build(input.jsonMode);
87
+ // Combine the per-request timeout with any caller abort signal (cancellation).
88
+ const signals = [AbortSignal.timeout(input.timeoutMs)];
89
+ if (input.signal)
90
+ signals.push(input.signal);
91
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
92
+ let res;
93
+ try {
94
+ res = await fetchImpl(req.url, { method: 'POST', headers: req.headers, body: JSON.stringify(req.body), signal });
95
+ }
96
+ catch (e) {
97
+ // undici embeds the full URL in the message — use only the error NAME, never the message.
98
+ const name = e instanceof Error ? e.name : 'Error';
99
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
100
+ throw new AIError(`${providerId} stream request failed (${name})`, { category: isTimeout ? 'TIMEOUT' : 'NETWORK', retryable: true, providerId, model });
101
+ }
102
+ if (!res.ok || !res.body) {
103
+ const text = await res.text().catch(() => '');
104
+ const excerpt = redactString(text.slice(0, 300));
105
+ const category = statusToCategory(res.status);
106
+ throw new AIError(`${providerId} stream returned HTTP ${res.status}: ${excerpt}`, { category, status: res.status, retryable: isRetryable(category), providerId, model });
107
+ }
108
+ const reader = res.body.getReader();
109
+ const decoder = new TextDecoder();
110
+ let buffer = '';
111
+ let text = '';
112
+ let usage;
113
+ let finishReason = 'stop';
114
+ let done = false;
115
+ const handleData = (payload) => {
116
+ if (!payload)
117
+ return;
118
+ const delta = input.readDelta(payload);
119
+ if (delta.text) {
120
+ text += delta.text;
121
+ input.onDelta(delta.text);
122
+ }
123
+ if (delta.usage)
124
+ usage = { ...usage, ...delta.usage };
125
+ if (delta.finishReason)
126
+ finishReason = delta.finishReason;
127
+ if (delta.done)
128
+ done = true;
129
+ };
130
+ try {
131
+ try {
132
+ while (!done) {
133
+ const { value, done: streamDone } = await reader.read();
134
+ if (streamDone)
135
+ break;
136
+ buffer += decoder.decode(value, { stream: true });
137
+ // Process complete lines; keep the trailing partial in the buffer.
138
+ let nl;
139
+ while ((nl = buffer.indexOf('\n')) >= 0) {
140
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
141
+ buffer = buffer.slice(nl + 1);
142
+ if (line.startsWith('data:'))
143
+ handleData(line.slice(5).trim());
144
+ // `event:` / blank / comment (`:`) lines are ignored — the type is in the data JSON.
145
+ if (done)
146
+ break;
147
+ }
148
+ }
149
+ // Flush a final line that had no trailing newline.
150
+ if (!done && buffer.startsWith('data:'))
151
+ handleData(buffer.slice(5).trim());
152
+ }
153
+ finally {
154
+ try {
155
+ await reader.cancel();
156
+ }
157
+ catch {
158
+ /* nothing to do */
159
+ }
160
+ }
161
+ }
162
+ catch (e) {
163
+ // A mid-stream drop/timeout after 200 OK: categorize and sanitize like the initial-fetch path (undici
164
+ // embeds the full URL in the message — use only the error NAME, never the message).
165
+ const name = e instanceof Error ? e.name : 'Error';
166
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
167
+ throw new AIError(`${providerId} stream interrupted (${name})`, { category: isTimeout ? 'TIMEOUT' : 'NETWORK', retryable: true, providerId, model });
168
+ }
169
+ const result = { status: res.status, text, finishReason, durationMs: clock.now() - started };
170
+ if (usage)
171
+ result.usage = usage;
172
+ return result;
173
+ }
@@ -46,4 +46,5 @@ export declare class HttpProvider implements AIProvider {
46
46
  getCapabilities(model: string): Promise<CapabilityProfile>;
47
47
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
48
48
  execute(request: AIRequest): Promise<AIResponse>;
49
+ executeStream(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
49
50
  }
@@ -12,7 +12,7 @@ import { AIError } from '../core/fallback/errors.js';
12
12
  import { emptyProfile } from '../core/capabilities/evidence.js';
13
13
  import { extractJson } from '../util/extractJson.js';
14
14
  import { getWire } from './wire/registry.js';
15
- import { callHttp } from './httpClient.js';
15
+ import { callHttp, callHttpStream } from './httpClient.js';
16
16
  function originOf(url) {
17
17
  try {
18
18
  return new URL(url).origin;
@@ -132,4 +132,70 @@ export class HttpProvider {
132
132
  }
133
133
  return response;
134
134
  }
135
+ async executeStream(request, onDelta) {
136
+ const wire = getWire(this.cfg.wireShape);
137
+ const wantsJson = request.output?.format === 'json' || request.output?.format === 'structured_output';
138
+ // JSON output, or a wire without streaming support, degrades to the normal single-shot path.
139
+ if (wantsJson || !wire.buildStreamRequest || !wire.readDelta)
140
+ return this.execute(request);
141
+ const needsKey = this.cfg.requiresKey !== false;
142
+ const apiKey = this.cfg.credential.use();
143
+ if (needsKey && !apiKey) {
144
+ throw new AIError(`no ${this.cfg.credential.envName ?? 'API key'} in the environment`, { category: 'AUTHENTICATION', retryable: false, providerId: this.id, model: request.model });
145
+ }
146
+ const maxTokens = request.params?.maxTokens ?? 2000;
147
+ const ctx = {
148
+ baseUrl: this.cfg.baseUrl,
149
+ model: request.model,
150
+ maxTokens,
151
+ ...(apiKey ? { apiKey } : {}),
152
+ ...(this.cfg.headers ? { headers: this.cfg.headers } : {}),
153
+ };
154
+ // Graceful degrade: if the stream fails BEFORE any token (a gateway that doesn't do SSE, or 400s on
155
+ // the streaming body), fall back to buffered execute() so a non-streaming endpoint still works — the
156
+ // caller just doesn't get live tokens. Once tokens have flowed we can't degrade (would double-emit),
157
+ // so we rethrow and let the router fall back to a different candidate.
158
+ let emitted = 0;
159
+ let result;
160
+ try {
161
+ result = await callHttpStream({
162
+ build: (jsonMode) => wire.buildStreamRequest(request, ctx, jsonMode),
163
+ readDelta: (data) => wire.readDelta(data),
164
+ onDelta: (chunk) => {
165
+ emitted += 1;
166
+ onDelta(chunk);
167
+ },
168
+ providerId: this.id,
169
+ model: request.model,
170
+ timeoutMs: request.timeoutMs || this.cfg.timeoutMs || 90_000,
171
+ jsonMode: this.jsonMode,
172
+ ...(this.cfg.fetchImpl ? { fetchImpl: this.cfg.fetchImpl } : {}),
173
+ ...(this.cfg.clock ? { clock: this.cfg.clock } : {}),
174
+ ...(request.signal ? { signal: request.signal } : {}),
175
+ });
176
+ }
177
+ catch (e) {
178
+ if (emitted === 0)
179
+ return this.execute(request);
180
+ throw e;
181
+ }
182
+ const response = {
183
+ finishReason: result.finishReason,
184
+ providerId: this.id,
185
+ model: request.model,
186
+ latencyMs: result.durationMs,
187
+ };
188
+ if (result.text)
189
+ response.text = result.text;
190
+ if (result.usage) {
191
+ const inTok = result.usage.inputTokens;
192
+ const outTok = result.usage.outputTokens;
193
+ response.usage = {
194
+ ...(inTok !== undefined ? { inputTokens: inTok } : {}),
195
+ ...(outTok !== undefined ? { outputTokens: outTok } : {}),
196
+ ...(inTok !== undefined && outTok !== undefined ? { totalTokens: inTok + outTok } : {}),
197
+ };
198
+ }
199
+ return response;
200
+ }
135
201
  }
@@ -32,4 +32,7 @@ export declare class MockProvider implements AIProvider {
32
32
  getCapabilities(model: string): Promise<CapabilityProfile>;
33
33
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
34
34
  execute(request: AIRequest): Promise<AIResponse>;
35
+ /** Stream text chunks then resolve with the full aggregate (Phase 13). Failure behaviors still throw. */
36
+ executeStream(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
37
+ private respond;
35
38
  }
@@ -67,11 +67,45 @@ export class MockProvider {
67
67
  }
68
68
  async execute(request) {
69
69
  this.executeCalls += 1;
70
+ return this.respond(request);
71
+ }
72
+ /** Stream text chunks then resolve with the full aggregate (Phase 13). Failure behaviors still throw. */
73
+ async executeStream(request, onDelta) {
74
+ this.executeCalls += 1;
75
+ const behavior = typeof this.behavior === 'function' ? this.behavior(request) : this.behavior;
76
+ // JSON output never streams — fall back to the single-shot response (no deltas).
77
+ const wantsJson = request.output?.format === 'json' || request.output?.format === 'structured_output';
78
+ if (wantsJson || behavior.kind === 'malformed' || behavior.kind === 'timeout' || behavior.kind === 'rate_limit' || behavior.kind === 'server_error' || behavior.kind === 'auth_fail') {
79
+ return this.respond(request); // throws for failure kinds, single-shot for json
80
+ }
81
+ // Stream some chunks, THEN fail mid-stream (exercises the partial-then-fallback path).
82
+ if (behavior.kind === 'stream_then_fail') {
83
+ for (const c of behavior.chunks)
84
+ onDelta(c);
85
+ throw new AIError(`${this.id} dropped mid-stream`, { category: 'PROVIDER', status: 503, providerId: this.id, model: request.model });
86
+ }
87
+ const full = behavior.kind === 'ok_stream' || behavior.kind === 'ok' || behavior.kind === 'ok_text' ? behavior.text ?? `mock(${this.id}/${request.model}) streamed ${request.taskId}` : '';
88
+ const chunks = behavior.kind === 'ok_stream' && behavior.chunks ? behavior.chunks : chunkText(full);
89
+ for (const c of chunks)
90
+ onDelta(c);
91
+ return {
92
+ finishReason: 'stop',
93
+ providerId: this.id,
94
+ model: request.model,
95
+ latencyMs: this.latencyMs,
96
+ text: chunks.join(''),
97
+ usage: { inputTokens: Math.ceil((request.input.text?.length ?? 0) / 4), outputTokens: chunks.length },
98
+ };
99
+ }
100
+ respond(request) {
70
101
  const behavior = typeof this.behavior === 'function' ? this.behavior(request) : this.behavior;
71
102
  const base = { providerId: this.id, model: request.model };
72
103
  switch (behavior.kind) {
73
104
  case 'timeout':
74
105
  throw new AIError(`${this.id} timed out`, { category: 'TIMEOUT', ...base });
106
+ case 'stream_then_fail':
107
+ // Non-streaming caller: no partial output, just the failure.
108
+ throw new AIError(`${this.id} dropped mid-stream`, { category: 'PROVIDER', status: 503, ...base });
75
109
  case 'rate_limit':
76
110
  throw new AIError(`${this.id} rate limited`, { category: 'RATE_LIMIT', status: 429, ...base });
77
111
  case 'server_error':
@@ -116,6 +150,26 @@ export class MockProvider {
116
150
  usage: { inputTokens: 4, outputTokens: 8 },
117
151
  };
118
152
  }
153
+ case 'ok_stream': {
154
+ // Reached only via execute() (non-streaming caller): return the aggregate directly.
155
+ return {
156
+ finishReason: 'stop',
157
+ providerId: this.id,
158
+ model: request.model,
159
+ latencyMs: this.latencyMs,
160
+ text: behavior.chunks ? behavior.chunks.join('') : behavior.text ?? `mock(${this.id}/${request.model}) streamed ${request.taskId}`,
161
+ usage: { inputTokens: 4, outputTokens: 8 },
162
+ };
163
+ }
119
164
  }
120
165
  }
121
166
  }
167
+ /** Split text into small deterministic chunks to simulate token streaming (join(chunks) === text). */
168
+ function chunkText(text, size = 3) {
169
+ if (!text)
170
+ return [];
171
+ const out = [];
172
+ for (let i = 0; i < text.length; i += size)
173
+ out.push(text.slice(i, i + size));
174
+ return out;
175
+ }
@@ -12,6 +12,13 @@ export type MockBehavior = {
12
12
  } | {
13
13
  kind: 'ok_text';
14
14
  text?: string;
15
+ } | {
16
+ kind: 'ok_stream';
17
+ text?: string;
18
+ chunks?: string[];
19
+ } | {
20
+ kind: 'stream_then_fail';
21
+ chunks: string[];
15
22
  } | {
16
23
  kind: 'timeout';
17
24
  } | {
@@ -22,5 +22,11 @@ export interface AIProvider {
22
22
  /** Resolve capabilities for one model. Providers own capability resolution; core never reads the catalog directly. */
23
23
  getCapabilities(model: string): Promise<CapabilityProfile>;
24
24
  execute(request: AIRequest): Promise<AIResponse>;
25
+ /**
26
+ * OPTIONAL streaming execution (Phase 13). Calls `onDelta` with each text chunk and resolves with the
27
+ * full aggregated `AIResponse`. A provider that omits it is still valid — the caller falls back to
28
+ * `execute()`. Text output only; a JSON-mode request should degrade to `execute()`.
29
+ */
30
+ executeStream?(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
25
31
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
26
32
  }
@@ -80,4 +80,38 @@ export const anthropicWire = {
80
80
  }
81
81
  return parsed;
82
82
  },
83
+ buildStreamRequest(req, ctx, jsonMode) {
84
+ const built = anthropicWire.buildRequest(req, ctx, jsonMode);
85
+ built.body.stream = true;
86
+ return built;
87
+ },
88
+ readDelta(data) {
89
+ // Anthropic SSE data payloads are always JSON objects with a `type`. The `event:` lines carry the
90
+ // same type, so we parse the data alone (the caller skips non-`data:` lines).
91
+ let j;
92
+ try {
93
+ j = JSON.parse(data);
94
+ }
95
+ catch {
96
+ return {};
97
+ }
98
+ switch (j.type) {
99
+ case 'content_block_delta':
100
+ return j.delta?.text ? { text: j.delta.text } : {};
101
+ case 'message_start':
102
+ return j.message?.usage?.input_tokens !== undefined ? { usage: { inputTokens: j.message.usage.input_tokens } } : {};
103
+ case 'message_delta': {
104
+ const out = {};
105
+ if (j.usage?.output_tokens !== undefined)
106
+ out.usage = { outputTokens: j.usage.output_tokens };
107
+ if (j.delta?.stop_reason)
108
+ out.finishReason = mapStop(j.delta.stop_reason);
109
+ return out;
110
+ }
111
+ case 'message_stop':
112
+ return { done: true };
113
+ default:
114
+ return {}; // ping / content_block_start / content_block_stop
115
+ }
116
+ },
83
117
  };
@@ -78,4 +78,34 @@ export const openaiWire = {
78
78
  }
79
79
  return parsed;
80
80
  },
81
+ buildStreamRequest(req, ctx, jsonMode) {
82
+ // Same as buildRequest, plus `stream:true` and `stream_options.include_usage` so the final chunk
83
+ // carries token usage (supported by OpenAI and most compatible gateways; harmless where ignored).
84
+ const built = openaiWire.buildRequest(req, ctx, jsonMode);
85
+ const body = built.body;
86
+ body.stream = true;
87
+ body.stream_options = { include_usage: true };
88
+ return built;
89
+ },
90
+ readDelta(data) {
91
+ if (data.trim() === '[DONE]')
92
+ return { done: true };
93
+ let j;
94
+ try {
95
+ j = JSON.parse(data);
96
+ }
97
+ catch {
98
+ return {}; // a partial/keep-alive line — ignore
99
+ }
100
+ const out = {};
101
+ const content = j.choices?.[0]?.delta?.content;
102
+ if (content)
103
+ out.text = content;
104
+ const fr = j.choices?.[0]?.finish_reason;
105
+ if (fr)
106
+ out.finishReason = mapFinish(fr);
107
+ if (j.usage)
108
+ out.usage = { inputTokens: j.usage.prompt_tokens, outputTokens: j.usage.completion_tokens };
109
+ return out;
110
+ },
81
111
  };
@@ -28,12 +28,28 @@ export interface WireParsed {
28
28
  };
29
29
  finishReason: FinishReason;
30
30
  }
31
+ /** One parsed streaming event (from a single SSE `data:` payload). All fields optional; `done` ends the stream. */
32
+ export interface WireDelta {
33
+ /** A text chunk to append to the running answer (and surface to the caller). */
34
+ text?: string;
35
+ usage?: {
36
+ inputTokens?: number;
37
+ outputTokens?: number;
38
+ };
39
+ finishReason?: FinishReason;
40
+ /** The stream is complete (e.g. OpenAI `[DONE]`, Anthropic `message_stop`). */
41
+ done?: boolean;
42
+ }
31
43
  export interface WireModule {
32
44
  readonly shape: WireShape;
33
45
  /** Pure request builder. `jsonMode` requests a JSON response format where the shape supports it. */
34
46
  buildRequest(req: AIRequest, ctx: WireContext, jsonMode: boolean): WireRequest;
35
47
  /** Parse a successful response body into normalized pieces. */
36
48
  readResponse(json: unknown): WireParsed;
49
+ /** OPTIONAL: build a streaming request (sets the vendor's `stream` flag). Absence ⇒ no streaming. */
50
+ buildStreamRequest?(req: AIRequest, ctx: WireContext, jsonMode: boolean): WireRequest;
51
+ /** OPTIONAL: parse one SSE `data:` payload string into a WireDelta. Pure (no socket). */
52
+ readDelta?(data: string): WireDelta;
37
53
  }
38
54
  /** Turn input parts into an OpenAI-style content value (string when text-only, array when multimodal). */
39
55
  export declare function toMultimodalContent(text: string | undefined, parts: AIRequest['input']['parts']): unknown;
@@ -375,6 +375,18 @@ export class Runtime {
375
375
  return result;
376
376
  }
377
377
  const runRequest = buildChatRequest(req, strategy, compiled.system || undefined, routing);
378
+ // Streaming (Phase 13): text-only, chat-mode only, never on a dry run (handled above). Each chunk is
379
+ // emitted as a `response.delta` lifecycle event (redacted by the emitter). We also accumulate the raw
380
+ // chunks so we can mark the result `streamed` when the streamed text IS the final answer.
381
+ const streaming = req.stream === true && req.output?.format !== 'json' && req.output?.format !== 'structured_output';
382
+ let streamedText = '';
383
+ if (streaming) {
384
+ runRequest.stream = true;
385
+ runRequest.onDelta = (chunk) => {
386
+ streamedText += chunk;
387
+ this.emitter.emit({ type: 'response.delta', runId, text: chunk });
388
+ };
389
+ }
378
390
  const runResult = await this._ai.run(runRequest);
379
391
  this.calibrate(runRequest.system, text, runResult);
380
392
  const sel = runResult.routing.selected;
@@ -393,6 +405,12 @@ export class Runtime {
393
405
  if (memTrace)
394
406
  result.memory = memTrace;
395
407
  result.context = contextReport;
408
+ // Mark `streamed` only when the streamed chunks ARE the final answer (a clean single stream). If a
409
+ // streamed attempt failed and a fallback produced different text, they won't match → not streamed, so
410
+ // a renderer reprints the authoritative final text.
411
+ if (streaming && runResult.ok && result.response && streamedText.length > 0 && (result.response.text ?? '') === streamedText) {
412
+ result.response.streamed = true;
413
+ }
396
414
  return result;
397
415
  }
398
416
  /** The persistent execution store and its owner-lease machinery. */
@@ -89,6 +89,9 @@ export interface RuntimeRunInput {
89
89
  };
90
90
  /** Dry-run: plan + report what WOULD happen, performing zero mutations (plan/execute/orchestrate). */
91
91
  dryRun?: boolean;
92
+ /** Stream the answer token-by-token in chat mode: emits `response.delta` lifecycle events (Phase 13).
93
+ * Text output only; ignored for JSON output and for dry runs. */
94
+ stream?: boolean;
92
95
  /** Caller idempotency identity (dedup enforced from Phase 7). */
93
96
  requestId?: string;
94
97
  /** Per-run exclude/prefer routing (highest precedence; merged with config + env). */
@@ -116,9 +119,12 @@ export interface RuntimeResult {
116
119
  runId: string;
117
120
  mode: ModeResolution;
118
121
  status: RuntimeStatus;
122
+ /** `streamed` is true when `text` was already delivered via `response.delta` events (Phase 13) — a
123
+ * renderer that showed the deltas live should not reprint it. */
119
124
  response?: {
120
125
  text?: string;
121
126
  json?: unknown;
127
+ streamed?: boolean;
122
128
  };
123
129
  /** Present when the runtime needs the user to disambiguate. Never a failure. */
124
130
  clarification?: Clarification;
package/dist/types.d.ts CHANGED
@@ -80,6 +80,8 @@ export interface AIRequest {
80
80
  signal?: AbortSignal;
81
81
  sensitivity: Sensitivity;
82
82
  metadata?: Record<string, unknown>;
83
+ /** Request token-by-token streaming (text output only). A provider without `executeStream` ignores it. */
84
+ stream?: boolean;
83
85
  }
84
86
  export type FinishReason = 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error';
85
87
  /** What every provider adapter returns — normalized so callers never parse vendor JSON. */
@@ -335,6 +337,10 @@ export interface RunRequest {
335
337
  verification?: boolean;
336
338
  /** Attach tools from registered MCP sources to this run (requires a tool-calling model). */
337
339
  mcp?: boolean;
340
+ /** Stream the answer token-by-token (text output only; ignored for JSON output). Needs `onDelta`. */
341
+ stream?: boolean;
342
+ /** Called with each text chunk as it streams. The final `AIResponse.text` is still the full aggregate. */
343
+ onDelta?: (chunk: string) => void;
338
344
  }
339
345
  /**
340
346
  * User exclude/prefer routing (all optional). EXCLUDE is a HARD filter — an excluded candidate is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "AI Runtime — a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",