@boostecom/provider 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +90 -0
  2. package/dist/index.cjs +2522 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +848 -0
  5. package/dist/index.d.ts +848 -0
  6. package/dist/index.js +2484 -0
  7. package/dist/index.js.map +1 -0
  8. package/docs/content/README.md +337 -0
  9. package/docs/content/agent-teams.mdx +324 -0
  10. package/docs/content/api.mdx +757 -0
  11. package/docs/content/best-practices.mdx +624 -0
  12. package/docs/content/examples.mdx +675 -0
  13. package/docs/content/guide.mdx +516 -0
  14. package/docs/content/index.mdx +99 -0
  15. package/docs/content/installation.mdx +246 -0
  16. package/docs/content/skills.mdx +548 -0
  17. package/docs/content/troubleshooting.mdx +588 -0
  18. package/docs/examples/README.md +499 -0
  19. package/docs/examples/abort-signal.ts +125 -0
  20. package/docs/examples/agent-teams.ts +122 -0
  21. package/docs/examples/basic-usage.ts +73 -0
  22. package/docs/examples/check-cli.ts +51 -0
  23. package/docs/examples/conversation-history.ts +69 -0
  24. package/docs/examples/custom-config.ts +90 -0
  25. package/docs/examples/generate-object-constraints.ts +209 -0
  26. package/docs/examples/generate-object.ts +211 -0
  27. package/docs/examples/hooks-callbacks.ts +63 -0
  28. package/docs/examples/images.ts +76 -0
  29. package/docs/examples/integration-test.ts +241 -0
  30. package/docs/examples/limitations.ts +150 -0
  31. package/docs/examples/logging-custom-logger.ts +99 -0
  32. package/docs/examples/logging-default.ts +55 -0
  33. package/docs/examples/logging-disabled.ts +74 -0
  34. package/docs/examples/logging-verbose.ts +64 -0
  35. package/docs/examples/long-running-tasks.ts +179 -0
  36. package/docs/examples/message-injection.ts +210 -0
  37. package/docs/examples/mid-stream-injection.ts +126 -0
  38. package/docs/examples/run-all-examples.sh +48 -0
  39. package/docs/examples/sdk-tools-callbacks.ts +49 -0
  40. package/docs/examples/skills-discovery.ts +144 -0
  41. package/docs/examples/skills-management.ts +140 -0
  42. package/docs/examples/stream-object.ts +80 -0
  43. package/docs/examples/streaming.ts +52 -0
  44. package/docs/examples/structured-output-repro.ts +227 -0
  45. package/docs/examples/tool-management.ts +215 -0
  46. package/docs/examples/tool-streaming.ts +132 -0
  47. package/docs/examples/zod4-compatibility-test.ts +290 -0
  48. package/docs/src/claude-code-language-model.test.ts +3883 -0
  49. package/docs/src/claude-code-language-model.ts +2586 -0
  50. package/docs/src/claude-code-provider.test.ts +97 -0
  51. package/docs/src/claude-code-provider.ts +179 -0
  52. package/docs/src/convert-to-claude-code-messages.images.test.ts +104 -0
  53. package/docs/src/convert-to-claude-code-messages.test.ts +193 -0
  54. package/docs/src/convert-to-claude-code-messages.ts +419 -0
  55. package/docs/src/errors.test.ts +213 -0
  56. package/docs/src/errors.ts +216 -0
  57. package/docs/src/index.test.ts +49 -0
  58. package/docs/src/index.ts +98 -0
  59. package/docs/src/logger.integration.test.ts +164 -0
  60. package/docs/src/logger.test.ts +184 -0
  61. package/docs/src/logger.ts +65 -0
  62. package/docs/src/map-claude-code-finish-reason.test.ts +120 -0
  63. package/docs/src/map-claude-code-finish-reason.ts +60 -0
  64. package/docs/src/mcp-helpers.test.ts +71 -0
  65. package/docs/src/mcp-helpers.ts +123 -0
  66. package/docs/src/message-injection.test.ts +460 -0
  67. package/docs/src/types.ts +447 -0
  68. package/docs/src/validation.test.ts +558 -0
  69. package/docs/src/validation.ts +360 -0
  70. package/package.json +124 -0
@@ -0,0 +1,2586 @@
1
+ import type {
2
+ LanguageModelV3,
3
+ LanguageModelV3FinishReason,
4
+ LanguageModelV3StreamPart,
5
+ LanguageModelV3Usage,
6
+ SharedV3Warning,
7
+ JSONValue,
8
+ JSONObject,
9
+ } from '@ai-sdk/provider';
10
+ import { NoSuchModelError, APICallError, LoadAPIKeyError } from '@ai-sdk/provider';
11
+ import { generateId } from '@ai-sdk/provider-utils';
12
+ import type { ClaudeCodeSettings, Logger, MessageInjector } from './types.js';
13
+ import { convertToClaudeCodeMessages } from './convert-to-claude-code-messages.js';
14
+ import { createAPICallError, createAuthenticationError, createTimeoutError } from './errors.js';
15
+ import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason.js';
16
+ import { validateModelId, validatePrompt, validateSessionId } from './validation.js';
17
+ import { getLogger, createVerboseLogger } from './logger.js';
18
+
19
+ import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
20
+ import type { SDKUserMessage, SDKPartialAssistantMessage } from '@anthropic-ai/claude-agent-sdk';
21
+
22
+ const CLAUDE_CODE_TRUNCATION_WARNING =
23
+ 'Claude Code SDK output ended unexpectedly; returning truncated response from buffered text. Await upstream fix to avoid data loss.';
24
+
25
+ const MIN_TRUNCATION_LENGTH = 512;
26
+
27
+ /**
28
+ * Detects if an error represents a truncated SDK JSON stream.
29
+ *
30
+ * The Claude Code SDK can truncate JSON responses mid-stream, producing a SyntaxError.
31
+ * This function distinguishes genuine truncation from normal JSON syntax errors by:
32
+ * 1. Verifying the error is a SyntaxError with truncation-specific messages
33
+ * 2. Ensuring we received meaningful content (>= MIN_TRUNCATION_LENGTH characters)
34
+ * 3. Avoiding false positives from unrelated parse errors
35
+ *
36
+ * Note: We compare against `bufferedText` (assistant text content) rather than the raw
37
+ * JSON buffer length, since the SDK layer doesn't expose buffer positions. The position
38
+ * reported in SyntaxError messages measures the full JSON payload (metadata + content),
39
+ * which is typically much larger than extracted text. Therefore, we cannot reliably use
40
+ * position proximity checks and instead rely on message patterns and content length.
41
+ *
42
+ * @param error - The caught error (expected to be SyntaxError for truncation)
43
+ * @param bufferedText - Accumulated assistant text content (measured in UTF-16 code units)
44
+ * @returns true if error indicates SDK truncation; false otherwise
45
+ */
46
+ function isClaudeCodeTruncationError(error: unknown, bufferedText: string): boolean {
47
+ // Check for SyntaxError by instanceof or by name (for cross-realm errors)
48
+ const isSyntaxError =
49
+ error instanceof SyntaxError ||
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ (typeof (error as any)?.name === 'string' &&
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ (error as any).name.toLowerCase() === 'syntaxerror');
54
+
55
+ if (!isSyntaxError) {
56
+ return false;
57
+ }
58
+
59
+ if (!bufferedText) {
60
+ return false;
61
+ }
62
+
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ const rawMessage = typeof (error as any)?.message === 'string' ? (error as any).message : '';
65
+ const message = rawMessage.toLowerCase();
66
+
67
+ // Only match actual truncation patterns, not normal JSON parsing errors.
68
+ // Real truncation: "Unexpected end of JSON input" or "Unterminated string in JSON..."
69
+ // Normal errors: "Unexpected token X in JSON at position N" (should be surfaced as errors)
70
+ const truncationIndicators = [
71
+ 'unexpected end of json input',
72
+ 'unexpected end of input',
73
+ 'unexpected end of string',
74
+ 'unexpected eof',
75
+ 'end of file',
76
+ 'unterminated string',
77
+ 'unterminated string constant',
78
+ ];
79
+
80
+ if (!truncationIndicators.some((indicator) => message.includes(indicator))) {
81
+ return false;
82
+ }
83
+
84
+ // Require meaningful content before treating as truncation.
85
+ // Short responses with "end of input" errors are likely genuine syntax errors.
86
+ // Note: bufferedText.length measures UTF-16 code units, not byte length.
87
+ if (bufferedText.length < MIN_TRUNCATION_LENGTH) {
88
+ return false;
89
+ }
90
+
91
+ // If we have a truncation indicator AND meaningful content, treat as truncation.
92
+ return true;
93
+ }
94
+
95
+ function isAbortError(err: unknown): boolean {
96
+ if (err && typeof err === 'object') {
97
+ const e = err as { name?: unknown; code?: unknown };
98
+ if (typeof e.name === 'string' && e.name === 'AbortError') return true;
99
+ if (typeof e.code === 'string' && e.code.toUpperCase() === 'ABORT_ERR') return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ const DEFAULT_INHERITED_ENV_VARS =
105
+ process.platform === 'win32'
106
+ ? [
107
+ 'APPDATA',
108
+ 'HOMEDRIVE',
109
+ 'HOMEPATH',
110
+ 'LOCALAPPDATA',
111
+ 'PATH',
112
+ 'PATHEXT',
113
+ 'SYSTEMDRIVE',
114
+ 'SYSTEMROOT',
115
+ 'TEMP',
116
+ 'TMP',
117
+ 'USERNAME',
118
+ 'USERPROFILE',
119
+ 'WINDIR',
120
+ ]
121
+ : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL', 'TMPDIR'];
122
+
123
+ const CLAUDE_ENV_VARS = ['CLAUDE_CONFIG_DIR'];
124
+
125
+ function getBaseProcessEnv(): Record<string, string> {
126
+ const env: Record<string, string> = {};
127
+ const allowedKeys = new Set([...DEFAULT_INHERITED_ENV_VARS, ...CLAUDE_ENV_VARS]);
128
+
129
+ for (const key of allowedKeys) {
130
+ const value = process.env[key];
131
+ if (typeof value !== 'string') {
132
+ continue;
133
+ }
134
+
135
+ if (value.startsWith('()')) {
136
+ continue;
137
+ }
138
+
139
+ env[key] = value;
140
+ }
141
+
142
+ return env;
143
+ }
144
+
145
+ const STREAMING_FEATURE_WARNING =
146
+ "Claude Agent SDK features (hooks/MCP/images) require streaming input. Set `streamingInput: 'always'` or provide `canUseTool` (auto streams only when canUseTool is set).";
147
+
148
+ const SDK_OPTIONS_BLOCKLIST = new Set(['model', 'abortController', 'prompt', 'outputFormat']);
149
+
150
+ type ClaudeToolUse = {
151
+ id: string;
152
+ name: string;
153
+ input: unknown;
154
+ parentToolUseId?: string | null;
155
+ };
156
+
157
+ type ClaudeToolResult = {
158
+ id: string;
159
+ name?: string;
160
+ result: unknown;
161
+ isError: boolean;
162
+ };
163
+
164
+ // Provider extension for tool-error stream parts.
165
+ type ToolErrorPart = {
166
+ type: 'tool-error';
167
+ toolCallId: string;
168
+ toolName: string;
169
+ error: string;
170
+ providerExecuted: true;
171
+ providerMetadata?: Record<string, JSONValue>;
172
+ };
173
+
174
+ // Local extension of the AI SDK stream part union to include tool-error.
175
+ type ExtendedStreamPart = LanguageModelV3StreamPart | ToolErrorPart;
176
+
177
+ /**
178
+ * Usage data from Claude Code SDK.
179
+ */
180
+ type ClaudeCodeUsage = {
181
+ input_tokens?: number | null;
182
+ output_tokens?: number | null;
183
+ cache_creation_input_tokens?: number | null;
184
+ cache_read_input_tokens?: number | null;
185
+ };
186
+
187
+ /**
188
+ * Creates a zero-initialized usage object for AI SDK v6 stable.
189
+ */
190
+ function createEmptyUsage(): LanguageModelV3Usage {
191
+ return {
192
+ inputTokens: {
193
+ total: 0,
194
+ noCache: 0,
195
+ cacheRead: 0,
196
+ cacheWrite: 0,
197
+ },
198
+ outputTokens: {
199
+ total: 0,
200
+ text: undefined,
201
+ reasoning: undefined,
202
+ },
203
+ raw: undefined,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Converts Claude Code SDK usage to AI SDK v6 stable usage format.
209
+ *
210
+ * Maps Claude's flat token counts to the nested structure required by AI SDK v6:
211
+ * - `cache_creation_input_tokens` → `inputTokens.cacheWrite`
212
+ * - `cache_read_input_tokens` → `inputTokens.cacheRead`
213
+ * - `input_tokens` → `inputTokens.noCache`
214
+ * - `inputTokens.total` = sum of all input tokens
215
+ * - `output_tokens` → `outputTokens.total`
216
+ *
217
+ * @param usage - Raw usage data from Claude Code SDK
218
+ * @returns Formatted usage object for AI SDK v6
219
+ */
220
+ function convertClaudeCodeUsage(usage: ClaudeCodeUsage): LanguageModelV3Usage {
221
+ const inputTokens = usage.input_tokens ?? 0;
222
+ const outputTokens = usage.output_tokens ?? 0;
223
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0;
224
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
225
+
226
+ return {
227
+ inputTokens: {
228
+ total: inputTokens + cacheWrite + cacheRead,
229
+ noCache: inputTokens,
230
+ cacheRead,
231
+ cacheWrite,
232
+ },
233
+ outputTokens: {
234
+ total: outputTokens,
235
+ text: undefined,
236
+ reasoning: undefined,
237
+ },
238
+ raw: usage as JSONObject,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Tracks the streaming lifecycle state for a single tool invocation.
244
+ *
245
+ * The tool streaming lifecycle follows this sequence:
246
+ * 1. Tool use detected → state created with all flags false
247
+ * 2. First input seen → `inputStarted` = true, emit `tool-input-start`
248
+ * 3. Input deltas streamed → emit `tool-input-delta` (may be skipped for large/non-prefix updates)
249
+ * 4. Input finalized → `inputClosed` = true, emit `tool-input-end`
250
+ * 5. Tool call formed → `callEmitted` = true, emit `tool-call`
251
+ * 6. Tool results/errors arrive → emit `tool-result` or `tool-error` (may occur multiple times)
252
+ * 7. Stream ends → state cleaned up by `finalizeToolCalls()`
253
+ *
254
+ * @property name - Tool name from SDK (e.g., "Bash", "Read")
255
+ * @property lastSerializedInput - Most recent serialized input, used for delta calculation
256
+ * @property inputStarted - True after `tool-input-start` emitted; prevents duplicate start events
257
+ * @property inputClosed - True after `tool-input-end` emitted; ensures proper event ordering
258
+ * @property callEmitted - True after `tool-call` emitted; prevents duplicate call events when
259
+ * multiple result/error chunks arrive for the same tool invocation
260
+ */
261
+ type ToolStreamState = {
262
+ name: string;
263
+ lastSerializedInput?: string;
264
+ inputStarted: boolean;
265
+ inputClosed: boolean;
266
+ callEmitted: boolean;
267
+ parentToolCallId?: string | null;
268
+ };
269
+
270
+ /**
271
+ * Queued injection item with content and optional delivery callback.
272
+ */
273
+ type QueuedInjection = {
274
+ content: string;
275
+ onResult?: (delivered: boolean) => void;
276
+ };
277
+
278
+ /**
279
+ * Creates a MessageInjector implementation that can queue messages for mid-session injection.
280
+ * The injector uses a queue and signals to coordinate between the producer (user code)
281
+ * and consumer (async generator).
282
+ *
283
+ * Note: getNextItem returns the full QueuedInjection so the consumer can call onResult
284
+ * AFTER successfully yielding, avoiding a race condition with outputStreamEnded.
285
+ */
286
+ function createMessageInjector(): {
287
+ injector: MessageInjector;
288
+ getNextItem: () => Promise<QueuedInjection | null>;
289
+ notifySessionEnded: () => void;
290
+ } {
291
+ const queue: QueuedInjection[] = [];
292
+ let closed = false;
293
+ let resolver: ((item: QueuedInjection | null) => void) | null = null;
294
+
295
+ const injector: MessageInjector = {
296
+ inject(content, onResult) {
297
+ if (closed) {
298
+ // Already closed - immediately notify not delivered
299
+ onResult?.(false);
300
+ return;
301
+ }
302
+ const item: QueuedInjection = { content, onResult };
303
+ if (resolver) {
304
+ // Consumer is waiting, resolve immediately
305
+ const r = resolver;
306
+ resolver = null;
307
+ r(item);
308
+ } else {
309
+ // Queue for later consumption
310
+ queue.push(item);
311
+ }
312
+ },
313
+ close() {
314
+ // Stop accepting new messages, but don't cancel pending ones
315
+ // Pending messages can still be delivered until session ends
316
+ closed = true;
317
+ if (resolver && queue.length === 0) {
318
+ // No pending messages and consumer is waiting - signal done
319
+ resolver(null);
320
+ resolver = null;
321
+ }
322
+ },
323
+ };
324
+
325
+ const getNextItem = (): Promise<QueuedInjection | null> => {
326
+ if (queue.length > 0) {
327
+ const item = queue.shift();
328
+ if (!item) {
329
+ return Promise.resolve(null);
330
+ }
331
+ // Return the full item - caller is responsible for calling onResult after yielding
332
+ return Promise.resolve(item);
333
+ }
334
+ if (closed) {
335
+ // Closed and queue is empty - no more messages
336
+ return Promise.resolve(null);
337
+ }
338
+ return new Promise((resolve) => {
339
+ resolver = (item) => {
340
+ // Return the full item (or null) - caller handles onResult
341
+ resolve(item);
342
+ };
343
+ });
344
+ };
345
+
346
+ const notifySessionEnded = () => {
347
+ // Session ended - any remaining queued messages won't be delivered
348
+ for (const item of queue) {
349
+ item.onResult?.(false);
350
+ }
351
+ queue.length = 0;
352
+ closed = true;
353
+ if (resolver) {
354
+ resolver(null);
355
+ resolver = null;
356
+ }
357
+ };
358
+
359
+ return { injector, getNextItem, notifySessionEnded };
360
+ }
361
+
362
+ function toAsyncIterablePrompt(
363
+ messagesPrompt: string,
364
+ outputStreamEnded: Promise<unknown>,
365
+ sessionId?: string,
366
+ contentParts?: SDKUserMessage['message']['content'],
367
+ onStreamStart?: (injector: MessageInjector) => void
368
+ ): AsyncIterable<SDKUserMessage> {
369
+ const content = (
370
+ contentParts && contentParts.length > 0
371
+ ? contentParts
372
+ : [{ type: 'text', text: messagesPrompt }]
373
+ ) as SDKUserMessage['message']['content'];
374
+
375
+ const initialMsg: SDKUserMessage = {
376
+ type: 'user',
377
+ message: {
378
+ role: 'user',
379
+ content,
380
+ },
381
+ parent_tool_use_id: null,
382
+ session_id: sessionId ?? '',
383
+ };
384
+
385
+ // If no callback, use simple behavior (backwards compatible)
386
+ if (!onStreamStart) {
387
+ return {
388
+ async *[Symbol.asyncIterator]() {
389
+ yield initialMsg;
390
+ await outputStreamEnded;
391
+ },
392
+ };
393
+ }
394
+
395
+ // With injection support: create injector and yield messages as they arrive
396
+ const { injector, getNextItem, notifySessionEnded } = createMessageInjector();
397
+
398
+ return {
399
+ async *[Symbol.asyncIterator]() {
400
+ // Yield initial message
401
+ yield initialMsg;
402
+
403
+ // Notify consumer that streaming has started
404
+ onStreamStart(injector);
405
+
406
+ // Race between output ending and new messages arriving
407
+ let streamEnded = false;
408
+ void outputStreamEnded.then(() => {
409
+ streamEnded = true;
410
+ // Notify any pending injections that the session ended
411
+ notifySessionEnded();
412
+ });
413
+
414
+ // Keep yielding injected messages until stream ends or injector closes
415
+ while (!streamEnded) {
416
+ // Race getNextItem against outputStreamEnded
417
+ // We get the full item so we can call onResult AFTER yielding
418
+ const item = await Promise.race([getNextItem(), outputStreamEnded.then(() => null)]);
419
+
420
+ if (item === null) {
421
+ // Ensure we don't close the input stream prematurely.
422
+ // Wait for output to complete to avoid truncation issues.
423
+ await outputStreamEnded;
424
+ break;
425
+ }
426
+
427
+ const sdkMsg: SDKUserMessage = {
428
+ type: 'user',
429
+ message: {
430
+ role: 'user',
431
+ content: [{ type: 'text', text: item.content }],
432
+ },
433
+ parent_tool_use_id: null,
434
+ session_id: sessionId ?? '',
435
+ };
436
+ yield sdkMsg;
437
+
438
+ // Only report delivery AFTER successfully yielding
439
+ item.onResult?.(true);
440
+ }
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Options for creating a Claude Code language model instance.
447
+ *
448
+ * @example
449
+ * ```typescript
450
+ * const model = new ClaudeCodeLanguageModel({
451
+ * id: 'opus',
452
+ * settings: {
453
+ * maxTurns: 10,
454
+ * permissionMode: 'auto'
455
+ * }
456
+ * });
457
+ * ```
458
+ */
459
+ export interface ClaudeCodeLanguageModelOptions {
460
+ /**
461
+ * The model identifier to use.
462
+ * Can be 'opus', 'sonnet', 'haiku', or a custom model string.
463
+ */
464
+ id: ClaudeCodeModelId;
465
+
466
+ /**
467
+ * Optional settings to configure the model behavior.
468
+ */
469
+ settings?: ClaudeCodeSettings;
470
+
471
+ /**
472
+ * Validation warnings from settings validation.
473
+ * Used internally to pass warnings from provider.
474
+ */
475
+ settingsValidationWarnings?: string[];
476
+ }
477
+
478
+ /**
479
+ * Supported Claude model identifiers.
480
+ * - 'opus': Claude Opus (most capable)
481
+ * - 'sonnet': Claude Sonnet (balanced performance)
482
+ * - 'haiku': Claude Haiku (fastest, most cost-effective)
483
+ * - Custom string: Any full model identifier (e.g., 'claude-opus-4-5', 'claude-sonnet-4-5-20250514')
484
+ *
485
+ * @example
486
+ * ```typescript
487
+ * const opusModel = claudeCode('opus');
488
+ * const sonnetModel = claudeCode('sonnet');
489
+ * const haikuModel = claudeCode('haiku');
490
+ * const customModel = claudeCode('claude-opus-4-5');
491
+ * ```
492
+ */
493
+ export type ClaudeCodeModelId = 'opus' | 'sonnet' | 'haiku' | (string & {});
494
+
495
+ const modelMap: Record<string, string> = {
496
+ opus: 'opus',
497
+ sonnet: 'sonnet',
498
+ haiku: 'haiku',
499
+ };
500
+
501
+ /**
502
+ * Maximum size for tool results sent to the client stream.
503
+ * Interior Claude Code process has full data; this only affects client stream.
504
+ */
505
+ const MAX_TOOL_RESULT_SIZE = 10000;
506
+
507
+ /**
508
+ * Truncates large tool results to prevent stream bloat.
509
+ * Only the largest string value in an object/array is truncated.
510
+ * Preserves the original type (array stays array, object stays object).
511
+ */
512
+ function truncateToolResultForStream(
513
+ result: unknown,
514
+ maxSize: number = MAX_TOOL_RESULT_SIZE
515
+ ): unknown {
516
+ if (typeof result === 'string') {
517
+ if (result.length <= maxSize) return result;
518
+ return result.slice(0, maxSize) + `\n...[truncated ${result.length - maxSize} chars]`;
519
+ }
520
+
521
+ if (typeof result !== 'object' || result === null) return result;
522
+
523
+ // Handle arrays separately to preserve array type
524
+ if (Array.isArray(result)) {
525
+ let largestIndex = -1;
526
+ let largestSize = 0;
527
+
528
+ for (let i = 0; i < result.length; i++) {
529
+ const value = result[i];
530
+ if (typeof value === 'string' && value.length > largestSize) {
531
+ largestIndex = i;
532
+ largestSize = value.length;
533
+ }
534
+ }
535
+
536
+ if (largestIndex >= 0 && largestSize > maxSize) {
537
+ const truncatedValue =
538
+ (result[largestIndex] as string).slice(0, maxSize) +
539
+ `\n...[truncated ${largestSize - maxSize} chars]`;
540
+ const cloned = [...result];
541
+ cloned[largestIndex] = truncatedValue;
542
+ return cloned;
543
+ }
544
+
545
+ return result;
546
+ }
547
+
548
+ // For objects, find and truncate only the largest string value
549
+ const obj = result as Record<string, unknown>;
550
+ let largestKey: string | null = null;
551
+ let largestSize = 0;
552
+
553
+ for (const [key, value] of Object.entries(obj)) {
554
+ if (typeof value === 'string' && value.length > largestSize) {
555
+ largestKey = key;
556
+ largestSize = value.length;
557
+ }
558
+ }
559
+
560
+ if (largestKey && largestSize > maxSize) {
561
+ const truncatedValue =
562
+ (obj[largestKey] as string).slice(0, maxSize) +
563
+ `\n...[truncated ${largestSize - maxSize} chars]`;
564
+ return { ...obj, [largestKey]: truncatedValue };
565
+ }
566
+
567
+ return result;
568
+ }
569
+
570
+ /**
571
+ * Language model implementation for Claude Code SDK.
572
+ * This class implements the AI SDK's LanguageModelV3 interface to provide
573
+ * integration with Claude models through the Claude Agent SDK.
574
+ *
575
+ * Features:
576
+ * - Supports streaming and non-streaming generation
577
+ * - Native structured outputs via SDK's outputFormat (guaranteed schema compliance)
578
+ * - Manages CLI sessions for conversation continuity
579
+ * - Provides detailed error handling and retry logic
580
+ *
581
+ * Limitations:
582
+ * - Image inputs require streaming mode
583
+ * - Some parameters like temperature and max tokens are not supported by the CLI
584
+ *
585
+ * @example
586
+ * ```typescript
587
+ * const model = new ClaudeCodeLanguageModel({
588
+ * id: 'opus',
589
+ * settings: { maxTurns: 5 }
590
+ * });
591
+ *
592
+ * const result = await model.doGenerate({
593
+ * prompt: [{ role: 'user', content: 'Hello!' }],
594
+ * mode: { type: 'regular' }
595
+ * });
596
+ * ```
597
+ */
598
+
599
+ export class ClaudeCodeLanguageModel implements LanguageModelV3 {
600
+ readonly specificationVersion = 'v3' as const;
601
+ readonly defaultObjectGenerationMode = 'json' as const;
602
+ readonly supportsImageUrls = false;
603
+ readonly supportedUrls = {};
604
+ readonly supportsStructuredOutputs = true;
605
+
606
+ // Fallback/magic string constants
607
+ static readonly UNKNOWN_TOOL_NAME = 'unknown-tool';
608
+
609
+ // Tool input safety limits
610
+ private static readonly MAX_TOOL_INPUT_SIZE = 1_048_576; // 1MB hard limit
611
+ private static readonly MAX_TOOL_INPUT_WARN = 102_400; // 100KB warning threshold
612
+ private static readonly MAX_DELTA_CALC_SIZE = 10_000; // 10KB delta computation threshold
613
+
614
+ readonly modelId: ClaudeCodeModelId;
615
+ readonly settings: ClaudeCodeSettings;
616
+
617
+ private sessionId?: string;
618
+ private modelValidationWarning?: string;
619
+ private settingsValidationWarnings: string[];
620
+ private logger: Logger;
621
+
622
+ constructor(options: ClaudeCodeLanguageModelOptions) {
623
+ this.modelId = options.id;
624
+ this.settings = options.settings ?? {};
625
+ this.settingsValidationWarnings = options.settingsValidationWarnings ?? [];
626
+
627
+ // Create logger that respects verbose setting
628
+ const baseLogger = getLogger(this.settings.logger);
629
+ this.logger = createVerboseLogger(baseLogger, this.settings.verbose ?? false);
630
+
631
+ // Validate model ID format
632
+ if (!this.modelId || typeof this.modelId !== 'string' || this.modelId.trim() === '') {
633
+ throw new NoSuchModelError({
634
+ modelId: this.modelId,
635
+ modelType: 'languageModel',
636
+ });
637
+ }
638
+
639
+ // Additional model ID validation
640
+ this.modelValidationWarning = validateModelId(this.modelId);
641
+ if (this.modelValidationWarning) {
642
+ this.logger.warn(`Claude Code Model: ${this.modelValidationWarning}`);
643
+ }
644
+ }
645
+
646
+ get provider(): string {
647
+ return 'claude-code';
648
+ }
649
+
650
+ private getModel(): string {
651
+ const mapped = modelMap[this.modelId];
652
+ return mapped ?? this.modelId;
653
+ }
654
+
655
+ private getSanitizedSdkOptions(): Partial<Options> | undefined {
656
+ if (!this.settings.sdkOptions || typeof this.settings.sdkOptions !== 'object') {
657
+ return undefined;
658
+ }
659
+
660
+ const sanitized = { ...(this.settings.sdkOptions as Record<string, unknown>) };
661
+ const blockedKeys = Array.from(SDK_OPTIONS_BLOCKLIST).filter((key) => key in sanitized);
662
+
663
+ if (blockedKeys.length > 0) {
664
+ this.logger.warn(
665
+ `[claude-code] sdkOptions includes provider-managed fields (${blockedKeys.join(
666
+ ', '
667
+ )}); these will be ignored.`
668
+ );
669
+ blockedKeys.forEach((key) => delete sanitized[key]);
670
+ }
671
+
672
+ return sanitized as Partial<Options>;
673
+ }
674
+
675
+ private getEffectiveResume(sdkOptions?: Partial<Options>): string | undefined {
676
+ return sdkOptions?.resume ?? this.settings.resume ?? this.sessionId;
677
+ }
678
+
679
+ private extractToolUses(content: unknown): ClaudeToolUse[] {
680
+ if (!Array.isArray(content)) {
681
+ return [];
682
+ }
683
+
684
+ return content
685
+ .filter(
686
+ (item): item is { type: string; id?: unknown; name?: unknown; input?: unknown } =>
687
+ typeof item === 'object' &&
688
+ item !== null &&
689
+ 'type' in item &&
690
+ (item as { type: unknown }).type === 'tool_use'
691
+ )
692
+ .map((item) => {
693
+ const { id, name, input, parent_tool_use_id } = item as {
694
+ id?: unknown;
695
+ name?: unknown;
696
+ input?: unknown;
697
+ parent_tool_use_id?: unknown;
698
+ };
699
+ return {
700
+ id: typeof id === 'string' && id.length > 0 ? id : generateId(),
701
+ name:
702
+ typeof name === 'string' && name.length > 0
703
+ ? name
704
+ : ClaudeCodeLanguageModel.UNKNOWN_TOOL_NAME,
705
+ input,
706
+ parentToolUseId: typeof parent_tool_use_id === 'string' ? parent_tool_use_id : null,
707
+ } satisfies ClaudeToolUse;
708
+ });
709
+ }
710
+
711
+ private extractToolResults(content: unknown): ClaudeToolResult[] {
712
+ if (!Array.isArray(content)) {
713
+ return [];
714
+ }
715
+
716
+ return content
717
+ .filter(
718
+ (
719
+ item
720
+ ): item is {
721
+ type: string;
722
+ tool_use_id?: unknown;
723
+ content?: unknown;
724
+ is_error?: unknown;
725
+ name?: unknown;
726
+ } =>
727
+ typeof item === 'object' &&
728
+ item !== null &&
729
+ 'type' in item &&
730
+ (item as { type: unknown }).type === 'tool_result'
731
+ )
732
+ .map((item) => {
733
+ const { tool_use_id, content, is_error, name } = item;
734
+ return {
735
+ id:
736
+ typeof tool_use_id === 'string' && tool_use_id.length > 0 ? tool_use_id : generateId(),
737
+ name: typeof name === 'string' && name.length > 0 ? name : undefined,
738
+ result: content,
739
+ isError: Boolean(is_error),
740
+ } satisfies ClaudeToolResult;
741
+ });
742
+ }
743
+
744
+ private extractToolErrors(content: unknown): Array<{
745
+ id: string;
746
+ name?: string;
747
+ error: unknown;
748
+ }> {
749
+ if (!Array.isArray(content)) {
750
+ return [];
751
+ }
752
+
753
+ return content
754
+ .filter(
755
+ (
756
+ item
757
+ ): item is {
758
+ type: string;
759
+ tool_use_id?: unknown;
760
+ error?: unknown;
761
+ name?: unknown;
762
+ } =>
763
+ typeof item === 'object' &&
764
+ item !== null &&
765
+ 'type' in item &&
766
+ (item as { type: unknown }).type === 'tool_error'
767
+ )
768
+ .map((item) => {
769
+ const { tool_use_id, error, name } = item as {
770
+ tool_use_id?: unknown;
771
+ error?: unknown;
772
+ name?: unknown;
773
+ };
774
+ return {
775
+ id:
776
+ typeof tool_use_id === 'string' && tool_use_id.length > 0 ? tool_use_id : generateId(),
777
+ name: typeof name === 'string' && name.length > 0 ? name : undefined,
778
+ error,
779
+ };
780
+ });
781
+ }
782
+
783
+ private serializeToolInput(input: unknown): string {
784
+ if (typeof input === 'string') {
785
+ return this.checkInputSize(input);
786
+ }
787
+
788
+ if (input === undefined) {
789
+ return '';
790
+ }
791
+
792
+ try {
793
+ const serialized = JSON.stringify(input);
794
+ return this.checkInputSize(serialized);
795
+ } catch {
796
+ const fallback = String(input);
797
+ return this.checkInputSize(fallback);
798
+ }
799
+ }
800
+
801
+ private checkInputSize(str: string): string {
802
+ const length = str.length;
803
+
804
+ if (length > ClaudeCodeLanguageModel.MAX_TOOL_INPUT_SIZE) {
805
+ throw new Error(
806
+ `Tool input exceeds maximum size of ${ClaudeCodeLanguageModel.MAX_TOOL_INPUT_SIZE} bytes (got ${length} bytes). This may indicate a malformed request or an attempt to process excessively large data.`
807
+ );
808
+ }
809
+
810
+ if (length > ClaudeCodeLanguageModel.MAX_TOOL_INPUT_WARN) {
811
+ this.logger.warn(
812
+ `[claude-code] Large tool input detected: ${length} bytes. Performance may be impacted. Consider chunking or reducing input size.`
813
+ );
814
+ }
815
+
816
+ return str;
817
+ }
818
+
819
+ private normalizeToolResult(result: unknown): unknown {
820
+ if (typeof result === 'string') {
821
+ try {
822
+ return JSON.parse(result);
823
+ } catch {
824
+ return result;
825
+ }
826
+ }
827
+ // Handle MCP content format: [{type: 'text', text: '...'}, ...]
828
+ // MCP tools can return multiple content blocks; only normalize when all blocks are text.
829
+ if (Array.isArray(result) && result.length > 0) {
830
+ // Collect all text content from text blocks
831
+ const textBlocks = result
832
+ .filter(
833
+ (block): block is { type: 'text'; text: string } =>
834
+ block?.type === 'text' && typeof block.text === 'string'
835
+ )
836
+ .map((block) => block.text);
837
+
838
+ if (textBlocks.length !== result.length) {
839
+ return result;
840
+ }
841
+
842
+ // If single text block, try to parse as JSON
843
+ if (textBlocks.length === 1) {
844
+ try {
845
+ return JSON.parse(textBlocks[0]);
846
+ } catch {
847
+ return textBlocks[0];
848
+ }
849
+ }
850
+
851
+ // Multiple text blocks: join them and try to parse as JSON
852
+ const combined = textBlocks.join('\n');
853
+ try {
854
+ return JSON.parse(combined);
855
+ } catch {
856
+ return combined;
857
+ }
858
+ }
859
+
860
+ return result;
861
+ }
862
+
863
+ private generateAllWarnings(
864
+ options:
865
+ | Parameters<LanguageModelV3['doGenerate']>[0]
866
+ | Parameters<LanguageModelV3['doStream']>[0],
867
+ prompt: string
868
+ ): SharedV3Warning[] {
869
+ const warnings: SharedV3Warning[] = [];
870
+ const unsupportedParams: string[] = [];
871
+
872
+ // Check for unsupported parameters
873
+ if (options.temperature !== undefined) unsupportedParams.push('temperature');
874
+ if (options.topP !== undefined) unsupportedParams.push('topP');
875
+ if (options.topK !== undefined) unsupportedParams.push('topK');
876
+ if (options.presencePenalty !== undefined) unsupportedParams.push('presencePenalty');
877
+ if (options.frequencyPenalty !== undefined) unsupportedParams.push('frequencyPenalty');
878
+ if (options.stopSequences !== undefined && options.stopSequences.length > 0)
879
+ unsupportedParams.push('stopSequences');
880
+ if (options.seed !== undefined) unsupportedParams.push('seed');
881
+
882
+ if (unsupportedParams.length > 0) {
883
+ // Add a warning for each unsupported parameter
884
+ for (const param of unsupportedParams) {
885
+ warnings.push({
886
+ type: 'unsupported',
887
+ feature: param,
888
+ details: `Claude Code SDK does not support the ${param} parameter. It will be ignored.`,
889
+ });
890
+ }
891
+ }
892
+
893
+ // Add model validation warning if present
894
+ if (this.modelValidationWarning) {
895
+ warnings.push({
896
+ type: 'other',
897
+ message: this.modelValidationWarning,
898
+ });
899
+ }
900
+
901
+ // Add settings validation warnings
902
+ this.settingsValidationWarnings.forEach((warning) => {
903
+ warnings.push({
904
+ type: 'other',
905
+ message: warning,
906
+ });
907
+ });
908
+
909
+ // Warn if JSON response format is requested without a schema
910
+ // Claude Code only supports structured outputs with schemas (like Anthropic's API)
911
+ if (options.responseFormat?.type === 'json' && !options.responseFormat.schema) {
912
+ warnings.push({
913
+ type: 'unsupported',
914
+ feature: 'responseFormat',
915
+ details:
916
+ 'JSON response format requires a schema for the Claude Code provider. The JSON responseFormat is ignored and the call is treated as plain text.',
917
+ });
918
+ }
919
+
920
+ // Validate prompt
921
+ const promptWarning = validatePrompt(prompt);
922
+ if (promptWarning) {
923
+ warnings.push({
924
+ type: 'other',
925
+ message: promptWarning,
926
+ });
927
+ }
928
+
929
+ return warnings;
930
+ }
931
+
932
+ private createQueryOptions(
933
+ abortController: AbortController,
934
+ responseFormat?: Parameters<LanguageModelV3['doGenerate']>[0]['responseFormat'],
935
+ stderrCollector?: (data: string) => void,
936
+ sdkOptions?: Partial<Options>,
937
+ effectiveResume?: string
938
+ ): Options {
939
+ const opts: Partial<Options> & Record<string, unknown> = {
940
+ model: this.getModel(),
941
+ abortController,
942
+ resume: effectiveResume ?? this.settings.resume ?? this.sessionId,
943
+ pathToClaudeCodeExecutable: this.settings.pathToClaudeCodeExecutable,
944
+ maxTurns: this.settings.maxTurns,
945
+ maxThinkingTokens: this.settings.maxThinkingTokens,
946
+ cwd: this.settings.cwd,
947
+ executable: this.settings.executable,
948
+ executableArgs: this.settings.executableArgs,
949
+ permissionMode: this.settings.permissionMode,
950
+ permissionPromptToolName: this.settings.permissionPromptToolName,
951
+ continue: this.settings.continue,
952
+ allowedTools: this.settings.allowedTools,
953
+ disallowedTools: this.settings.disallowedTools,
954
+ betas: this.settings.betas,
955
+ allowDangerouslySkipPermissions: this.settings.allowDangerouslySkipPermissions,
956
+ enableFileCheckpointing: this.settings.enableFileCheckpointing,
957
+ maxBudgetUsd: this.settings.maxBudgetUsd,
958
+ plugins: this.settings.plugins,
959
+ resumeSessionAt: this.settings.resumeSessionAt,
960
+ sandbox: this.settings.sandbox,
961
+ tools: this.settings.tools,
962
+ mcpServers: this.settings.mcpServers,
963
+ canUseTool: this.settings.canUseTool,
964
+ };
965
+ // NEW: Agent SDK options with legacy mapping
966
+ if (this.settings.systemPrompt !== undefined) {
967
+ opts.systemPrompt = this.settings.systemPrompt;
968
+ } else if (this.settings.customSystemPrompt !== undefined) {
969
+ // Deprecation warning for legacy field
970
+ this.logger.warn(
971
+ "[claude-code] 'customSystemPrompt' is deprecated and will be removed in a future major release. Please use 'systemPrompt' instead (string or { type: 'preset', preset: 'claude_code', append? })."
972
+ );
973
+ opts.systemPrompt = this.settings.customSystemPrompt;
974
+ } else if (this.settings.appendSystemPrompt !== undefined) {
975
+ // Deprecation warning for legacy field
976
+ this.logger.warn(
977
+ "[claude-code] 'appendSystemPrompt' is deprecated and will be removed in a future major release. Please use 'systemPrompt: { type: 'preset', preset: 'claude_code', append: <text> }' instead."
978
+ );
979
+ opts.systemPrompt = {
980
+ type: 'preset',
981
+ preset: 'claude_code',
982
+ append: this.settings.appendSystemPrompt,
983
+ } as const;
984
+ }
985
+ if (this.settings.settingSources !== undefined) {
986
+ opts.settingSources = this.settings.settingSources;
987
+ }
988
+ if (this.settings.additionalDirectories !== undefined) {
989
+ opts.additionalDirectories = this.settings.additionalDirectories;
990
+ }
991
+ if (this.settings.agents !== undefined) {
992
+ opts.agents = this.settings.agents;
993
+ }
994
+ if (this.settings.includePartialMessages !== undefined) {
995
+ opts.includePartialMessages = this.settings.includePartialMessages;
996
+ }
997
+ if (this.settings.fallbackModel !== undefined) {
998
+ opts.fallbackModel = this.settings.fallbackModel;
999
+ }
1000
+ if (this.settings.forkSession !== undefined) {
1001
+ opts.forkSession = this.settings.forkSession;
1002
+ }
1003
+ if (this.settings.strictMcpConfig !== undefined) {
1004
+ opts.strictMcpConfig = this.settings.strictMcpConfig;
1005
+ }
1006
+ if (this.settings.extraArgs !== undefined) {
1007
+ opts.extraArgs = this.settings.extraArgs;
1008
+ }
1009
+ if (this.settings.persistSession !== undefined) {
1010
+ opts.persistSession = this.settings.persistSession;
1011
+ }
1012
+ if (this.settings.spawnClaudeCodeProcess !== undefined) {
1013
+ opts.spawnClaudeCodeProcess = this.settings.spawnClaudeCodeProcess;
1014
+ }
1015
+ // hooks is supported in newer SDKs; include it if provided
1016
+ if (this.settings.hooks) {
1017
+ opts.hooks = this.settings.hooks;
1018
+ }
1019
+ if (this.settings.sessionId !== undefined) {
1020
+ opts.sessionId = this.settings.sessionId;
1021
+ }
1022
+ if (this.settings.debug !== undefined) {
1023
+ opts.debug = this.settings.debug;
1024
+ }
1025
+ if (this.settings.debugFile !== undefined) {
1026
+ opts.debugFile = this.settings.debugFile;
1027
+ }
1028
+
1029
+ const sdkOverrides = sdkOptions
1030
+ ? (sdkOptions as Partial<Options> & Record<string, unknown>)
1031
+ : undefined;
1032
+ const sdkEnv =
1033
+ sdkOverrides && typeof sdkOverrides.env === 'object' && sdkOverrides.env !== null
1034
+ ? (sdkOverrides.env as Record<string, string | undefined>)
1035
+ : undefined;
1036
+ const sdkStderr =
1037
+ sdkOverrides && typeof sdkOverrides.stderr === 'function'
1038
+ ? (sdkOverrides.stderr as (data: string) => void)
1039
+ : undefined;
1040
+ if (sdkOverrides) {
1041
+ const rest = { ...sdkOverrides };
1042
+ delete rest.env;
1043
+ delete rest.stderr;
1044
+ Object.assign(opts, rest);
1045
+ }
1046
+
1047
+ // Wrap stderr callback to also collect data for error reporting
1048
+ const userStderrCallback = sdkStderr ?? this.settings.stderr;
1049
+ if (stderrCollector || userStderrCallback) {
1050
+ opts.stderr = (data: string) => {
1051
+ if (stderrCollector) stderrCollector(data);
1052
+ if (userStderrCallback) userStderrCallback(data);
1053
+ };
1054
+ }
1055
+
1056
+ if (this.settings.env !== undefined || sdkEnv !== undefined) {
1057
+ const baseEnv = getBaseProcessEnv();
1058
+ opts.env = { ...baseEnv, ...this.settings.env, ...sdkEnv };
1059
+ }
1060
+
1061
+ // Native structured outputs (SDK 0.1.45+)
1062
+ if (responseFormat?.type === 'json' && responseFormat.schema) {
1063
+ opts.outputFormat = {
1064
+ type: 'json_schema',
1065
+ schema: responseFormat.schema as Record<string, unknown>,
1066
+ };
1067
+ }
1068
+
1069
+ return opts as Options;
1070
+ }
1071
+
1072
+ private handleClaudeCodeError(
1073
+ error: unknown,
1074
+ messagesPrompt: string,
1075
+ collectedStderr?: string
1076
+ ): APICallError | LoadAPIKeyError {
1077
+ // Handle AbortError from the SDK
1078
+ if (isAbortError(error)) {
1079
+ // Return the abort reason if available, otherwise the error itself
1080
+ throw error;
1081
+ }
1082
+
1083
+ // Type guard for error with properties
1084
+ const isErrorWithMessage = (err: unknown): err is { message?: string } => {
1085
+ return typeof err === 'object' && err !== null && 'message' in err;
1086
+ };
1087
+
1088
+ const isErrorWithCode = (
1089
+ err: unknown
1090
+ ): err is { code?: string; exitCode?: number; stderr?: string } => {
1091
+ return typeof err === 'object' && err !== null;
1092
+ };
1093
+
1094
+ // Check for authentication errors with improved detection
1095
+ const authErrorPatterns = [
1096
+ 'not logged in',
1097
+ 'authentication',
1098
+ 'unauthorized',
1099
+ 'auth failed',
1100
+ 'please login',
1101
+ 'claude login',
1102
+ '/login', // CLI returns "Please run /login"
1103
+ 'invalid api key',
1104
+ ];
1105
+
1106
+ const errorMessage =
1107
+ isErrorWithMessage(error) && error.message ? error.message.toLowerCase() : '';
1108
+
1109
+ const exitCode =
1110
+ isErrorWithCode(error) && typeof error.exitCode === 'number' ? error.exitCode : undefined;
1111
+
1112
+ const isAuthError =
1113
+ authErrorPatterns.some((pattern) => errorMessage.includes(pattern)) || exitCode === 401;
1114
+
1115
+ if (isAuthError) {
1116
+ return createAuthenticationError({
1117
+ message:
1118
+ isErrorWithMessage(error) && error.message
1119
+ ? error.message
1120
+ : 'Authentication failed. Please ensure Claude Code SDK is properly authenticated.',
1121
+ });
1122
+ }
1123
+
1124
+ // Check for timeout errors
1125
+ const errorCode = isErrorWithCode(error) && typeof error.code === 'string' ? error.code : '';
1126
+
1127
+ if (errorCode === 'ETIMEDOUT' || errorMessage.includes('timeout')) {
1128
+ return createTimeoutError({
1129
+ message: isErrorWithMessage(error) && error.message ? error.message : 'Request timed out',
1130
+ promptExcerpt: messagesPrompt.substring(0, 200),
1131
+ // Don't specify timeoutMs since we don't know the actual timeout value
1132
+ // It's controlled by the consumer via AbortSignal
1133
+ });
1134
+ }
1135
+
1136
+ // Create general API call error with appropriate retry flag
1137
+ const isRetryable =
1138
+ errorCode === 'ENOENT' ||
1139
+ errorCode === 'ECONNREFUSED' ||
1140
+ errorCode === 'ETIMEDOUT' ||
1141
+ errorCode === 'ECONNRESET';
1142
+
1143
+ // Use error.stderr if available from SDK, otherwise use collected stderr
1144
+ const stderrFromError =
1145
+ isErrorWithCode(error) && typeof error.stderr === 'string' ? error.stderr : undefined;
1146
+ const stderr = stderrFromError || collectedStderr || undefined;
1147
+
1148
+ return createAPICallError({
1149
+ message: isErrorWithMessage(error) && error.message ? error.message : 'Claude Code SDK error',
1150
+ code: errorCode || undefined,
1151
+ exitCode: exitCode,
1152
+ stderr,
1153
+ promptExcerpt: messagesPrompt.substring(0, 200),
1154
+ isRetryable,
1155
+ });
1156
+ }
1157
+
1158
+ private setSessionId(sessionId: string): void {
1159
+ this.sessionId = sessionId;
1160
+ const warning = validateSessionId(sessionId);
1161
+ if (warning) {
1162
+ this.logger.warn(`Claude Code Session: ${warning}`);
1163
+ }
1164
+ }
1165
+
1166
+ async doGenerate(
1167
+ options: Parameters<LanguageModelV3['doGenerate']>[0]
1168
+ ): Promise<Awaited<ReturnType<LanguageModelV3['doGenerate']>>> {
1169
+ this.logger.debug(`[claude-code] Starting doGenerate request with model: ${this.modelId}`);
1170
+ this.logger.debug(`[claude-code] Response format: ${options.responseFormat?.type ?? 'none'}`);
1171
+
1172
+ const {
1173
+ messagesPrompt,
1174
+ warnings: messageWarnings,
1175
+ streamingContentParts,
1176
+ hasImageParts,
1177
+ } = convertToClaudeCodeMessages(options.prompt);
1178
+
1179
+ this.logger.debug(
1180
+ `[claude-code] Converted ${options.prompt.length} messages, hasImageParts: ${hasImageParts}`
1181
+ );
1182
+
1183
+ const abortController = new AbortController();
1184
+ let abortListener: (() => void) | undefined;
1185
+ if (options.abortSignal?.aborted) {
1186
+ // Propagate already-aborted state immediately with original reason
1187
+ abortController.abort(options.abortSignal.reason);
1188
+ } else if (options.abortSignal) {
1189
+ abortListener = () => abortController.abort(options.abortSignal?.reason);
1190
+ options.abortSignal.addEventListener('abort', abortListener, { once: true });
1191
+ }
1192
+
1193
+ // Collect stderr for error reporting (SDK may not include it in errors)
1194
+ let collectedStderr = '';
1195
+ const stderrCollector = (data: string) => {
1196
+ collectedStderr += data;
1197
+ };
1198
+
1199
+ const sdkOptions = this.getSanitizedSdkOptions();
1200
+ const effectiveResume = this.getEffectiveResume(sdkOptions);
1201
+ const queryOptions = this.createQueryOptions(
1202
+ abortController,
1203
+ options.responseFormat,
1204
+ stderrCollector,
1205
+ sdkOptions,
1206
+ effectiveResume
1207
+ );
1208
+
1209
+ let text = '';
1210
+ let structuredOutput: unknown | undefined;
1211
+ let usage: LanguageModelV3Usage = createEmptyUsage();
1212
+ let finishReason: LanguageModelV3FinishReason = { unified: 'stop', raw: undefined };
1213
+ let wasTruncated = false;
1214
+ let costUsd: number | undefined;
1215
+ let durationMs: number | undefined;
1216
+ let modelUsage: Record<string, unknown> | undefined;
1217
+ const warnings: SharedV3Warning[] = this.generateAllWarnings(options, messagesPrompt);
1218
+
1219
+ // Add warnings from message conversion
1220
+ if (messageWarnings) {
1221
+ messageWarnings.forEach((warning) => {
1222
+ warnings.push({
1223
+ type: 'other',
1224
+ message: warning,
1225
+ });
1226
+ });
1227
+ }
1228
+
1229
+ const modeSetting = this.settings.streamingInput ?? 'auto';
1230
+ const effectiveCanUseTool = sdkOptions?.canUseTool ?? this.settings.canUseTool;
1231
+ const effectivePermissionPromptToolName =
1232
+ sdkOptions?.permissionPromptToolName ?? this.settings.permissionPromptToolName;
1233
+ const wantsStreamInput =
1234
+ modeSetting === 'always' || (modeSetting === 'auto' && !!effectiveCanUseTool);
1235
+
1236
+ if (!wantsStreamInput && hasImageParts) {
1237
+ warnings.push({
1238
+ type: 'other',
1239
+ message: STREAMING_FEATURE_WARNING,
1240
+ });
1241
+ }
1242
+
1243
+ let done = () => {};
1244
+ const outputStreamEnded = new Promise((resolve) => {
1245
+ done = () => resolve(undefined);
1246
+ });
1247
+ try {
1248
+ if (effectiveCanUseTool && effectivePermissionPromptToolName) {
1249
+ throw new Error(
1250
+ "canUseTool requires streamingInput mode ('auto' or 'always') and cannot be used with permissionPromptToolName (SDK constraint). Set streamingInput: 'auto' (or 'always') and remove permissionPromptToolName, or remove canUseTool."
1251
+ );
1252
+ }
1253
+ // hold input stream open until results
1254
+ // see: https://github.com/anthropics/claude-code/issues/4775
1255
+ const sdkPrompt = wantsStreamInput
1256
+ ? toAsyncIterablePrompt(
1257
+ messagesPrompt,
1258
+ outputStreamEnded,
1259
+ effectiveResume,
1260
+ streamingContentParts,
1261
+ this.settings.onStreamStart
1262
+ )
1263
+ : messagesPrompt;
1264
+
1265
+ this.logger.debug(
1266
+ `[claude-code] Executing query with streamingInput: ${wantsStreamInput}, session: ${effectiveResume ?? 'new'}`
1267
+ );
1268
+
1269
+ const response = query({
1270
+ prompt: sdkPrompt,
1271
+ options: queryOptions,
1272
+ });
1273
+
1274
+ // Invoke onQueryCreated callback to expose Query object for advanced features
1275
+ // like mid-stream message injection via query.streamInput()
1276
+ this.settings.onQueryCreated?.(response);
1277
+
1278
+ for await (const message of response) {
1279
+ this.logger.debug(`[claude-code] Received message type: ${message.type}`);
1280
+ if (message.type === 'assistant') {
1281
+ text += message.message.content
1282
+ .map((c: { type: string; text?: string }) => (c.type === 'text' ? c.text : ''))
1283
+ .join('');
1284
+ } else if (message.type === 'result') {
1285
+ done();
1286
+ this.setSessionId(message.session_id);
1287
+ costUsd = message.total_cost_usd;
1288
+ durationMs = message.duration_ms;
1289
+ modelUsage = message.modelUsage;
1290
+
1291
+ // Handle is_error flag in result message (e.g., auth failures)
1292
+ // The CLI returns successful JSON with is_error: true and error message in result field
1293
+ if ('is_error' in message && message.is_error === true) {
1294
+ const errorMessage =
1295
+ 'result' in message && typeof message.result === 'string'
1296
+ ? message.result
1297
+ : 'Claude Code CLI returned an error';
1298
+ throw Object.assign(new Error(errorMessage), { exitCode: 1 });
1299
+ }
1300
+
1301
+ // Handle structured output errors (SDK 0.1.45+)
1302
+ // Use string comparison to support new SDK subtypes not yet in TypeScript definitions
1303
+ if ((message.subtype as string) === 'error_max_structured_output_retries') {
1304
+ throw new Error(
1305
+ 'Failed to generate valid structured output after maximum retries. The model could not produce a response matching the required schema.'
1306
+ );
1307
+ }
1308
+
1309
+ // Capture structured output if available (SDK 0.1.45+)
1310
+ if ('structured_output' in message && message.structured_output !== undefined) {
1311
+ structuredOutput = message.structured_output;
1312
+ this.logger.debug('[claude-code] Received structured output from SDK');
1313
+ }
1314
+
1315
+ this.logger.info(
1316
+ `[claude-code] Request completed - Session: ${message.session_id}, Cost: $${costUsd?.toFixed(4) ?? 'N/A'}, Duration: ${durationMs ?? 'N/A'}ms`
1317
+ );
1318
+
1319
+ if ('usage' in message) {
1320
+ usage = convertClaudeCodeUsage(message.usage);
1321
+
1322
+ this.logger.debug(
1323
+ `[claude-code] Token usage - Input: ${usage.inputTokens.total}, Output: ${usage.outputTokens.total}`
1324
+ );
1325
+ }
1326
+
1327
+ const stopReason =
1328
+ 'stop_reason' in message
1329
+ ? ((message as Record<string, unknown>).stop_reason as string | null | undefined)
1330
+ : undefined;
1331
+ finishReason = mapClaudeCodeFinishReason(message.subtype, stopReason);
1332
+ this.logger.debug(`[claude-code] Finish reason: ${finishReason.unified}`);
1333
+ } else if (message.type === 'system' && message.subtype === 'init') {
1334
+ this.setSessionId(message.session_id);
1335
+ this.logger.info(`[claude-code] Session initialized: ${message.session_id}`);
1336
+ }
1337
+ }
1338
+ } catch (error: unknown) {
1339
+ done();
1340
+ this.logger.debug(
1341
+ `[claude-code] Error during doGenerate: ${error instanceof Error ? error.message : String(error)}`
1342
+ );
1343
+
1344
+ // Special handling for AbortError to preserve abort signal reason
1345
+ if (isAbortError(error)) {
1346
+ this.logger.debug('[claude-code] Request aborted by user');
1347
+ throw options.abortSignal?.aborted ? options.abortSignal.reason : error;
1348
+ }
1349
+
1350
+ if (isClaudeCodeTruncationError(error, text)) {
1351
+ this.logger.warn(
1352
+ `[claude-code] Detected truncated response, returning ${text.length} characters of buffered text`
1353
+ );
1354
+ wasTruncated = true;
1355
+ finishReason = { unified: 'length', raw: 'truncation' };
1356
+ warnings.push({
1357
+ type: 'other',
1358
+ message: CLAUDE_CODE_TRUNCATION_WARNING,
1359
+ });
1360
+ } else {
1361
+ // Use unified error handler
1362
+ throw this.handleClaudeCodeError(error, messagesPrompt, collectedStderr);
1363
+ }
1364
+ } finally {
1365
+ if (options.abortSignal && abortListener) {
1366
+ options.abortSignal.removeEventListener('abort', abortListener);
1367
+ }
1368
+ }
1369
+
1370
+ // Use structured output from SDK if available (native JSON schema support)
1371
+ // Otherwise fall back to accumulated text
1372
+ const finalText = structuredOutput !== undefined ? JSON.stringify(structuredOutput) : text;
1373
+
1374
+ return {
1375
+ content: [{ type: 'text', text: finalText }],
1376
+ usage,
1377
+ finishReason,
1378
+ warnings,
1379
+ response: {
1380
+ id: generateId(),
1381
+ timestamp: new Date(),
1382
+ modelId: this.modelId,
1383
+ },
1384
+ request: {
1385
+ body: messagesPrompt,
1386
+ },
1387
+ providerMetadata: {
1388
+ 'claude-code': {
1389
+ ...(this.sessionId !== undefined && { sessionId: this.sessionId }),
1390
+ ...(costUsd !== undefined && { costUsd }),
1391
+ ...(durationMs !== undefined && { durationMs }),
1392
+ ...(modelUsage !== undefined && { modelUsage: modelUsage as unknown as JSONValue }),
1393
+ ...(wasTruncated && { truncated: true }),
1394
+ },
1395
+ },
1396
+ };
1397
+ }
1398
+
1399
+ async doStream(
1400
+ options: Parameters<LanguageModelV3['doStream']>[0]
1401
+ ): Promise<Awaited<ReturnType<LanguageModelV3['doStream']>>> {
1402
+ this.logger.debug(`[claude-code] Starting doStream request with model: ${this.modelId}`);
1403
+ this.logger.debug(`[claude-code] Response format: ${options.responseFormat?.type ?? 'none'}`);
1404
+
1405
+ const {
1406
+ messagesPrompt,
1407
+ warnings: messageWarnings,
1408
+ streamingContentParts,
1409
+ hasImageParts,
1410
+ } = convertToClaudeCodeMessages(options.prompt);
1411
+
1412
+ this.logger.debug(
1413
+ `[claude-code] Converted ${options.prompt.length} messages for streaming, hasImageParts: ${hasImageParts}`
1414
+ );
1415
+
1416
+ const abortController = new AbortController();
1417
+ let abortListener: (() => void) | undefined;
1418
+ if (options.abortSignal?.aborted) {
1419
+ // Propagate already-aborted state immediately with original reason
1420
+ abortController.abort(options.abortSignal.reason);
1421
+ } else if (options.abortSignal) {
1422
+ abortListener = () => abortController.abort(options.abortSignal?.reason);
1423
+ options.abortSignal.addEventListener('abort', abortListener, { once: true });
1424
+ }
1425
+
1426
+ // Collect stderr for error reporting (SDK may not include it in errors)
1427
+ let collectedStderr = '';
1428
+ const stderrCollector = (data: string) => {
1429
+ collectedStderr += data;
1430
+ };
1431
+
1432
+ const sdkOptions = this.getSanitizedSdkOptions();
1433
+ const effectiveResume = this.getEffectiveResume(sdkOptions);
1434
+ const queryOptions = this.createQueryOptions(
1435
+ abortController,
1436
+ options.responseFormat,
1437
+ stderrCollector,
1438
+ sdkOptions,
1439
+ effectiveResume
1440
+ );
1441
+
1442
+ // Enable partial messages for true streaming (token-by-token delivery)
1443
+ // This can be overridden by user settings, but we default to true for doStream
1444
+ if (queryOptions.includePartialMessages === undefined) {
1445
+ queryOptions.includePartialMessages = true;
1446
+ }
1447
+
1448
+ const warnings: SharedV3Warning[] = this.generateAllWarnings(options, messagesPrompt);
1449
+
1450
+ // Add warnings from message conversion
1451
+ if (messageWarnings) {
1452
+ messageWarnings.forEach((warning) => {
1453
+ warnings.push({
1454
+ type: 'other',
1455
+ message: warning,
1456
+ });
1457
+ });
1458
+ }
1459
+
1460
+ const modeSetting = this.settings.streamingInput ?? 'auto';
1461
+ const effectiveCanUseTool = sdkOptions?.canUseTool ?? this.settings.canUseTool;
1462
+ const effectivePermissionPromptToolName =
1463
+ sdkOptions?.permissionPromptToolName ?? this.settings.permissionPromptToolName;
1464
+ const wantsStreamInput =
1465
+ modeSetting === 'always' || (modeSetting === 'auto' && !!effectiveCanUseTool);
1466
+
1467
+ if (!wantsStreamInput && hasImageParts) {
1468
+ warnings.push({
1469
+ type: 'other',
1470
+ message: STREAMING_FEATURE_WARNING,
1471
+ });
1472
+ }
1473
+
1474
+ const stream = new ReadableStream<ExtendedStreamPart>({
1475
+ start: async (controller) => {
1476
+ let done = () => {};
1477
+ const outputStreamEnded = new Promise((resolve) => {
1478
+ done = () => resolve(undefined);
1479
+ });
1480
+ const toolStates = new Map<string, ToolStreamState>();
1481
+ // Track active Task tools for subagent hierarchy
1482
+ // Using a Map instead of stack to correctly handle parallel agents
1483
+ const activeTaskTools = new Map<string, { startTime: number }>();
1484
+
1485
+ // Helper to get fallback parent - only returns a parent when exactly ONE Task is active
1486
+ // This prevents incorrect grouping when parallel agents run simultaneously
1487
+ const getFallbackParentId = (): string | null => {
1488
+ if (activeTaskTools.size === 1) {
1489
+ return activeTaskTools.keys().next().value ?? null;
1490
+ }
1491
+ return null;
1492
+ };
1493
+
1494
+ const streamWarnings: SharedV3Warning[] = [];
1495
+
1496
+ const closeToolInput = (toolId: string, state: ToolStreamState) => {
1497
+ if (!state.inputClosed && state.inputStarted) {
1498
+ controller.enqueue({
1499
+ type: 'tool-input-end',
1500
+ id: toolId,
1501
+ });
1502
+ state.inputClosed = true;
1503
+ }
1504
+ };
1505
+
1506
+ const emitToolCall = (toolId: string, state: ToolStreamState) => {
1507
+ if (state.callEmitted) {
1508
+ return;
1509
+ }
1510
+
1511
+ closeToolInput(toolId, state);
1512
+
1513
+ controller.enqueue({
1514
+ type: 'tool-call',
1515
+ toolCallId: toolId,
1516
+ toolName: state.name,
1517
+ input: state.lastSerializedInput ?? '',
1518
+ providerExecuted: true,
1519
+ dynamic: true, // V3 field: indicates tool is provider-defined (not in user's tools map)
1520
+ providerMetadata: {
1521
+ 'claude-code': {
1522
+ // rawInput preserves the original serialized format before AI SDK normalization.
1523
+ // Use this if you need the exact string sent to the Claude CLI, which may differ
1524
+ // from the `input` field after AI SDK processing.
1525
+ rawInput: state.lastSerializedInput ?? '',
1526
+ parentToolCallId: state.parentToolCallId ?? null,
1527
+ },
1528
+ },
1529
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
1530
+ state.callEmitted = true;
1531
+ };
1532
+
1533
+ const finalizeToolCalls = () => {
1534
+ for (const [toolId, state] of toolStates) {
1535
+ emitToolCall(toolId, state);
1536
+ }
1537
+ toolStates.clear();
1538
+ };
1539
+
1540
+ let usage: LanguageModelV3Usage = createEmptyUsage();
1541
+ let accumulatedText = '';
1542
+ let textPartId: string | undefined;
1543
+ let streamedTextLength = 0; // Track text already emitted via stream_events to avoid duplication
1544
+ let hasReceivedStreamEvents = false; // Track if we've received any stream_events
1545
+ let hasStreamedJson = false; // Track if JSON has been streamed via input_json_delta
1546
+
1547
+ // Content block streaming: Map block indices to tool IDs and accumulated JSON
1548
+ const toolBlocksByIndex = new Map<number, string>();
1549
+ const toolInputAccumulators = new Map<string, string>();
1550
+
1551
+ // Track text content blocks by index for correlating text_delta with text parts
1552
+ const textBlocksByIndex = new Map<number, string>();
1553
+
1554
+ // Track if text was streamed via content blocks to prevent double emission in result handler
1555
+ let textStreamedViaContentBlock = false;
1556
+
1557
+ // Extended thinking: Map block indices to reasoning part IDs
1558
+ const reasoningBlocksByIndex = new Map<number, string>();
1559
+ let currentReasoningPartId: string | undefined;
1560
+
1561
+ try {
1562
+ // Emit stream-start with warnings
1563
+ controller.enqueue({ type: 'stream-start', warnings });
1564
+
1565
+ if (effectiveCanUseTool && effectivePermissionPromptToolName) {
1566
+ throw new Error(
1567
+ "canUseTool requires streamingInput mode ('auto' or 'always') and cannot be used with permissionPromptToolName (SDK constraint). Set streamingInput: 'auto' (or 'always') and remove permissionPromptToolName, or remove canUseTool."
1568
+ );
1569
+ }
1570
+ // hold input stream open until results
1571
+ // see: https://github.com/anthropics/claude-code/issues/4775
1572
+ const sdkPrompt = wantsStreamInput
1573
+ ? toAsyncIterablePrompt(
1574
+ messagesPrompt,
1575
+ outputStreamEnded,
1576
+ effectiveResume,
1577
+ streamingContentParts,
1578
+ this.settings.onStreamStart
1579
+ )
1580
+ : messagesPrompt;
1581
+
1582
+ this.logger.debug(
1583
+ `[claude-code] Starting stream query with streamingInput: ${wantsStreamInput}, session: ${effectiveResume ?? 'new'}`
1584
+ );
1585
+
1586
+ const response = query({
1587
+ prompt: sdkPrompt,
1588
+ options: queryOptions,
1589
+ });
1590
+
1591
+ // Invoke onQueryCreated callback to expose Query object for advanced features
1592
+ // like mid-stream message injection via query.streamInput()
1593
+ this.settings.onQueryCreated?.(response);
1594
+
1595
+ for await (const message of response) {
1596
+ this.logger.debug(`[claude-code] Stream received message type: ${message.type}`);
1597
+
1598
+ // Handle streaming events (token-by-token delivery via includePartialMessages)
1599
+ if (message.type === 'stream_event') {
1600
+ const streamEvent = message as SDKPartialAssistantMessage;
1601
+ const event = streamEvent.event;
1602
+
1603
+ // Check for text_delta events within content_block_delta
1604
+ if (
1605
+ event.type === 'content_block_delta' &&
1606
+ event.delta.type === 'text_delta' &&
1607
+ 'text' in event.delta &&
1608
+ event.delta.text
1609
+ ) {
1610
+ const deltaText = event.delta.text;
1611
+ hasReceivedStreamEvents = true;
1612
+
1613
+ // Don't emit text deltas in JSON mode - accumulate instead
1614
+ if (options.responseFormat?.type === 'json') {
1615
+ accumulatedText += deltaText;
1616
+ streamedTextLength += deltaText.length;
1617
+ continue;
1618
+ }
1619
+
1620
+ // Emit text-start if this is the first text
1621
+ if (!textPartId) {
1622
+ textPartId = generateId();
1623
+ controller.enqueue({
1624
+ type: 'text-start',
1625
+ id: textPartId,
1626
+ });
1627
+ }
1628
+
1629
+ controller.enqueue({
1630
+ type: 'text-delta',
1631
+ id: textPartId,
1632
+ delta: deltaText,
1633
+ });
1634
+ accumulatedText += deltaText;
1635
+ streamedTextLength += deltaText.length;
1636
+ }
1637
+ // Handle input_json_delta events for structured output streaming
1638
+ // The SDK uses a StructuredOutput tool internally, and JSON is streamed via input_json_delta
1639
+ if (
1640
+ event.type === 'content_block_delta' &&
1641
+ event.delta.type === 'input_json_delta' &&
1642
+ 'partial_json' in event.delta &&
1643
+ event.delta.partial_json
1644
+ ) {
1645
+ const jsonDelta = event.delta.partial_json;
1646
+ hasReceivedStreamEvents = true;
1647
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1648
+
1649
+ // In JSON mode, prioritize streaming to text-delta for streamObject() support
1650
+ // The SDK's internal StructuredOutput tool uses input_json_delta to stream JSON responses
1651
+ if (options.responseFormat?.type === 'json') {
1652
+ // Emit text-start if this is the first JSON delta
1653
+ if (!textPartId) {
1654
+ textPartId = generateId();
1655
+ controller.enqueue({
1656
+ type: 'text-start',
1657
+ id: textPartId,
1658
+ });
1659
+ }
1660
+
1661
+ controller.enqueue({
1662
+ type: 'text-delta',
1663
+ id: textPartId,
1664
+ delta: jsonDelta,
1665
+ });
1666
+ accumulatedText += jsonDelta;
1667
+ streamedTextLength += jsonDelta.length;
1668
+ hasStreamedJson = true;
1669
+ continue;
1670
+ }
1671
+
1672
+ // In non-JSON mode, route to tool-input-delta if we have a tracked tool
1673
+ const toolId = toolBlocksByIndex.get(blockIndex);
1674
+ if (toolId) {
1675
+ // Accumulate and emit tool-input-delta
1676
+ const accumulated = (toolInputAccumulators.get(toolId) ?? '') + jsonDelta;
1677
+ toolInputAccumulators.set(toolId, accumulated);
1678
+
1679
+ controller.enqueue({
1680
+ type: 'tool-input-delta',
1681
+ id: toolId,
1682
+ delta: jsonDelta,
1683
+ });
1684
+ continue;
1685
+ }
1686
+ // input_json_delta without tool context in non-JSON mode is ignored
1687
+ }
1688
+
1689
+ // Handle content_block_start for tool_use - emit tool-input-start immediately
1690
+ if (
1691
+ event.type === 'content_block_start' &&
1692
+ 'content_block' in event &&
1693
+ event.content_block?.type === 'tool_use'
1694
+ ) {
1695
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1696
+ const toolBlock = event.content_block as {
1697
+ type: string;
1698
+ id?: string;
1699
+ name?: string;
1700
+ };
1701
+ const toolId =
1702
+ typeof toolBlock.id === 'string' && toolBlock.id.length > 0
1703
+ ? toolBlock.id
1704
+ : generateId();
1705
+ const toolName =
1706
+ typeof toolBlock.name === 'string' && toolBlock.name.length > 0
1707
+ ? toolBlock.name
1708
+ : ClaudeCodeLanguageModel.UNKNOWN_TOOL_NAME;
1709
+
1710
+ hasReceivedStreamEvents = true;
1711
+
1712
+ // Close any active text part before tool starts
1713
+ if (textPartId) {
1714
+ controller.enqueue({
1715
+ type: 'text-end',
1716
+ id: textPartId,
1717
+ });
1718
+ textPartId = undefined;
1719
+ }
1720
+
1721
+ // Track this block for later delta/stop events
1722
+ toolBlocksByIndex.set(blockIndex, toolId);
1723
+ toolInputAccumulators.set(toolId, '');
1724
+
1725
+ // Create tool state if not exists
1726
+ let state = toolStates.get(toolId);
1727
+ if (!state) {
1728
+ // Use timing-based inference for parent (Task tools are top-level)
1729
+ const currentParentId = toolName === 'Task' ? null : getFallbackParentId();
1730
+ state = {
1731
+ name: toolName,
1732
+ inputStarted: false,
1733
+ inputClosed: false,
1734
+ callEmitted: false,
1735
+ parentToolCallId: currentParentId,
1736
+ };
1737
+ toolStates.set(toolId, state);
1738
+ }
1739
+
1740
+ // Emit tool-input-start immediately with providerMetadata for parent context
1741
+ if (!state.inputStarted) {
1742
+ this.logger.debug(
1743
+ `[claude-code] Tool input started (content_block) - Tool: ${toolName}, ID: ${toolId}, parent: ${state.parentToolCallId}`
1744
+ );
1745
+ controller.enqueue({
1746
+ type: 'tool-input-start',
1747
+ id: toolId,
1748
+ toolName,
1749
+ providerExecuted: true,
1750
+ dynamic: true,
1751
+ providerMetadata: {
1752
+ 'claude-code': {
1753
+ parentToolCallId: state.parentToolCallId ?? null,
1754
+ },
1755
+ },
1756
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
1757
+
1758
+ // Track Task tools as active so nested tools can reference them as parent
1759
+ if (toolName === 'Task') {
1760
+ activeTaskTools.set(toolId, { startTime: Date.now() });
1761
+ }
1762
+ state.inputStarted = true;
1763
+ }
1764
+ continue;
1765
+ }
1766
+
1767
+ // Handle content_block_start for text - emit text-start early
1768
+ if (
1769
+ event.type === 'content_block_start' &&
1770
+ 'content_block' in event &&
1771
+ event.content_block?.type === 'text'
1772
+ ) {
1773
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1774
+ hasReceivedStreamEvents = true;
1775
+
1776
+ // Generate text part ID early and map to block index
1777
+ const partId = generateId();
1778
+ textBlocksByIndex.set(blockIndex, partId);
1779
+ textPartId = partId;
1780
+
1781
+ this.logger.debug(
1782
+ `[claude-code] Text content block started - Index: ${blockIndex}, ID: ${partId}`
1783
+ );
1784
+
1785
+ controller.enqueue({
1786
+ type: 'text-start',
1787
+ id: partId,
1788
+ });
1789
+ textStreamedViaContentBlock = true;
1790
+ continue;
1791
+ }
1792
+
1793
+ // Handle content_block_start for thinking - emit reasoning-start immediately
1794
+ if (
1795
+ event.type === 'content_block_start' &&
1796
+ 'content_block' in event &&
1797
+ event.content_block?.type === 'thinking'
1798
+ ) {
1799
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1800
+ hasReceivedStreamEvents = true;
1801
+
1802
+ // Close any active text part before reasoning starts
1803
+ if (textPartId) {
1804
+ controller.enqueue({
1805
+ type: 'text-end',
1806
+ id: textPartId,
1807
+ });
1808
+ textPartId = undefined;
1809
+ }
1810
+
1811
+ const reasoningPartId = generateId();
1812
+ reasoningBlocksByIndex.set(blockIndex, reasoningPartId);
1813
+ currentReasoningPartId = reasoningPartId;
1814
+
1815
+ this.logger.debug(
1816
+ `[claude-code] Reasoning started (content_block) - ID: ${reasoningPartId}`
1817
+ );
1818
+ controller.enqueue({
1819
+ type: 'reasoning-start',
1820
+ id: reasoningPartId,
1821
+ });
1822
+ continue;
1823
+ }
1824
+
1825
+ // Handle thinking_delta for extended thinking
1826
+ if (
1827
+ event.type === 'content_block_delta' &&
1828
+ event.delta.type === 'thinking_delta' &&
1829
+ 'thinking' in event.delta &&
1830
+ event.delta.thinking
1831
+ ) {
1832
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1833
+ const reasoningPartId =
1834
+ reasoningBlocksByIndex.get(blockIndex) ?? currentReasoningPartId;
1835
+ hasReceivedStreamEvents = true;
1836
+
1837
+ if (reasoningPartId) {
1838
+ controller.enqueue({
1839
+ type: 'reasoning-delta',
1840
+ id: reasoningPartId,
1841
+ delta: event.delta.thinking,
1842
+ });
1843
+ }
1844
+ continue;
1845
+ }
1846
+
1847
+ // Handle content_block_stop - finalize tool input, text, or reasoning
1848
+ if (event.type === 'content_block_stop') {
1849
+ const blockIndex = 'index' in event ? (event.index as number) : -1;
1850
+ hasReceivedStreamEvents = true;
1851
+
1852
+ // Check if this is a tool block
1853
+ const toolId = toolBlocksByIndex.get(blockIndex);
1854
+ if (toolId) {
1855
+ const state = toolStates.get(toolId);
1856
+ if (state && !state.inputClosed) {
1857
+ const accumulatedInput = toolInputAccumulators.get(toolId) ?? '';
1858
+ this.logger.debug(
1859
+ `[claude-code] Tool content block stopped - Index: ${blockIndex}, Tool: ${state.name}, ID: ${toolId}`
1860
+ );
1861
+ controller.enqueue({
1862
+ type: 'tool-input-end',
1863
+ id: toolId,
1864
+ });
1865
+ state.inputClosed = true;
1866
+ const effectiveInput = accumulatedInput || state.lastSerializedInput || '';
1867
+ state.lastSerializedInput = effectiveInput;
1868
+
1869
+ // Emit tool-call immediately when input is complete (don't wait for result)
1870
+ // This allows UI to show "running" state while tool executes
1871
+ if (!state.callEmitted) {
1872
+ controller.enqueue({
1873
+ type: 'tool-call',
1874
+ toolCallId: toolId,
1875
+ toolName: state.name,
1876
+ input: effectiveInput,
1877
+ providerExecuted: true,
1878
+ dynamic: true,
1879
+ providerMetadata: {
1880
+ 'claude-code': {
1881
+ rawInput: effectiveInput,
1882
+ parentToolCallId: state.parentToolCallId ?? null,
1883
+ },
1884
+ },
1885
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
1886
+ state.callEmitted = true;
1887
+ }
1888
+ }
1889
+ toolBlocksByIndex.delete(blockIndex);
1890
+ toolInputAccumulators.delete(toolId);
1891
+ continue;
1892
+ }
1893
+
1894
+ // Check if this is a text block
1895
+ const textId = textBlocksByIndex.get(blockIndex);
1896
+ if (textId) {
1897
+ this.logger.debug(
1898
+ `[claude-code] Text content block stopped - Index: ${blockIndex}, ID: ${textId}`
1899
+ );
1900
+ controller.enqueue({
1901
+ type: 'text-end',
1902
+ id: textId,
1903
+ });
1904
+ textBlocksByIndex.delete(blockIndex);
1905
+ if (textPartId === textId) {
1906
+ textPartId = undefined;
1907
+ }
1908
+ continue;
1909
+ }
1910
+
1911
+ // Check if this is a reasoning block
1912
+ const reasoningPartId = reasoningBlocksByIndex.get(blockIndex);
1913
+ if (reasoningPartId) {
1914
+ this.logger.debug(
1915
+ `[claude-code] Reasoning ended (content_block) - ID: ${reasoningPartId}`
1916
+ );
1917
+ controller.enqueue({
1918
+ type: 'reasoning-end',
1919
+ id: reasoningPartId,
1920
+ });
1921
+ reasoningBlocksByIndex.delete(blockIndex);
1922
+ if (currentReasoningPartId === reasoningPartId) {
1923
+ currentReasoningPartId = undefined;
1924
+ }
1925
+ continue;
1926
+ }
1927
+ }
1928
+
1929
+ // Other stream_event types are informational
1930
+ continue;
1931
+ }
1932
+
1933
+ if (message.type === 'assistant') {
1934
+ if (!message.message?.content) {
1935
+ this.logger.warn(
1936
+ `[claude-code] Unexpected assistant message structure: missing content field. Message type: ${message.type}. This may indicate an SDK protocol violation.`
1937
+ );
1938
+ continue;
1939
+ }
1940
+
1941
+ // Extract parent_tool_use_id from SDK message - this is the authoritative source
1942
+ // SDK provides this field when tool is executed within a subagent context
1943
+ const sdkParentToolUseId = (message as { parent_tool_use_id?: string })
1944
+ .parent_tool_use_id;
1945
+
1946
+ const content = message.message.content;
1947
+ const tools = this.extractToolUses(content);
1948
+
1949
+ // Close any active text part before tool calls start.
1950
+ // This ensures tool calls split text into separate parts.
1951
+ // We only do this if there are actual tools to avoid unnecessary text-end events.
1952
+ if (textPartId && tools.length > 0) {
1953
+ controller.enqueue({
1954
+ type: 'text-end',
1955
+ id: textPartId,
1956
+ });
1957
+ textPartId = undefined; // Reset so next text gets a new ID
1958
+ }
1959
+
1960
+ for (const tool of tools) {
1961
+ const toolId = tool.id;
1962
+ let state = toolStates.get(toolId);
1963
+ if (!state) {
1964
+ // Prefer SDK message-level parent (works for parallel agents)
1965
+ // Fall back to content-level parent, then timing-based inference
1966
+ // Task tools never have a parent (they're top-level)
1967
+ const currentParentId =
1968
+ tool.name === 'Task'
1969
+ ? null
1970
+ : (sdkParentToolUseId ?? tool.parentToolUseId ?? getFallbackParentId());
1971
+ state = {
1972
+ name: tool.name,
1973
+ inputStarted: false,
1974
+ inputClosed: false,
1975
+ callEmitted: false,
1976
+ parentToolCallId: currentParentId,
1977
+ };
1978
+ toolStates.set(toolId, state);
1979
+ this.logger.debug(
1980
+ `[claude-code] New tool use detected - Tool: ${tool.name}, ID: ${toolId}, SDK parent: ${sdkParentToolUseId}, resolved parent: ${currentParentId}`
1981
+ );
1982
+ } else if (!state.parentToolCallId && sdkParentToolUseId && tool.name !== 'Task') {
1983
+ // RETROACTIVE PARENT CONTEXT: Tool state was created by streaming events
1984
+ // but we now have authoritative parent from SDK message - update state
1985
+ state.parentToolCallId = sdkParentToolUseId;
1986
+ this.logger.debug(
1987
+ `[claude-code] Retroactive parent context - Tool: ${tool.name}, ID: ${toolId}, parent: ${sdkParentToolUseId}`
1988
+ );
1989
+ }
1990
+
1991
+ state.name = tool.name;
1992
+
1993
+ if (!state.inputStarted) {
1994
+ this.logger.debug(
1995
+ `[claude-code] Tool input started - Tool: ${tool.name}, ID: ${toolId}`
1996
+ );
1997
+ controller.enqueue({
1998
+ type: 'tool-input-start',
1999
+ id: toolId,
2000
+ toolName: tool.name,
2001
+ providerExecuted: true,
2002
+ dynamic: true, // V3 field: indicates tool is provider-defined
2003
+ providerMetadata: {
2004
+ 'claude-code': {
2005
+ parentToolCallId: state.parentToolCallId ?? null,
2006
+ },
2007
+ },
2008
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
2009
+ // Track Task tools as active so nested tools can reference them as parent
2010
+ if (tool.name === 'Task') {
2011
+ activeTaskTools.set(toolId, { startTime: Date.now() });
2012
+ }
2013
+ state.inputStarted = true;
2014
+ }
2015
+
2016
+ const serializedInput = this.serializeToolInput(tool.input);
2017
+ if (serializedInput) {
2018
+ let deltaPayload = '';
2019
+
2020
+ // First input: emit full delta only if small enough
2021
+ if (state.lastSerializedInput === undefined) {
2022
+ if (serializedInput.length <= ClaudeCodeLanguageModel.MAX_DELTA_CALC_SIZE) {
2023
+ deltaPayload = serializedInput;
2024
+ }
2025
+ } else if (
2026
+ serializedInput.length <= ClaudeCodeLanguageModel.MAX_DELTA_CALC_SIZE &&
2027
+ state.lastSerializedInput.length <=
2028
+ ClaudeCodeLanguageModel.MAX_DELTA_CALC_SIZE &&
2029
+ serializedInput.startsWith(state.lastSerializedInput)
2030
+ ) {
2031
+ deltaPayload = serializedInput.slice(state.lastSerializedInput.length);
2032
+ } else if (serializedInput !== state.lastSerializedInput) {
2033
+ // Non-prefix updates or large inputs - defer to the final tool-call payload
2034
+ deltaPayload = '';
2035
+ }
2036
+
2037
+ if (deltaPayload) {
2038
+ controller.enqueue({
2039
+ type: 'tool-input-delta',
2040
+ id: toolId,
2041
+ delta: deltaPayload,
2042
+ });
2043
+ }
2044
+ state.lastSerializedInput = serializedInput;
2045
+ }
2046
+ }
2047
+
2048
+ const text = content
2049
+ .map((c: { type: string; text?: string }) => (c.type === 'text' ? c.text : ''))
2050
+ .join('');
2051
+
2052
+ if (text) {
2053
+ // When we've received stream_events, assistant messages contain cumulative text
2054
+ // that we've already emitted via stream_event deltas - skip duplicates
2055
+ // When no stream_events received, assistant messages contain incremental text
2056
+ if (hasReceivedStreamEvents) {
2057
+ // Calculate delta: only emit text that wasn't already streamed via stream_events
2058
+ const newTextStart = streamedTextLength;
2059
+ const deltaText = text.length > newTextStart ? text.slice(newTextStart) : '';
2060
+
2061
+ // Always accumulate for final result tracking
2062
+ accumulatedText = text; // Replace with full text (assistant msg contains full content)
2063
+
2064
+ // In JSON mode, we accumulate the text and extract JSON at the end
2065
+ // Otherwise, stream any new text
2066
+ if (options.responseFormat?.type !== 'json' && deltaText) {
2067
+ // Emit text-start if this is the first text
2068
+ if (!textPartId) {
2069
+ textPartId = generateId();
2070
+ controller.enqueue({
2071
+ type: 'text-start',
2072
+ id: textPartId,
2073
+ });
2074
+ }
2075
+
2076
+ controller.enqueue({
2077
+ type: 'text-delta',
2078
+ id: textPartId,
2079
+ delta: deltaText,
2080
+ });
2081
+ }
2082
+
2083
+ // Update streamedTextLength to match what we now know is the full text
2084
+ streamedTextLength = text.length;
2085
+ } else {
2086
+ // No stream_events - assistant messages contain incremental text chunks
2087
+ accumulatedText += text;
2088
+
2089
+ // In JSON mode, we accumulate the text and extract JSON at the end
2090
+ // Otherwise, stream the text as it comes
2091
+ if (options.responseFormat?.type !== 'json') {
2092
+ // Emit text-start if this is the first text
2093
+ if (!textPartId) {
2094
+ textPartId = generateId();
2095
+ controller.enqueue({
2096
+ type: 'text-start',
2097
+ id: textPartId,
2098
+ });
2099
+ }
2100
+
2101
+ controller.enqueue({
2102
+ type: 'text-delta',
2103
+ id: textPartId,
2104
+ delta: text,
2105
+ });
2106
+ }
2107
+ }
2108
+ }
2109
+ } else if (message.type === 'user') {
2110
+ if (!message.message?.content) {
2111
+ this.logger.warn(
2112
+ `[claude-code] Unexpected user message structure: missing content field. Message type: ${message.type}. This may indicate an SDK protocol violation.`
2113
+ );
2114
+ continue;
2115
+ }
2116
+
2117
+ // A user message signals the end of the current assistant message.
2118
+ // Reset text state to ensure the next assistant message starts with a new text part.
2119
+ // This prevents text from different assistant messages from being merged together.
2120
+ if (textPartId) {
2121
+ const closedTextId = textPartId;
2122
+ controller.enqueue({
2123
+ type: 'text-end',
2124
+ id: closedTextId,
2125
+ });
2126
+ textPartId = undefined;
2127
+ // Prevent a later content_block_stop from closing the same text part twice.
2128
+ for (const [blockIndex, blockTextId] of textBlocksByIndex) {
2129
+ if (blockTextId === closedTextId) {
2130
+ textBlocksByIndex.delete(blockIndex);
2131
+ break;
2132
+ }
2133
+ }
2134
+ accumulatedText = '';
2135
+ streamedTextLength = 0;
2136
+ this.logger.debug('[claude-code] Closed text part due to user message');
2137
+ }
2138
+
2139
+ // Extract parent_tool_use_id from SDK message for late-arriving tool results
2140
+ const sdkParentToolUseIdForResults = (message as { parent_tool_use_id?: string })
2141
+ .parent_tool_use_id;
2142
+
2143
+ const content = message.message.content;
2144
+ for (const result of this.extractToolResults(content)) {
2145
+ let state = toolStates.get(result.id);
2146
+ const toolName =
2147
+ result.name ?? state?.name ?? ClaudeCodeLanguageModel.UNKNOWN_TOOL_NAME;
2148
+
2149
+ this.logger.debug(
2150
+ `[claude-code] Tool result received - Tool: ${toolName}, ID: ${result.id}`
2151
+ );
2152
+
2153
+ if (!state) {
2154
+ this.logger.warn(
2155
+ `[claude-code] Received tool result for unknown tool ID: ${result.id}`
2156
+ );
2157
+ // Use SDK parent if available, otherwise fall back to timing-based inference
2158
+ const resolvedParentId =
2159
+ toolName === 'Task'
2160
+ ? null
2161
+ : (sdkParentToolUseIdForResults ?? getFallbackParentId());
2162
+ state = {
2163
+ name: toolName,
2164
+ inputStarted: false,
2165
+ inputClosed: false,
2166
+ callEmitted: false,
2167
+ parentToolCallId: resolvedParentId,
2168
+ };
2169
+ toolStates.set(result.id, state);
2170
+ // Synthesize input lifecycle to preserve ordering when no prior tool_use was seen
2171
+ if (!state.inputStarted) {
2172
+ controller.enqueue({
2173
+ type: 'tool-input-start',
2174
+ id: result.id,
2175
+ toolName,
2176
+ providerExecuted: true,
2177
+ dynamic: true, // V3 field: indicates tool is provider-defined
2178
+ providerMetadata: {
2179
+ 'claude-code': {
2180
+ parentToolCallId: state.parentToolCallId ?? null,
2181
+ },
2182
+ },
2183
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
2184
+ state.inputStarted = true;
2185
+ }
2186
+ if (!state.inputClosed) {
2187
+ controller.enqueue({
2188
+ type: 'tool-input-end',
2189
+ id: result.id,
2190
+ });
2191
+ state.inputClosed = true;
2192
+ }
2193
+ }
2194
+ state.name = toolName;
2195
+ const normalizedResult = this.normalizeToolResult(result.result);
2196
+ const rawResult =
2197
+ typeof result.result === 'string'
2198
+ ? result.result
2199
+ : (() => {
2200
+ try {
2201
+ return JSON.stringify(result.result);
2202
+ } catch {
2203
+ return String(result.result);
2204
+ }
2205
+ })();
2206
+ const maxToolResultSize = this.settings.maxToolResultSize;
2207
+ const truncatedResult = truncateToolResultForStream(
2208
+ normalizedResult,
2209
+ maxToolResultSize
2210
+ );
2211
+ const truncatedRawResult = truncateToolResultForStream(
2212
+ rawResult,
2213
+ maxToolResultSize
2214
+ ) as string;
2215
+ const rawResultTruncated = truncatedRawResult !== rawResult;
2216
+
2217
+ emitToolCall(result.id, state);
2218
+
2219
+ // Remove Task tools from active set when they complete
2220
+ if (toolName === 'Task') {
2221
+ activeTaskTools.delete(result.id);
2222
+ }
2223
+
2224
+ controller.enqueue({
2225
+ type: 'tool-result',
2226
+ toolCallId: result.id,
2227
+ toolName,
2228
+ result: truncatedResult,
2229
+ isError: result.isError,
2230
+ providerExecuted: true,
2231
+ dynamic: true, // V3 field: indicates tool is provider-defined
2232
+ providerMetadata: {
2233
+ 'claude-code': {
2234
+ // rawResult preserves the original CLI output string before JSON parsing.
2235
+ // Use this when you need the exact string returned by the tool, especially
2236
+ // if the `result` field has been parsed/normalized and you need the original format.
2237
+ rawResult: truncatedRawResult,
2238
+ rawResultTruncated,
2239
+ parentToolCallId: state.parentToolCallId ?? null,
2240
+ },
2241
+ },
2242
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
2243
+ }
2244
+ // Handle tool errors
2245
+ for (const error of this.extractToolErrors(content)) {
2246
+ let state = toolStates.get(error.id);
2247
+ const toolName =
2248
+ error.name ?? state?.name ?? ClaudeCodeLanguageModel.UNKNOWN_TOOL_NAME;
2249
+
2250
+ this.logger.debug(
2251
+ `[claude-code] Tool error received - Tool: ${toolName}, ID: ${error.id}`
2252
+ );
2253
+
2254
+ if (!state) {
2255
+ this.logger.warn(
2256
+ `[claude-code] Received tool error for unknown tool ID: ${error.id}`
2257
+ );
2258
+ // Use SDK parent if available, otherwise fall back to timing-based inference
2259
+ const errorResolvedParentId =
2260
+ toolName === 'Task'
2261
+ ? null
2262
+ : (sdkParentToolUseIdForResults ?? getFallbackParentId());
2263
+ state = {
2264
+ name: toolName,
2265
+ inputStarted: true,
2266
+ inputClosed: true,
2267
+ callEmitted: false,
2268
+ parentToolCallId: errorResolvedParentId,
2269
+ };
2270
+ toolStates.set(error.id, state);
2271
+ }
2272
+
2273
+ // Ensure tool-call is emitted before tool-error
2274
+ emitToolCall(error.id, state);
2275
+
2276
+ // Remove Task tools from active set when they error
2277
+ if (toolName === 'Task') {
2278
+ activeTaskTools.delete(error.id);
2279
+ }
2280
+
2281
+ const rawError =
2282
+ typeof error.error === 'string'
2283
+ ? error.error
2284
+ : typeof error.error === 'object' && error.error !== null
2285
+ ? (() => {
2286
+ try {
2287
+ return JSON.stringify(error.error);
2288
+ } catch {
2289
+ return String(error.error);
2290
+ }
2291
+ })()
2292
+ : String(error.error);
2293
+
2294
+ controller.enqueue({
2295
+ type: 'tool-error',
2296
+ toolCallId: error.id,
2297
+ toolName,
2298
+ error: rawError,
2299
+ providerExecuted: true,
2300
+ dynamic: true, // V3 field: indicates tool is provider-defined
2301
+ providerMetadata: {
2302
+ 'claude-code': {
2303
+ rawError,
2304
+ parentToolCallId: state.parentToolCallId ?? null,
2305
+ },
2306
+ },
2307
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
2308
+ }
2309
+ } else if (message.type === 'result') {
2310
+ done();
2311
+
2312
+ // Handle is_error flag in result message (e.g., auth failures)
2313
+ // The CLI returns successful JSON with is_error: true and error message in result field
2314
+ if ('is_error' in message && message.is_error === true) {
2315
+ const errorMessage =
2316
+ 'result' in message && typeof message.result === 'string'
2317
+ ? message.result
2318
+ : 'Claude Code CLI returned an error';
2319
+ throw Object.assign(new Error(errorMessage), { exitCode: 1 });
2320
+ }
2321
+
2322
+ // Handle structured output errors (SDK 0.1.45+)
2323
+ // Use string comparison to support new SDK subtypes not yet in TypeScript definitions
2324
+ if ((message.subtype as string) === 'error_max_structured_output_retries') {
2325
+ throw new Error(
2326
+ 'Failed to generate valid structured output after maximum retries. The model could not produce a response matching the required schema.'
2327
+ );
2328
+ }
2329
+
2330
+ this.logger.info(
2331
+ `[claude-code] Stream completed - Session: ${message.session_id}, Cost: $${message.total_cost_usd?.toFixed(4) ?? 'N/A'}, Duration: ${message.duration_ms ?? 'N/A'}ms`
2332
+ );
2333
+
2334
+ if ('usage' in message) {
2335
+ usage = convertClaudeCodeUsage(message.usage);
2336
+
2337
+ this.logger.debug(
2338
+ `[claude-code] Stream token usage - Input: ${usage.inputTokens.total}, Output: ${usage.outputTokens.total}`
2339
+ );
2340
+ }
2341
+
2342
+ const stopReason =
2343
+ 'stop_reason' in message
2344
+ ? ((message as Record<string, unknown>).stop_reason as string | null | undefined)
2345
+ : undefined;
2346
+ const finishReason: LanguageModelV3FinishReason = mapClaudeCodeFinishReason(
2347
+ message.subtype,
2348
+ stopReason
2349
+ );
2350
+
2351
+ this.logger.debug(`[claude-code] Stream finish reason: ${finishReason.unified}`);
2352
+
2353
+ // Store session ID in the model instance
2354
+ this.setSessionId(message.session_id);
2355
+
2356
+ // Use structured output from SDK if available (native JSON schema support)
2357
+ const structuredOutput =
2358
+ 'structured_output' in message ? message.structured_output : undefined;
2359
+
2360
+ // Check if we've already streamed JSON via input_json_delta
2361
+ const alreadyStreamedJson =
2362
+ hasStreamedJson &&
2363
+ options.responseFormat?.type === 'json' &&
2364
+ hasReceivedStreamEvents;
2365
+
2366
+ if (alreadyStreamedJson) {
2367
+ // We've already streamed JSON deltas; only close the text part if it's still open.
2368
+ if (textPartId) {
2369
+ controller.enqueue({
2370
+ type: 'text-end',
2371
+ id: textPartId,
2372
+ });
2373
+ }
2374
+ } else if (structuredOutput !== undefined) {
2375
+ // Emit structured output as text (fallback when streaming didn't occur)
2376
+ const jsonTextId = generateId();
2377
+ const jsonText = JSON.stringify(structuredOutput);
2378
+ controller.enqueue({
2379
+ type: 'text-start',
2380
+ id: jsonTextId,
2381
+ });
2382
+ controller.enqueue({
2383
+ type: 'text-delta',
2384
+ id: jsonTextId,
2385
+ delta: jsonText,
2386
+ });
2387
+ controller.enqueue({
2388
+ type: 'text-end',
2389
+ id: jsonTextId,
2390
+ });
2391
+ } else if (textPartId) {
2392
+ // Close the text part if it was opened (non-JSON mode)
2393
+ controller.enqueue({
2394
+ type: 'text-end',
2395
+ id: textPartId,
2396
+ });
2397
+ } else if (accumulatedText && !textStreamedViaContentBlock) {
2398
+ // Fallback for JSON mode without schema: emit accumulated text
2399
+ // This handles the case where responseFormat.type === 'json' but no schema
2400
+ // was provided, so the SDK returns plain text instead of structured_output
2401
+ const fallbackTextId = generateId();
2402
+ controller.enqueue({
2403
+ type: 'text-start',
2404
+ id: fallbackTextId,
2405
+ });
2406
+ controller.enqueue({
2407
+ type: 'text-delta',
2408
+ id: fallbackTextId,
2409
+ delta: accumulatedText,
2410
+ });
2411
+ controller.enqueue({
2412
+ type: 'text-end',
2413
+ id: fallbackTextId,
2414
+ });
2415
+ }
2416
+
2417
+ finalizeToolCalls();
2418
+
2419
+ // Prepare JSON-safe warnings for provider metadata
2420
+ const warningsJson = this.serializeWarningsForMetadata(streamWarnings);
2421
+
2422
+ controller.enqueue({
2423
+ type: 'finish',
2424
+ finishReason,
2425
+ usage,
2426
+ providerMetadata: {
2427
+ 'claude-code': {
2428
+ sessionId: message.session_id,
2429
+ ...(message.total_cost_usd !== undefined && {
2430
+ costUsd: message.total_cost_usd,
2431
+ }),
2432
+ ...(message.duration_ms !== undefined && { durationMs: message.duration_ms }),
2433
+ ...(message.modelUsage !== undefined && {
2434
+ modelUsage: message.modelUsage as unknown as JSONValue,
2435
+ }),
2436
+ // JSON validation warnings are collected during streaming and included
2437
+ // in providerMetadata since the AI SDK's finish event doesn't support
2438
+ // a top-level warnings field (unlike stream-start which was already emitted)
2439
+ ...(streamWarnings.length > 0 && {
2440
+ warnings: warningsJson as unknown as JSONValue,
2441
+ }),
2442
+ },
2443
+ },
2444
+ });
2445
+ controller.close();
2446
+ return;
2447
+ } else if (message.type === 'system' && message.subtype === 'init') {
2448
+ // Store session ID for future use
2449
+ this.setSessionId(message.session_id);
2450
+
2451
+ this.logger.info(`[claude-code] Stream session initialized: ${message.session_id}`);
2452
+
2453
+ // Emit response metadata when session is initialized
2454
+ controller.enqueue({
2455
+ type: 'response-metadata',
2456
+ id: message.session_id,
2457
+ timestamp: new Date(),
2458
+ modelId: this.modelId,
2459
+ });
2460
+ }
2461
+ }
2462
+
2463
+ finalizeToolCalls();
2464
+ this.logger.debug('[claude-code] Stream finalized, closing stream');
2465
+ controller.close();
2466
+ } catch (error: unknown) {
2467
+ done();
2468
+
2469
+ this.logger.debug(
2470
+ `[claude-code] Error during doStream: ${error instanceof Error ? error.message : String(error)}`
2471
+ );
2472
+
2473
+ if (isClaudeCodeTruncationError(error, accumulatedText)) {
2474
+ this.logger.warn(
2475
+ `[claude-code] Detected truncated stream response, returning ${accumulatedText.length} characters of buffered text`
2476
+ );
2477
+ const truncationWarning: SharedV3Warning = {
2478
+ type: 'other',
2479
+ message: CLAUDE_CODE_TRUNCATION_WARNING,
2480
+ };
2481
+ streamWarnings.push(truncationWarning);
2482
+
2483
+ if (textPartId) {
2484
+ controller.enqueue({
2485
+ type: 'text-end',
2486
+ id: textPartId,
2487
+ });
2488
+ } else if (accumulatedText && !textStreamedViaContentBlock) {
2489
+ const fallbackTextId = generateId();
2490
+ controller.enqueue({
2491
+ type: 'text-start',
2492
+ id: fallbackTextId,
2493
+ });
2494
+ controller.enqueue({
2495
+ type: 'text-delta',
2496
+ id: fallbackTextId,
2497
+ delta: accumulatedText,
2498
+ });
2499
+ controller.enqueue({
2500
+ type: 'text-end',
2501
+ id: fallbackTextId,
2502
+ });
2503
+ }
2504
+
2505
+ finalizeToolCalls();
2506
+
2507
+ const warningsJson = this.serializeWarningsForMetadata(streamWarnings);
2508
+
2509
+ controller.enqueue({
2510
+ type: 'finish',
2511
+ finishReason: { unified: 'length', raw: 'truncation' },
2512
+ usage,
2513
+ providerMetadata: {
2514
+ 'claude-code': {
2515
+ ...(this.sessionId !== undefined && { sessionId: this.sessionId }),
2516
+ truncated: true,
2517
+ ...(streamWarnings.length > 0 && {
2518
+ warnings: warningsJson as unknown as JSONValue,
2519
+ }),
2520
+ },
2521
+ },
2522
+ });
2523
+
2524
+ controller.close();
2525
+ return;
2526
+ }
2527
+
2528
+ finalizeToolCalls();
2529
+ let errorToEmit: unknown;
2530
+
2531
+ // Special handling for AbortError to preserve abort signal reason
2532
+ if (isAbortError(error)) {
2533
+ errorToEmit = options.abortSignal?.aborted ? options.abortSignal.reason : error;
2534
+ } else {
2535
+ // Use unified error handler
2536
+ errorToEmit = this.handleClaudeCodeError(error, messagesPrompt, collectedStderr);
2537
+ }
2538
+
2539
+ // Emit error as a stream part
2540
+ controller.enqueue({
2541
+ type: 'error',
2542
+ error: errorToEmit,
2543
+ });
2544
+
2545
+ controller.close();
2546
+ } finally {
2547
+ if (options.abortSignal && abortListener) {
2548
+ options.abortSignal.removeEventListener('abort', abortListener);
2549
+ }
2550
+ }
2551
+ },
2552
+ cancel: () => {
2553
+ if (options.abortSignal && abortListener) {
2554
+ options.abortSignal.removeEventListener('abort', abortListener);
2555
+ }
2556
+ },
2557
+ });
2558
+
2559
+ return {
2560
+ stream: stream as unknown as ReadableStream<LanguageModelV3StreamPart>,
2561
+ request: {
2562
+ body: messagesPrompt,
2563
+ },
2564
+ };
2565
+ }
2566
+
2567
+ private serializeWarningsForMetadata(warnings: SharedV3Warning[]): JSONValue {
2568
+ const result = warnings.map((w) => {
2569
+ const base: Record<string, string> = { type: w.type };
2570
+ if ('message' in w) {
2571
+ const m = (w as { message?: unknown }).message;
2572
+ if (m !== undefined) base.message = String(m);
2573
+ }
2574
+ if (w.type === 'unsupported' || w.type === 'compatibility') {
2575
+ const feature = (w as { feature: unknown }).feature;
2576
+ if (feature !== undefined) base.feature = String(feature);
2577
+ if ('details' in w) {
2578
+ const d = (w as { details?: unknown }).details;
2579
+ if (d !== undefined) base.details = String(d);
2580
+ }
2581
+ }
2582
+ return base;
2583
+ });
2584
+ return result as unknown as JSONValue;
2585
+ }
2586
+ }