@oh-my-pi/pi-ai 0.1.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/src/stream.ts ADDED
@@ -0,0 +1,340 @@
1
+ import { supportsXhigh } from "./models";
2
+ import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
3
+ import { type GoogleOptions, streamGoogle } from "./providers/google";
4
+ import {
5
+ type GoogleGeminiCliOptions,
6
+ type GoogleThinkingLevel,
7
+ streamGoogleGeminiCli,
8
+ } from "./providers/google-gemini-cli";
9
+ import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
10
+ import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses";
11
+ import type {
12
+ Api,
13
+ AssistantMessage,
14
+ AssistantMessageEventStream,
15
+ Context,
16
+ KnownProvider,
17
+ Model,
18
+ OptionsForApi,
19
+ ReasoningEffort,
20
+ SimpleStreamOptions,
21
+ } from "./types";
22
+
23
+ /**
24
+ * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
25
+ *
26
+ * Will not return API keys for providers that require OAuth tokens.
27
+ */
28
+ export function getEnvApiKey(provider: KnownProvider): string | undefined;
29
+ export function getEnvApiKey(provider: string): string | undefined;
30
+ export function getEnvApiKey(provider: any): string | undefined {
31
+ // Fall back to environment variables
32
+ if (provider === "github-copilot") {
33
+ return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
34
+ }
35
+
36
+ // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
37
+ if (provider === "anthropic") {
38
+ return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
39
+ }
40
+
41
+ const envMap: Record<string, string> = {
42
+ openai: "OPENAI_API_KEY",
43
+ google: "GEMINI_API_KEY",
44
+ groq: "GROQ_API_KEY",
45
+ cerebras: "CEREBRAS_API_KEY",
46
+ xai: "XAI_API_KEY",
47
+ openrouter: "OPENROUTER_API_KEY",
48
+ zai: "ZAI_API_KEY",
49
+ mistral: "MISTRAL_API_KEY",
50
+ };
51
+
52
+ const envVar = envMap[provider];
53
+ return envVar ? process.env[envVar] : undefined;
54
+ }
55
+
56
+ export function stream<TApi extends Api>(
57
+ model: Model<TApi>,
58
+ context: Context,
59
+ options?: OptionsForApi<TApi>,
60
+ ): AssistantMessageEventStream {
61
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider);
62
+ if (!apiKey) {
63
+ throw new Error(`No API key for provider: ${model.provider}`);
64
+ }
65
+ const providerOptions = { ...options, apiKey };
66
+
67
+ const api: Api = model.api;
68
+ switch (api) {
69
+ case "anthropic-messages":
70
+ return streamAnthropic(model as Model<"anthropic-messages">, context, providerOptions);
71
+
72
+ case "openai-completions":
73
+ return streamOpenAICompletions(model as Model<"openai-completions">, context, providerOptions as any);
74
+
75
+ case "openai-responses":
76
+ return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any);
77
+
78
+ case "google-generative-ai":
79
+ return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
80
+
81
+ case "google-gemini-cli":
82
+ return streamGoogleGeminiCli(
83
+ model as Model<"google-gemini-cli">,
84
+ context,
85
+ providerOptions as GoogleGeminiCliOptions,
86
+ );
87
+
88
+ default: {
89
+ // This should never be reached if all Api cases are handled
90
+ const _exhaustive: never = api;
91
+ throw new Error(`Unhandled API: ${_exhaustive}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ export async function complete<TApi extends Api>(
97
+ model: Model<TApi>,
98
+ context: Context,
99
+ options?: OptionsForApi<TApi>,
100
+ ): Promise<AssistantMessage> {
101
+ const s = stream(model, context, options);
102
+ return s.result();
103
+ }
104
+
105
+ export function streamSimple<TApi extends Api>(
106
+ model: Model<TApi>,
107
+ context: Context,
108
+ options?: SimpleStreamOptions,
109
+ ): AssistantMessageEventStream {
110
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider);
111
+ if (!apiKey) {
112
+ throw new Error(`No API key for provider: ${model.provider}`);
113
+ }
114
+
115
+ const providerOptions = mapOptionsForApi(model, options, apiKey);
116
+ return stream(model, context, providerOptions);
117
+ }
118
+
119
+ export async function completeSimple<TApi extends Api>(
120
+ model: Model<TApi>,
121
+ context: Context,
122
+ options?: SimpleStreamOptions,
123
+ ): Promise<AssistantMessage> {
124
+ const s = streamSimple(model, context, options);
125
+ return s.result();
126
+ }
127
+
128
+ function mapOptionsForApi<TApi extends Api>(
129
+ model: Model<TApi>,
130
+ options?: SimpleStreamOptions,
131
+ apiKey?: string,
132
+ ): OptionsForApi<TApi> {
133
+ const base = {
134
+ temperature: options?.temperature,
135
+ maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
136
+ signal: options?.signal,
137
+ apiKey: apiKey || options?.apiKey,
138
+ };
139
+
140
+ // Helper to clamp xhigh to high for providers that don't support it
141
+ const clampReasoning = (effort: ReasoningEffort | undefined) => (effort === "xhigh" ? "high" : effort);
142
+
143
+ switch (model.api) {
144
+ case "anthropic-messages": {
145
+ // Explicitly disable thinking when reasoning is not specified
146
+ if (!options?.reasoning) {
147
+ return { ...base, thinkingEnabled: false } satisfies AnthropicOptions;
148
+ }
149
+
150
+ const anthropicBudgets = {
151
+ minimal: 1024,
152
+ low: 2048,
153
+ medium: 8192,
154
+ high: 16384,
155
+ };
156
+
157
+ return {
158
+ ...base,
159
+ thinkingEnabled: true,
160
+ thinkingBudgetTokens: anthropicBudgets[clampReasoning(options.reasoning)!],
161
+ } satisfies AnthropicOptions;
162
+ }
163
+
164
+ case "openai-completions":
165
+ return {
166
+ ...base,
167
+ reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
168
+ } satisfies OpenAICompletionsOptions;
169
+
170
+ case "openai-responses":
171
+ return {
172
+ ...base,
173
+ reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
174
+ } satisfies OpenAIResponsesOptions;
175
+
176
+ case "google-generative-ai": {
177
+ // Explicitly disable thinking when reasoning is not specified
178
+ // This is needed because Gemini has "dynamic thinking" enabled by default
179
+ if (!options?.reasoning) {
180
+ return { ...base, thinking: { enabled: false } } satisfies GoogleOptions;
181
+ }
182
+
183
+ const googleModel = model as Model<"google-generative-ai">;
184
+ const effort = clampReasoning(options.reasoning)!;
185
+
186
+ // Gemini 3 models use thinkingLevel exclusively instead of thinkingBudget.
187
+ // https://ai.google.dev/gemini-api/docs/thinking#set-budget
188
+ if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) {
189
+ return {
190
+ ...base,
191
+ thinking: {
192
+ enabled: true,
193
+ level: getGemini3ThinkingLevel(effort, googleModel),
194
+ },
195
+ } satisfies GoogleOptions;
196
+ }
197
+
198
+ return {
199
+ ...base,
200
+ thinking: {
201
+ enabled: true,
202
+ budgetTokens: getGoogleBudget(googleModel, effort),
203
+ },
204
+ } satisfies GoogleOptions;
205
+ }
206
+
207
+ case "google-gemini-cli": {
208
+ if (!options?.reasoning) {
209
+ return { ...base, thinking: { enabled: false } } satisfies GoogleGeminiCliOptions;
210
+ }
211
+
212
+ const effort = clampReasoning(options.reasoning)!;
213
+
214
+ // Gemini 3 models use thinkingLevel instead of thinkingBudget
215
+ if (model.id.includes("3-pro") || model.id.includes("3-flash")) {
216
+ return {
217
+ ...base,
218
+ thinking: {
219
+ enabled: true,
220
+ level: getGeminiCliThinkingLevel(effort, model.id),
221
+ },
222
+ } satisfies GoogleGeminiCliOptions;
223
+ }
224
+
225
+ // Gemini 2.x models use thinkingBudget
226
+ const budgets: Record<ClampedReasoningEffort, number> = {
227
+ minimal: 1024,
228
+ low: 2048,
229
+ medium: 8192,
230
+ high: 16384,
231
+ };
232
+
233
+ return {
234
+ ...base,
235
+ thinking: {
236
+ enabled: true,
237
+ budgetTokens: budgets[effort],
238
+ },
239
+ } satisfies GoogleGeminiCliOptions;
240
+ }
241
+
242
+ default: {
243
+ // Exhaustiveness check
244
+ const _exhaustive: never = model.api;
245
+ throw new Error(`Unhandled API in mapOptionsForApi: ${_exhaustive}`);
246
+ }
247
+ }
248
+ }
249
+
250
+ type ClampedReasoningEffort = Exclude<ReasoningEffort, "xhigh">;
251
+
252
+ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
253
+ // Covers gemini-3-pro, gemini-3-pro-preview, and possible other prefixed ids in the future
254
+ return model.id.includes("3-pro");
255
+ }
256
+
257
+ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
258
+ // Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future
259
+ return model.id.includes("3-flash");
260
+ }
261
+
262
+ function getGemini3ThinkingLevel(
263
+ effort: ClampedReasoningEffort,
264
+ model: Model<"google-generative-ai">,
265
+ ): GoogleThinkingLevel {
266
+ if (isGemini3ProModel(model)) {
267
+ // Gemini 3 Pro only supports LOW/HIGH (for now)
268
+ switch (effort) {
269
+ case "minimal":
270
+ case "low":
271
+ return "LOW";
272
+ case "medium":
273
+ case "high":
274
+ return "HIGH";
275
+ }
276
+ }
277
+ // Gemini 3 Flash supports all four levels
278
+ switch (effort) {
279
+ case "minimal":
280
+ return "MINIMAL";
281
+ case "low":
282
+ return "LOW";
283
+ case "medium":
284
+ return "MEDIUM";
285
+ case "high":
286
+ return "HIGH";
287
+ }
288
+ }
289
+
290
+ function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): GoogleThinkingLevel {
291
+ if (modelId.includes("3-pro")) {
292
+ // Gemini 3 Pro only supports LOW/HIGH (for now)
293
+ switch (effort) {
294
+ case "minimal":
295
+ case "low":
296
+ return "LOW";
297
+ case "medium":
298
+ case "high":
299
+ return "HIGH";
300
+ }
301
+ }
302
+ // Gemini 3 Flash supports all four levels
303
+ switch (effort) {
304
+ case "minimal":
305
+ return "MINIMAL";
306
+ case "low":
307
+ return "LOW";
308
+ case "medium":
309
+ return "MEDIUM";
310
+ case "high":
311
+ return "HIGH";
312
+ }
313
+ }
314
+
315
+ function getGoogleBudget(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number {
316
+ // See https://ai.google.dev/gemini-api/docs/thinking#set-budget
317
+ if (model.id.includes("2.5-pro")) {
318
+ const budgets: Record<ClampedReasoningEffort, number> = {
319
+ minimal: 128,
320
+ low: 2048,
321
+ medium: 8192,
322
+ high: 32768,
323
+ };
324
+ return budgets[effort];
325
+ }
326
+
327
+ if (model.id.includes("2.5-flash")) {
328
+ // Covers 2.5-flash-lite as well
329
+ const budgets: Record<ClampedReasoningEffort, number> = {
330
+ minimal: 128,
331
+ low: 2048,
332
+ medium: 8192,
333
+ high: 24576,
334
+ };
335
+ return budgets[effort];
336
+ }
337
+
338
+ // Unknown model - use dynamic
339
+ return -1;
340
+ }
package/src/types.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type { AnthropicOptions } from "./providers/anthropic";
2
+ import type { GoogleOptions } from "./providers/google";
3
+ import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
4
+ import type { OpenAICompletionsOptions } from "./providers/openai-completions";
5
+ import type { OpenAIResponsesOptions } from "./providers/openai-responses";
6
+ import type { AssistantMessageEventStream } from "./utils/event-stream";
7
+
8
+ export type { AssistantMessageEventStream } from "./utils/event-stream";
9
+
10
+ export type Api =
11
+ | "openai-completions"
12
+ | "openai-responses"
13
+ | "anthropic-messages"
14
+ | "google-generative-ai"
15
+ | "google-gemini-cli";
16
+
17
+ export interface ApiOptionsMap {
18
+ "anthropic-messages": AnthropicOptions;
19
+ "openai-completions": OpenAICompletionsOptions;
20
+ "openai-responses": OpenAIResponsesOptions;
21
+ "google-generative-ai": GoogleOptions;
22
+ "google-gemini-cli": GoogleGeminiCliOptions;
23
+ }
24
+
25
+ // Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
26
+ type _CheckExhaustive =
27
+ ApiOptionsMap extends Record<Api, StreamOptions>
28
+ ? Record<Api, StreamOptions> extends ApiOptionsMap
29
+ ? true
30
+ : ["ApiOptionsMap is missing some KnownApi values", Exclude<Api, keyof ApiOptionsMap>]
31
+ : ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
32
+ const _exhaustive: _CheckExhaustive = true;
33
+
34
+ // Helper type to get options for a specific API
35
+ export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
36
+
37
+ export type KnownProvider =
38
+ | "anthropic"
39
+ | "google"
40
+ | "google-gemini-cli"
41
+ | "google-antigravity"
42
+ | "openai"
43
+ | "github-copilot"
44
+ | "xai"
45
+ | "groq"
46
+ | "cerebras"
47
+ | "openrouter"
48
+ | "zai"
49
+ | "mistral";
50
+ export type Provider = KnownProvider | string;
51
+
52
+ export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
53
+
54
+ // Base options all providers share
55
+ export interface StreamOptions {
56
+ temperature?: number;
57
+ maxTokens?: number;
58
+ signal?: AbortSignal;
59
+ apiKey?: string;
60
+ }
61
+
62
+ // Unified options with reasoning passed to streamSimple() and completeSimple()
63
+ export interface SimpleStreamOptions extends StreamOptions {
64
+ reasoning?: ReasoningEffort;
65
+ }
66
+
67
+ // Generic StreamFunction with typed options
68
+ export type StreamFunction<TApi extends Api> = (
69
+ model: Model<TApi>,
70
+ context: Context,
71
+ options: OptionsForApi<TApi>,
72
+ ) => AssistantMessageEventStream;
73
+
74
+ export interface TextContent {
75
+ type: "text";
76
+ text: string;
77
+ textSignature?: string; // e.g., for OpenAI responses, the message ID
78
+ }
79
+
80
+ export interface ThinkingContent {
81
+ type: "thinking";
82
+ thinking: string;
83
+ thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
84
+ }
85
+
86
+ export interface ImageContent {
87
+ type: "image";
88
+ data: string; // base64 encoded image data
89
+ mimeType: string; // e.g., "image/jpeg", "image/png"
90
+ }
91
+
92
+ export interface ToolCall {
93
+ type: "toolCall";
94
+ id: string;
95
+ name: string;
96
+ arguments: Record<string, any>;
97
+ thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
98
+ }
99
+
100
+ export interface Usage {
101
+ input: number;
102
+ output: number;
103
+ cacheRead: number;
104
+ cacheWrite: number;
105
+ totalTokens: number;
106
+ cost: {
107
+ input: number;
108
+ output: number;
109
+ cacheRead: number;
110
+ cacheWrite: number;
111
+ total: number;
112
+ };
113
+ }
114
+
115
+ export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
116
+
117
+ export interface UserMessage {
118
+ role: "user";
119
+ content: string | (TextContent | ImageContent)[];
120
+ timestamp: number; // Unix timestamp in milliseconds
121
+ }
122
+
123
+ export interface AssistantMessage {
124
+ role: "assistant";
125
+ content: (TextContent | ThinkingContent | ToolCall)[];
126
+ api: Api;
127
+ provider: Provider;
128
+ model: string;
129
+ usage: Usage;
130
+ stopReason: StopReason;
131
+ errorMessage?: string;
132
+ timestamp: number; // Unix timestamp in milliseconds
133
+ }
134
+
135
+ export interface ToolResultMessage<TDetails = any> {
136
+ role: "toolResult";
137
+ toolCallId: string;
138
+ toolName: string;
139
+ content: (TextContent | ImageContent)[]; // Supports text and images
140
+ details?: TDetails;
141
+ isError?: boolean;
142
+ timestamp: number; // Unix timestamp in milliseconds
143
+ }
144
+
145
+ export type Message = UserMessage | AssistantMessage | ToolResultMessage;
146
+
147
+ import type { TSchema } from "@sinclair/typebox";
148
+
149
+ export interface Tool<TParameters extends TSchema = TSchema> {
150
+ name: string;
151
+ description: string;
152
+ parameters: TParameters;
153
+ }
154
+
155
+ export interface Context {
156
+ systemPrompt?: string;
157
+ messages: Message[];
158
+ tools?: Tool[];
159
+ }
160
+
161
+ export type AssistantMessageEvent =
162
+ | { type: "start"; partial: AssistantMessage }
163
+ | { type: "text_start"; contentIndex: number; partial: AssistantMessage }
164
+ | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
165
+ | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
166
+ | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
167
+ | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
168
+ | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
169
+ | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
170
+ | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
171
+ | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
172
+ | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
173
+ | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
174
+
175
+ /**
176
+ * Compatibility settings for openai-completions API.
177
+ * Use this to override URL-based auto-detection for custom providers.
178
+ */
179
+ export interface OpenAICompat {
180
+ /** Whether the provider supports the `store` field. Default: auto-detected from URL. */
181
+ supportsStore?: boolean;
182
+ /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
183
+ supportsDeveloperRole?: boolean;
184
+ /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */
185
+ supportsReasoningEffort?: boolean;
186
+ /** Which field to use for max tokens. Default: auto-detected from URL. */
187
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
188
+ /** Whether tool results require the `name` field. Default: auto-detected from URL. */
189
+ requiresToolResultName?: boolean;
190
+ /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
191
+ requiresAssistantAfterToolResult?: boolean;
192
+ /** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
193
+ requiresThinkingAsText?: boolean;
194
+ /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
195
+ requiresMistralToolIds?: boolean;
196
+ }
197
+
198
+ // Model interface for the unified model system
199
+ export interface Model<TApi extends Api> {
200
+ id: string;
201
+ name: string;
202
+ api: TApi;
203
+ provider: Provider;
204
+ baseUrl: string;
205
+ reasoning: boolean;
206
+ input: ("text" | "image")[];
207
+ cost: {
208
+ input: number; // $/million tokens
209
+ output: number; // $/million tokens
210
+ cacheRead: number; // $/million tokens
211
+ cacheWrite: number; // $/million tokens
212
+ };
213
+ contextWindow: number;
214
+ maxTokens: number;
215
+ headers?: Record<string, string>;
216
+ /** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */
217
+ compat?: TApi extends "openai-completions" ? OpenAICompat : never;
218
+ }
@@ -0,0 +1,82 @@
1
+ import type { AssistantMessage, AssistantMessageEvent } from "../types";
2
+
3
+ // Generic event stream class for async iteration
4
+ export class EventStream<T, R = T> implements AsyncIterable<T> {
5
+ private queue: T[] = [];
6
+ private waiting: ((value: IteratorResult<T>) => void)[] = [];
7
+ private done = false;
8
+ private finalResultPromise: Promise<R>;
9
+ private resolveFinalResult!: (result: R) => void;
10
+
11
+ constructor(
12
+ private isComplete: (event: T) => boolean,
13
+ private extractResult: (event: T) => R,
14
+ ) {
15
+ this.finalResultPromise = new Promise((resolve) => {
16
+ this.resolveFinalResult = resolve;
17
+ });
18
+ }
19
+
20
+ push(event: T): void {
21
+ if (this.done) return;
22
+
23
+ if (this.isComplete(event)) {
24
+ this.done = true;
25
+ this.resolveFinalResult(this.extractResult(event));
26
+ }
27
+
28
+ // Deliver to waiting consumer or queue it
29
+ const waiter = this.waiting.shift();
30
+ if (waiter) {
31
+ waiter({ value: event, done: false });
32
+ } else {
33
+ this.queue.push(event);
34
+ }
35
+ }
36
+
37
+ end(result?: R): void {
38
+ this.done = true;
39
+ if (result !== undefined) {
40
+ this.resolveFinalResult(result);
41
+ }
42
+ // Notify all waiting consumers that we're done
43
+ while (this.waiting.length > 0) {
44
+ const waiter = this.waiting.shift()!;
45
+ waiter({ value: undefined as any, done: true });
46
+ }
47
+ }
48
+
49
+ async *[Symbol.asyncIterator](): AsyncIterator<T> {
50
+ while (true) {
51
+ if (this.queue.length > 0) {
52
+ yield this.queue.shift()!;
53
+ } else if (this.done) {
54
+ return;
55
+ } else {
56
+ const result = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
57
+ if (result.done) return;
58
+ yield result.value;
59
+ }
60
+ }
61
+ }
62
+
63
+ result(): Promise<R> {
64
+ return this.finalResultPromise;
65
+ }
66
+ }
67
+
68
+ export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
69
+ constructor() {
70
+ super(
71
+ (event) => event.type === "done" || event.type === "error",
72
+ (event) => {
73
+ if (event.type === "done") {
74
+ return event.message;
75
+ } else if (event.type === "error") {
76
+ return event.error;
77
+ }
78
+ throw new Error("Unexpected event type for final result");
79
+ },
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,28 @@
1
+ import { parse as partialParse } from "partial-json";
2
+
3
+ /**
4
+ * Attempts to parse potentially incomplete JSON during streaming.
5
+ * Always returns a valid object, even if the JSON is incomplete.
6
+ *
7
+ * @param partialJson The partial JSON string from streaming
8
+ * @returns Parsed object or empty object if parsing fails
9
+ */
10
+ export function parseStreamingJson<T = any>(partialJson: string | undefined): T {
11
+ if (!partialJson || partialJson.trim() === "") {
12
+ return {} as T;
13
+ }
14
+
15
+ // Try standard parsing first (fastest for complete JSON)
16
+ try {
17
+ return JSON.parse(partialJson) as T;
18
+ } catch {
19
+ // Try partial-json for incomplete JSON
20
+ try {
21
+ const result = partialParse(partialJson);
22
+ return (result ?? {}) as T;
23
+ } catch {
24
+ // If all parsing fails, return empty object
25
+ return {} as T;
26
+ }
27
+ }
28
+ }