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 +24 -0
- package/dist/cli/cli.js +2 -1
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +15 -2
- package/dist/cli/interactive/repl.js +15 -4
- package/dist/cli/interactive/session.d.ts +4 -1
- package/dist/cli/interactive/session.js +10 -3
- package/dist/cli/render.d.ts +6 -0
- package/dist/cli/render.js +8 -0
- package/dist/core/fallback/fallback.d.ts +3 -0
- package/dist/core/fallback/fallback.js +1 -1
- package/dist/core/router/executor.d.ts +6 -1
- package/dist/core/router/executor.js +9 -2
- package/dist/core/router/normalize.d.ts +2 -0
- package/dist/core/router/request.js +2 -0
- package/dist/core/router/router.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/providers/httpClient.d.ts +25 -1
- package/dist/providers/httpClient.js +93 -0
- package/dist/providers/httpProvider.d.ts +1 -0
- package/dist/providers/httpProvider.js +67 -1
- package/dist/providers/mock/mockProvider.d.ts +3 -0
- package/dist/providers/mock/mockProvider.js +54 -0
- package/dist/providers/mock/scenarios.d.ts +7 -0
- package/dist/providers/provider.d.ts +6 -0
- package/dist/providers/wire/anthropicWire.js +34 -0
- package/dist/providers/wire/openaiWire.js +30 -0
- package/dist/providers/wire/types.d.ts +16 -0
- package/dist/runtime/runtime.js +18 -0
- package/dist/runtime/types.d.ts +6 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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.
|
|
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')
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/cli/render.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/render.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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;
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -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. */
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|