@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.
- package/README.md +90 -0
- package/dist/index.cjs +2522 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +848 -0
- package/dist/index.d.ts +848 -0
- package/dist/index.js +2484 -0
- package/dist/index.js.map +1 -0
- package/docs/content/README.md +337 -0
- package/docs/content/agent-teams.mdx +324 -0
- package/docs/content/api.mdx +757 -0
- package/docs/content/best-practices.mdx +624 -0
- package/docs/content/examples.mdx +675 -0
- package/docs/content/guide.mdx +516 -0
- package/docs/content/index.mdx +99 -0
- package/docs/content/installation.mdx +246 -0
- package/docs/content/skills.mdx +548 -0
- package/docs/content/troubleshooting.mdx +588 -0
- package/docs/examples/README.md +499 -0
- package/docs/examples/abort-signal.ts +125 -0
- package/docs/examples/agent-teams.ts +122 -0
- package/docs/examples/basic-usage.ts +73 -0
- package/docs/examples/check-cli.ts +51 -0
- package/docs/examples/conversation-history.ts +69 -0
- package/docs/examples/custom-config.ts +90 -0
- package/docs/examples/generate-object-constraints.ts +209 -0
- package/docs/examples/generate-object.ts +211 -0
- package/docs/examples/hooks-callbacks.ts +63 -0
- package/docs/examples/images.ts +76 -0
- package/docs/examples/integration-test.ts +241 -0
- package/docs/examples/limitations.ts +150 -0
- package/docs/examples/logging-custom-logger.ts +99 -0
- package/docs/examples/logging-default.ts +55 -0
- package/docs/examples/logging-disabled.ts +74 -0
- package/docs/examples/logging-verbose.ts +64 -0
- package/docs/examples/long-running-tasks.ts +179 -0
- package/docs/examples/message-injection.ts +210 -0
- package/docs/examples/mid-stream-injection.ts +126 -0
- package/docs/examples/run-all-examples.sh +48 -0
- package/docs/examples/sdk-tools-callbacks.ts +49 -0
- package/docs/examples/skills-discovery.ts +144 -0
- package/docs/examples/skills-management.ts +140 -0
- package/docs/examples/stream-object.ts +80 -0
- package/docs/examples/streaming.ts +52 -0
- package/docs/examples/structured-output-repro.ts +227 -0
- package/docs/examples/tool-management.ts +215 -0
- package/docs/examples/tool-streaming.ts +132 -0
- package/docs/examples/zod4-compatibility-test.ts +290 -0
- package/docs/src/claude-code-language-model.test.ts +3883 -0
- package/docs/src/claude-code-language-model.ts +2586 -0
- package/docs/src/claude-code-provider.test.ts +97 -0
- package/docs/src/claude-code-provider.ts +179 -0
- package/docs/src/convert-to-claude-code-messages.images.test.ts +104 -0
- package/docs/src/convert-to-claude-code-messages.test.ts +193 -0
- package/docs/src/convert-to-claude-code-messages.ts +419 -0
- package/docs/src/errors.test.ts +213 -0
- package/docs/src/errors.ts +216 -0
- package/docs/src/index.test.ts +49 -0
- package/docs/src/index.ts +98 -0
- package/docs/src/logger.integration.test.ts +164 -0
- package/docs/src/logger.test.ts +184 -0
- package/docs/src/logger.ts +65 -0
- package/docs/src/map-claude-code-finish-reason.test.ts +120 -0
- package/docs/src/map-claude-code-finish-reason.ts +60 -0
- package/docs/src/mcp-helpers.test.ts +71 -0
- package/docs/src/mcp-helpers.ts +123 -0
- package/docs/src/message-injection.test.ts +460 -0
- package/docs/src/types.ts +447 -0
- package/docs/src/validation.test.ts +558 -0
- package/docs/src/validation.ts +360 -0
- 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
|
+
}
|