@kuralle-syrinx/aisdk 4.4.1 → 4.5.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.
@@ -0,0 +1,35 @@
1
+ import { streamText, type ModelMessage, type TextStreamPart, type ToolChoice, type ToolSet } from "ai";
2
+ import { type Reasoner } from "@kuralle-syrinx/core";
3
+ import type { AISDKStreamFactory } from "./index.js";
4
+ export interface AiSdkAgentLike {
5
+ stream(opts: {
6
+ messages: ModelMessage[];
7
+ abortSignal: AbortSignal;
8
+ }): Promise<{
9
+ fullStream: AsyncIterable<TextStreamPart<ToolSet>>;
10
+ }>;
11
+ }
12
+ export type StreamTextConfig = {
13
+ model: Parameters<typeof streamText>[0]["model"];
14
+ system?: string;
15
+ tools?: ToolSet;
16
+ toolChoice?: ToolChoice<ToolSet>;
17
+ temperature?: number;
18
+ maxOutputTokens?: number;
19
+ maxRetries?: number;
20
+ timeout?: number;
21
+ stopWhen?: Parameters<typeof streamText>[0]["stopWhen"];
22
+ };
23
+ export declare function fromAiSdkAgent(agent: AiSdkAgentLike): Reasoner;
24
+ export declare function fromStreamText(config: StreamTextConfig): Reasoner;
25
+ export declare function fromStreamFactory(factory: AISDKStreamFactory): Reasoner;
26
+ /**
27
+ * Extract provider/model for cost attribution. The AI SDK model is either a bare id
28
+ * string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
29
+ * Without this, usage counters are tagged with empty provider/model and spend cannot be
30
+ * attributed to a model — the whole point of the low-cardinality tags.
31
+ */
32
+ export declare function modelIdentity(model: StreamTextConfig["model"]): {
33
+ provider?: string;
34
+ model?: string;
35
+ };
@@ -0,0 +1,200 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — AI SDK → Reasoner adapters
4
+ //
5
+ // Normalizes ai@6 TextStreamPart streams into the Reasoner/ReasoningPart seam.
6
+ // See RFC §4.3 and Sprint 0 PLAN §6.
7
+ import { streamText, } from "ai";
8
+ import { categorizeLlmError, isRecoverable, } from "@kuralle-syrinx/core";
9
+ export function fromAiSdkAgent(agent) {
10
+ return {
11
+ stream(turn) {
12
+ return streamFromAgent(agent, turn);
13
+ },
14
+ };
15
+ }
16
+ export function fromStreamText(config) {
17
+ return {
18
+ stream(turn) {
19
+ return streamFromStreamText(config, turn);
20
+ },
21
+ };
22
+ }
23
+ export function fromStreamFactory(factory) {
24
+ return {
25
+ stream(turn) {
26
+ return streamFromFactory(factory, turn);
27
+ },
28
+ };
29
+ }
30
+ async function* streamFromAgent(agent, turn) {
31
+ const messages = buildMessagesForTurn(turn);
32
+ const result = await agent.stream({ messages, abortSignal: turn.signal });
33
+ yield* mapTextStreamParts(result.fullStream);
34
+ }
35
+ async function* streamFromStreamText(config, turn) {
36
+ const messages = buildMessagesForTurn(turn);
37
+ const result = streamText({
38
+ ...config,
39
+ messages,
40
+ abortSignal: turn.signal,
41
+ });
42
+ yield* mapTextStreamParts(result.fullStream, modelIdentity(config.model));
43
+ }
44
+ async function* streamFromFactory(factory, turn) {
45
+ const messages = buildMessagesForTurn(turn);
46
+ yield* mapTextStreamParts(factory({ userText: turn.userText, signal: turn.signal, messages }));
47
+ }
48
+ function buildMessagesForTurn(turn) {
49
+ return [...mapMessages(turn.messages), { role: "user", content: turn.userText }];
50
+ }
51
+ function mapMessages(messages) {
52
+ return messages.map((message) => {
53
+ if (message.role === "tool") {
54
+ return {
55
+ role: "tool",
56
+ content: [
57
+ {
58
+ type: "tool-result",
59
+ toolCallId: message.toolCallId ?? "",
60
+ toolName: "",
61
+ output: { type: "text", value: message.content },
62
+ },
63
+ ],
64
+ };
65
+ }
66
+ return { role: message.role, content: message.content };
67
+ });
68
+ }
69
+ async function* mapTextStreamParts(source, identity = {}) {
70
+ let accumulatedText = "";
71
+ let sawFinish = false;
72
+ for await (const part of source) {
73
+ switch (part.type) {
74
+ case "text-delta":
75
+ accumulatedText += part.text;
76
+ yield { type: "text-delta", text: part.text };
77
+ break;
78
+ case "tool-call":
79
+ yield {
80
+ type: "tool-call",
81
+ toolId: part.toolCallId,
82
+ toolName: part.toolName,
83
+ args: toRecord(part.input),
84
+ };
85
+ break;
86
+ case "tool-result":
87
+ yield {
88
+ type: "tool-result",
89
+ toolId: part.toolCallId,
90
+ toolName: part.toolName,
91
+ result: stringifyToolOutput(part.output),
92
+ };
93
+ break;
94
+ case "error": {
95
+ const cause = part.error instanceof Error ? part.error : new Error(String(part.error));
96
+ yield toErrorPart(cause);
97
+ return;
98
+ }
99
+ case "tool-error": {
100
+ const cause = part.error instanceof Error ? part.error : new Error(`Tool ${part.toolName} failed`);
101
+ yield toErrorPart(cause);
102
+ return;
103
+ }
104
+ case "abort": {
105
+ // An abort is a benign cancellation (barge-in aborted the reasoner), NOT an error.
106
+ // Carry the web/Node standard `name === "AbortError"` so downstream `isAbortError`
107
+ // guards (realtime-bridge runDelegate, ReasoningBridge) swallow it instead of
108
+ // surfacing a fatal `bridge.error/internal_fault`.
109
+ const cause = new Error(part.reason ?? "AI SDK stream aborted");
110
+ cause.name = "AbortError";
111
+ yield toErrorPart(cause);
112
+ return;
113
+ }
114
+ case "finish-step":
115
+ if (part.finishReason === "error" || part.finishReason === "content-filter") {
116
+ yield toErrorPart(new Error(`AI SDK provider step failed: ${formatFinishReason(part.finishReason, part.rawFinishReason)}`));
117
+ return;
118
+ }
119
+ break;
120
+ case "finish":
121
+ sawFinish = true;
122
+ if (part.finishReason === "stop" || part.finishReason === "tool-calls" || part.finishReason === "length") {
123
+ yield {
124
+ type: "finish",
125
+ reason: mapFinishReason(part.finishReason),
126
+ text: accumulatedText,
127
+ // The AI SDK finish part carries totalUsage; forward it so the bridge can
128
+ // record cost. Omit entirely when the provider reported nothing.
129
+ ...(part.totalUsage
130
+ ? { usage: { ...identity, ...toReasonerUsage(part.totalUsage) } }
131
+ : {}),
132
+ };
133
+ return;
134
+ }
135
+ if (part.finishReason === "error" ||
136
+ part.finishReason === "content-filter" ||
137
+ part.finishReason === "other" ||
138
+ part.finishReason === "unknown") {
139
+ yield toErrorPart(new Error(`AI SDK provider did not complete normally: ${formatFinishReason(part.finishReason, part.rawFinishReason)}`));
140
+ return;
141
+ }
142
+ break;
143
+ default:
144
+ break;
145
+ }
146
+ }
147
+ if (!sawFinish) {
148
+ yield toErrorPart(new Error("AI SDK stream ended without a provider finish reason"));
149
+ }
150
+ }
151
+ /**
152
+ * Extract provider/model for cost attribution. The AI SDK model is either a bare id
153
+ * string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
154
+ * Without this, usage counters are tagged with empty provider/model and spend cannot be
155
+ * attributed to a model — the whole point of the low-cardinality tags.
156
+ */
157
+ export function modelIdentity(model) {
158
+ if (typeof model === "string")
159
+ return { model };
160
+ const m = model;
161
+ return {
162
+ ...(typeof m.provider === "string" ? { provider: m.provider } : {}),
163
+ ...(typeof m.modelId === "string" ? { model: m.modelId } : {}),
164
+ };
165
+ }
166
+ /** Copy only the token fields the SDK actually populated (all are `number | undefined`). */
167
+ function toReasonerUsage(u) {
168
+ return {
169
+ ...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
170
+ ...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
171
+ ...(u.totalTokens !== undefined ? { totalTokens: u.totalTokens } : {}),
172
+ ...(u.cachedInputTokens !== undefined ? { cachedInputTokens: u.cachedInputTokens } : {}),
173
+ ...(u.reasoningTokens !== undefined ? { reasoningTokens: u.reasoningTokens } : {}),
174
+ };
175
+ }
176
+ function mapFinishReason(finishReason) {
177
+ if (finishReason === "tool-calls")
178
+ return "tool";
179
+ if (finishReason === "length")
180
+ return "length";
181
+ return "stop";
182
+ }
183
+ function toErrorPart(cause) {
184
+ return {
185
+ type: "error",
186
+ cause,
187
+ recoverable: isRecoverable(categorizeLlmError(cause)),
188
+ };
189
+ }
190
+ function formatFinishReason(finishReason, rawFinishReason) {
191
+ return rawFinishReason ? `${finishReason} (${rawFinishReason})` : finishReason;
192
+ }
193
+ function toRecord(value) {
194
+ if (typeof value !== "object" || value === null || Array.isArray(value))
195
+ return {};
196
+ return value;
197
+ }
198
+ function stringifyToolOutput(output) {
199
+ return typeof output === "string" ? output : JSON.stringify(output);
200
+ }
@@ -0,0 +1,109 @@
1
+ import type { PipelineBus } from "@kuralle-syrinx/core";
2
+ import { type ModelMessage, type TextStreamPart, type ToolSet } from "ai";
3
+ import { type VoicePlugin, type PluginConfig, type Reasoner, type ReasonerSessionStore } from "@kuralle-syrinx/core";
4
+ export { fromAiSdkAgent, fromStreamText, fromStreamFactory, type AiSdkAgentLike, type StreamTextConfig, } from "./from-ai-sdk.js";
5
+ export type AISDKBridgeTools = ToolSet;
6
+ export type AISDKStreamFactory = (request: {
7
+ userText: string;
8
+ signal: AbortSignal;
9
+ messages: ModelMessage[];
10
+ }) => AsyncGenerator<TextStreamPart<ToolSet>>;
11
+ export interface RunPointer {
12
+ readonly runId: string;
13
+ }
14
+ export interface RunStore {
15
+ save(contextId: string, runId: string): void | Promise<void>;
16
+ takePending(contextId: string): RunPointer | null | Promise<RunPointer | null>;
17
+ discard(contextId: string): void | Promise<void>;
18
+ }
19
+ export declare class ReasoningBridge implements VoicePlugin {
20
+ private readonly reasoner;
21
+ private readonly opts;
22
+ private bus;
23
+ private timeoutMs;
24
+ private maxHistoryTurns;
25
+ private history;
26
+ private readonly transientContextMessages;
27
+ private activeGeneration;
28
+ private speculativeDraft;
29
+ private iuLedger;
30
+ private readonly epochByContext;
31
+ private turnEpochCounter;
32
+ private retryConfig;
33
+ private disposers;
34
+ private spokenByContext;
35
+ private turnUserText;
36
+ private assistantMsgByContext;
37
+ private wordTimestampsByContext;
38
+ private playedOutMsByContext;
39
+ constructor(reasoner: Reasoner, opts?: {
40
+ runStore?: RunStore;
41
+ onResumeConflict?: "restart" | "replay";
42
+ /**
43
+ * G4 durable session (RFC bimodel-delegate-seam): when set with `sessionId`, the
44
+ * bridge loads its conversation history from the store on initialize and persists
45
+ * the bounded snapshot after every committed (or interrupted-truncated) turn — a
46
+ * bridge re-created after host eviction resumes with the same context.
47
+ */
48
+ sessionStore?: ReasonerSessionStore;
49
+ sessionId?: string;
50
+ /**
51
+ * Speculative generation (LiveKit preemptive-generation / Deepgram Flux
52
+ * eager-EOT semantics): start the LLM on `eos.interim` with every side effect
53
+ * held back; commit as-is when `eos.turn_complete` confirms the same
54
+ * transcript, regenerate when it differs, discard on `eos.retracted`.
55
+ * Parallelizes LLM TTFT with the endpoint-confirmation window. Opt-in:
56
+ * unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
57
+ * eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
58
+ * `runStore` resume stays confirmed-turn-only.
59
+ *
60
+ * **Only enable this with a confidence-gated eager endpointer (Deepgram Flux).**
61
+ * Promotion requires `draft.userText === eos.text` — exact equality. Flux
62
+ * guarantees the EndOfTurn transcript matches the preceding EagerEndOfTurn when
63
+ * no TurnResumed intervened, so drafts promote. `PipecatEOSPlugin` instead pushes
64
+ * `eos.interim` on EVERY non-empty STT interim, and each interim discards the prior
65
+ * draft and starts a new call, so the surviving draft is built on an interim
66
+ * transcript that rarely matches the final one.
67
+ *
68
+ * Measured live on smart-turn, one turn, university fixture:
69
+ * ON — started 13, discarded 13, promoted 0; ttfa 1724ms, llmTtft 1269ms
70
+ * OFF — started 0, discarded 0, promoted 0; ttfa 1302ms, llmTtft 1025ms
71
+ *
72
+ * Thirteen wasted LLM calls, zero promotions, and latency got *worse*. The lever
73
+ * is Flux-specific; on a per-interim endpointer it is pure cost.
74
+ */
75
+ speculative?: boolean;
76
+ });
77
+ injectContext(text: string): void;
78
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
79
+ /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
80
+ private runDraft;
81
+ private iuIdFor;
82
+ private assistantIuIdFor;
83
+ private discardDraft;
84
+ private metric;
85
+ private processTurn;
86
+ close(): Promise<void>;
87
+ private rememberTurn;
88
+ /** G4: persist the bounded history snapshot, best-effort off the hot path. */
89
+ private persistHistory;
90
+ /**
91
+ * G25: compute the spoken prefix — the assistant text the user actually heard before
92
+ * the barge-in. Uses word timestamps + playout position when available (exact at word
93
+ * boundaries), otherwise falls back to the accumulated text sent to TTS (approximate).
94
+ */
95
+ private computeSpokenPrefix;
96
+ /**
97
+ * Barge-in: rewrite the interrupted turn's history to what the user actually HEARD,
98
+ * not the full generated reply. Precision ladder (G25):
99
+ * 1. Word timestamps + playout position → exact word-boundary prefix.
100
+ * 2. Fallback: text sent to TTS — approximate (may include unplayed audio since
101
+ * TTS streams faster than realtime; headless/browser paths have no playout clock).
102
+ * If the turn was committed (generation done before barge-in), truncates in place.
103
+ * If mid-generation (not yet committed), records what was sent. Either way the user
104
+ * utterance is preserved: neither divergent nor amnesiac.
105
+ */
106
+ private commitInterruptedHistory;
107
+ private trimHistory;
108
+ private clearTurnState;
109
+ }