@juspay/neurolink 9.69.1 → 9.69.2

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.
@@ -1,5 +1,5 @@
1
- import { createAnthropic } from "@ai-sdk/anthropic";
2
- import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { SpanKind, trace } from "@opentelemetry/api";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
@@ -12,14 +12,16 @@ import { createOAuthFetch } from "../proxy/oauthFetch.js";
12
12
  import { createProxyFetch } from "../proxy/proxyFetch.js";
13
13
  import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
14
14
  import { logger } from "../utils/logger.js";
15
+ import { redactUrlCredentials } from "../utils/logSanitize.js";
15
16
  import { calculateCost } from "../utils/pricing.js";
16
17
  import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
17
- import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
18
+ import { composeAbortSignals, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../utils/timeout.js";
18
19
  import { resolveToolChoice } from "../utils/toolChoice.js";
19
20
  import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
20
- import { getModelId } from "./providerTypeUtils.js";
21
- import { stepCountIs } from "../utils/tool.js";
22
- import { streamText } from "../utils/generation.js";
21
+ import { NoOutputGeneratedError } from "../utils/generationErrors.js";
22
+ import { buildNoOutputSentinel, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
23
+ import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
24
+ import { createChunkQueue, createDeferredAnalytics, stringifyToolInput, } from "./openaiChatCompletionsClient.js";
23
25
  /**
24
26
  * Beta headers for Claude Code integration.
25
27
  * These enable experimental features:
@@ -203,12 +205,304 @@ const parseRateLimitHeaders = (headers) => {
203
205
  retryAfter: parseNumber(getHeader("retry-after")),
204
206
  };
205
207
  };
208
+ // ───────────────────────────────────────────────────────────────────────────
209
+ // Native Messages-API conversion helpers (NeuroLink/V3 shapes → Anthropic)
210
+ // ───────────────────────────────────────────────────────────────────────────
211
+ /**
212
+ * Convert an image part (data URL, bare base64, https URL, or byte array)
213
+ * into an Anthropic image block. Returns undefined for unusable inputs.
214
+ */
215
+ const toAnthropicImageBlock = (data) => {
216
+ if (data instanceof Uint8Array) {
217
+ return {
218
+ type: "image",
219
+ source: {
220
+ type: "base64",
221
+ media_type: "image/png",
222
+ data: Buffer.from(data).toString("base64"),
223
+ },
224
+ };
225
+ }
226
+ if (typeof data !== "string" && !(data instanceof URL)) {
227
+ return undefined;
228
+ }
229
+ const str = data instanceof URL ? data.toString() : data;
230
+ const dataUrlMatch = str.match(/^data:(image\/[a-z+.-]+);base64,(.+)$/i);
231
+ if (dataUrlMatch) {
232
+ return {
233
+ type: "image",
234
+ source: {
235
+ type: "base64",
236
+ media_type: dataUrlMatch[1],
237
+ data: dataUrlMatch[2],
238
+ },
239
+ };
240
+ }
241
+ if (/^https?:\/\//i.test(str)) {
242
+ return { type: "image", source: { type: "url", url: str } };
243
+ }
244
+ // Bare base64 payload — assume PNG (matches the OpenAI-compat client).
245
+ return {
246
+ type: "image",
247
+ source: { type: "base64", media_type: "image/png", data: str },
248
+ };
249
+ };
250
+ /**
251
+ * Read an Anthropic cache breakpoint from a message/part/tool carrier.
252
+ * MessageBuilder marks system messages (and GenerationHandler marks the last
253
+ * tool definition) with `providerOptions.anthropic.cacheControl` — the
254
+ * AI-SDK-era prompt-caching contract this native path must keep honoring.
255
+ */
256
+ const cacheControlOf = (carrier) => {
257
+ const cc = carrier?.providerOptions?.anthropic?.cacheControl;
258
+ return cc?.type === "ephemeral" ? { type: "ephemeral" } : undefined;
259
+ };
260
+ /** Serialize a tool-result `output` into text for a tool_result block. */
261
+ const stringifyAnthropicToolOutput = (output) => {
262
+ if (output === null || output === undefined) {
263
+ return "";
264
+ }
265
+ if (typeof output === "string") {
266
+ return output;
267
+ }
268
+ const o = output;
269
+ if (o.type === "text" && typeof o.value === "string") {
270
+ return o.value;
271
+ }
272
+ if (o.type === "json" || o.type === "error-json") {
273
+ try {
274
+ return JSON.stringify(o.value);
275
+ }
276
+ catch {
277
+ return String(o.value);
278
+ }
279
+ }
280
+ try {
281
+ return JSON.stringify(output);
282
+ }
283
+ catch {
284
+ return String(output);
285
+ }
286
+ };
287
+ /**
288
+ * Convert NeuroLink/V3-shaped messages (the shape produced by
289
+ * buildMessagesForStream and by the AI-SDK prompt on the V3 doGenerate path)
290
+ * into the Anthropic Messages payload: a top-level `system` string plus
291
+ * alternating user/assistant messages with typed content blocks.
292
+ */
293
+ const messagesToAnthropic = (msgs) => {
294
+ const systemBlocks = [];
295
+ const messages = [];
296
+ const partsOf = (content) => Array.isArray(content) ? content : [content];
297
+ // Message-level cache breakpoints apply to the message's LAST content
298
+ // block (the AI-SDK convention MessageBuilder relies on).
299
+ const applyMessageCacheControl = (blocks, msg) => {
300
+ const cc = cacheControlOf(msg);
301
+ if (cc && blocks.length > 0) {
302
+ const last = blocks[blocks.length - 1];
303
+ last.cache_control = cc;
304
+ }
305
+ };
306
+ for (const msg of msgs) {
307
+ switch (msg.role) {
308
+ case "system": {
309
+ const text = typeof msg.content === "string"
310
+ ? msg.content
311
+ : partsOf(msg.content)
312
+ .map((p) => typeof p === "string"
313
+ ? p
314
+ : String(p?.text ?? ""))
315
+ .join("\n");
316
+ const cc = cacheControlOf(msg);
317
+ systemBlocks.push({
318
+ type: "text",
319
+ text,
320
+ ...(cc ? { cache_control: cc } : {}),
321
+ });
322
+ break;
323
+ }
324
+ case "user": {
325
+ const blocks = [];
326
+ for (const part of partsOf(msg.content)) {
327
+ if (typeof part === "string") {
328
+ if (part.length > 0) {
329
+ blocks.push({ type: "text", text: part });
330
+ }
331
+ continue;
332
+ }
333
+ const p = part;
334
+ if (p?.type === "text" && typeof p.text === "string") {
335
+ const cc = cacheControlOf(p);
336
+ blocks.push({
337
+ type: "text",
338
+ text: p.text,
339
+ ...(cc ? { cache_control: cc } : {}),
340
+ });
341
+ }
342
+ else if (p?.type === "image" || p?.type === "image_url") {
343
+ const img = toAnthropicImageBlock(p.image ?? p.data ?? p.url);
344
+ if (img) {
345
+ const cc = cacheControlOf(p);
346
+ blocks.push(cc ? { ...img, cache_control: cc } : img);
347
+ }
348
+ }
349
+ }
350
+ if (blocks.length > 0) {
351
+ applyMessageCacheControl(blocks, msg);
352
+ messages.push({ role: "user", content: blocks });
353
+ }
354
+ break;
355
+ }
356
+ case "assistant": {
357
+ const blocks = [];
358
+ for (const part of partsOf(msg.content)) {
359
+ if (typeof part === "string") {
360
+ if (part.length > 0) {
361
+ blocks.push({ type: "text", text: part });
362
+ }
363
+ continue;
364
+ }
365
+ const p = part;
366
+ if (p?.type === "text" && typeof p.text === "string") {
367
+ if (p.text.length > 0) {
368
+ const cc = cacheControlOf(p);
369
+ blocks.push({
370
+ type: "text",
371
+ text: p.text,
372
+ ...(cc ? { cache_control: cc } : {}),
373
+ });
374
+ }
375
+ }
376
+ else if (p?.type === "tool-call") {
377
+ let input = p.input;
378
+ if (typeof input === "string") {
379
+ try {
380
+ input = JSON.parse(input);
381
+ }
382
+ catch {
383
+ input = {};
384
+ }
385
+ }
386
+ blocks.push({
387
+ type: "tool_use",
388
+ id: p.toolCallId ?? "",
389
+ name: p.toolName ?? "",
390
+ input: input ?? {},
391
+ });
392
+ }
393
+ }
394
+ if (blocks.length > 0) {
395
+ applyMessageCacheControl(blocks, msg);
396
+ messages.push({ role: "assistant", content: blocks });
397
+ }
398
+ break;
399
+ }
400
+ case "tool": {
401
+ // Tool results are user-role messages with tool_result blocks.
402
+ const blocks = [];
403
+ if (Array.isArray(msg.content)) {
404
+ for (const part of msg.content) {
405
+ const p = part;
406
+ if (p?.type === "tool-result") {
407
+ blocks.push({
408
+ type: "tool_result",
409
+ tool_use_id: p.toolCallId ?? "",
410
+ content: stringifyAnthropicToolOutput(p.output),
411
+ });
412
+ }
413
+ }
414
+ }
415
+ else if (typeof msg.content === "string") {
416
+ blocks.push({
417
+ type: "tool_result",
418
+ tool_use_id: msg.toolCallId ?? "",
419
+ content: msg.content,
420
+ });
421
+ }
422
+ if (blocks.length > 0) {
423
+ messages.push({ role: "user", content: blocks });
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ }
429
+ // Plain-string system when no cache breakpoints are present (matches the
430
+ // previous wire shape); block form only when cache_control must ride along.
431
+ const system = systemBlocks.length === 0
432
+ ? undefined
433
+ : systemBlocks.some((b) => b.cache_control)
434
+ ? systemBlocks
435
+ : systemBlocks.map((b) => b.text).join("\n\n");
436
+ return {
437
+ ...(system !== undefined ? { system } : {}),
438
+ messages,
439
+ };
440
+ };
441
+ /** Convert a NeuroLink tool record into Anthropic tool definitions. */
442
+ const toolsToAnthropic = (tools) => {
443
+ const entries = Object.entries(tools);
444
+ if (entries.length === 0) {
445
+ return undefined;
446
+ }
447
+ return entries.map(([name, tool]) => {
448
+ const t = tool;
449
+ const rawSchema = t.inputSchema ?? t.parameters;
450
+ const input_schema = (rawSchema
451
+ ? convertZodToJsonSchema(rawSchema)
452
+ : { type: "object", properties: {} });
453
+ // GenerationHandler marks the last tool definition with a cache
454
+ // breakpoint when prompt caching is active — keep honoring it.
455
+ const cc = cacheControlOf(tool);
456
+ return {
457
+ name,
458
+ ...(t.description ? { description: t.description } : {}),
459
+ input_schema,
460
+ ...(cc ? { cache_control: cc } : {}),
461
+ };
462
+ });
463
+ };
464
+ /** Map a NeuroLink tool choice onto Anthropic's tool_choice shape. */
465
+ const toolChoiceToAnthropic = (choice) => {
466
+ if (!choice || choice === "auto") {
467
+ return undefined; // Anthropic defaults to auto when tools are present
468
+ }
469
+ if (choice === "none") {
470
+ return { type: "none" };
471
+ }
472
+ if (choice === "required") {
473
+ return { type: "any" };
474
+ }
475
+ if (typeof choice === "object") {
476
+ const c = choice;
477
+ if (c.type === "tool" && c.toolName) {
478
+ return { type: "tool", name: c.toolName };
479
+ }
480
+ }
481
+ return undefined;
482
+ };
483
+ /** Map Anthropic stop_reason onto the V3 unified finish reason. */
484
+ const mapAnthropicStopReason = (raw) => {
485
+ switch (raw) {
486
+ case "max_tokens":
487
+ return "length";
488
+ case "tool_use":
489
+ return "tool-calls";
490
+ case "refusal":
491
+ return "content-filter";
492
+ default:
493
+ return "stop";
494
+ }
495
+ };
496
+ // Anthropic's Messages API requires max_tokens on every request. The previous
497
+ // @ai-sdk/anthropic implementation defaulted it to 4096 when the caller did
498
+ // not specify maxTokens — preserve that wire behavior.
499
+ const ANTHROPIC_DEFAULT_MAX_TOKENS = 4096;
206
500
  /**
207
501
  * Anthropic Provider v2 - BaseProvider Implementation
208
502
  * Enhanced with OAuth support, subscription tiers, and beta headers for Claude Code integration.
209
503
  */
210
504
  export class AnthropicProvider extends BaseProvider {
211
- model;
505
+ client;
212
506
  authMethod;
213
507
  subscriptionTier;
214
508
  enableBetaFeatures;
@@ -273,8 +567,8 @@ export class AnthropicProvider extends BaseProvider {
273
567
  this.authMethod = authMethod;
274
568
  // Build headers based on auth method and subscription tier
275
569
  const headers = this.getAuthHeaders();
276
- // Create Anthropic instance based on auth method
277
- let anthropic;
570
+ // Create the official Anthropic SDK client based on auth method
571
+ let client;
278
572
  logger.debug("[AnthropicProvider] Constructor - checking OAuth:", {
279
573
  authMethod: this.authMethod,
280
574
  hasOAuthToken: !!this.oauthToken,
@@ -292,23 +586,24 @@ export class AnthropicProvider extends BaseProvider {
292
586
  // even after an automatic token refresh.
293
587
  // oauthToken is guaranteed non-null here (checked by the enclosing if-guard).
294
588
  const tokenRef = this.oauthToken;
295
- // skipBodyTransform=true: For the SDK provider path, body transforms ARE
296
- // intentionally skipped because the Vercel AI SDK builds its own request
297
- // format (system prompts, metadata, tool definitions). The billing header,
298
- // agent block, user_id injection, and mcp_ tool-name prefixing are only
299
- // needed for proxy passthrough of raw Claude API requests where we must
300
- // make the request look like it came from Claude Code / CLIProxyAPI.
589
+ // skipBodyTransform=true: For the SDK client path, body transforms ARE
590
+ // intentionally skipped because the official Anthropic SDK builds its
591
+ // own request format (system prompts, metadata, tool definitions). The
592
+ // billing header, agent block, user_id injection, and mcp_ tool-name
593
+ // prefixing are only needed for proxy passthrough of raw Claude API
594
+ // requests where we must make the request look like it came from
595
+ // Claude Code / CLIProxyAPI.
301
596
  const oauthFetch = createOAuthFetch(() => tokenRef.accessToken, this.enableBetaFeatures, false, // No mcp_ prefix — tool names pass through as-is (matches CLIProxyAPI)
302
597
  true);
303
598
  // For OAuth, we use a dummy API key since our fetch wrapper handles auth
304
599
  // IMPORTANT: Do NOT pass beta headers here - our fetch wrapper handles them
305
600
  // The claude-code-20250219 beta header triggers "credential only for Claude Code" error
306
- anthropic = createAnthropic({
601
+ client = new Anthropic({
307
602
  apiKey: "oauth-authenticated", // Placeholder, actual auth is in fetch wrapper
308
603
  // Note: No headers passed - fetch wrapper sets oauth-2025-04-20 beta header
309
604
  fetch: oauthFetch,
310
605
  });
311
- logger.debug("[AnthropicProvider] Anthropic SDK created with OAuth fetch wrapper");
606
+ logger.debug("[AnthropicProvider] Anthropic SDK client created with OAuth fetch wrapper");
312
607
  logger.debug("Anthropic Provider initialized with OAuth", {
313
608
  modelName: this.modelName,
314
609
  provider: this.providerName,
@@ -324,38 +619,32 @@ export class AnthropicProvider extends BaseProvider {
324
619
  else {
325
620
  // Traditional API key authentication
326
621
  const apiKeyToUse = credentials?.apiKey ?? config?.apiKey ?? getAnthropicApiKey();
327
- // The Vercel AI SDK builds `${baseURL}/messages` (no version prefix).
328
- // Anthropic's REST API lives under `/v1/messages`, so a user-supplied
329
- // bare host like `http://localhost:55669` 404s. Normalize: if the
330
- // baseURL doesn't already end with `/vN`, append `/v1`.
331
- //
332
- // CAVEAT: this heuristic assumes the proxy exposes Anthropic-compatible
333
- // endpoints rooted at `/vN/...`. Path-rooted proxies like
334
- // `https://proxy.example.com/anthropic` will be silently rewritten to
335
- // `…/anthropic/v1` and 404 if the proxy doesn't expose that path. To
336
- // disable auto-append, set `ANTHROPIC_BASE_URL` explicitly ending in a
337
- // version segment (e.g. `https://api.anthropic.com/v1`).
622
+ // The official Anthropic SDK builds `${baseURL}/v1/messages` itself, so
623
+ // a version-suffixed base URL the form the previous @ai-sdk/anthropic
624
+ // implementation REQUIRED (`https://api.anthropic.com/v1`) would
625
+ // double up as `/v1/v1/messages`. Normalize the inverse way now: strip
626
+ // a trailing `/vN` segment when present so both historical forms of
627
+ // ANTHROPIC_BASE_URL keep working.
338
628
  const normalizedBaseURL = (() => {
339
629
  const raw = process.env.ANTHROPIC_BASE_URL;
340
630
  if (!raw) {
341
631
  return undefined;
342
632
  }
343
633
  const trimmed = raw.replace(/\/+$/, "");
344
- if (/\/v\d+$/.test(trimmed)) {
345
- return trimmed;
634
+ const stripped = trimmed.replace(/\/v\d+$/, "");
635
+ if (stripped !== trimmed) {
636
+ logger.debug("[AnthropicProvider] Stripping the version suffix from " +
637
+ "ANTHROPIC_BASE_URL — the official Anthropic SDK appends /v1 " +
638
+ "to the base URL itself.", {
639
+ baseURL: redactUrlCredentials(raw),
640
+ rewrittenTo: redactUrlCredentials(stripped),
641
+ });
346
642
  }
347
- logger.warn("[AnthropicProvider] ANTHROPIC_BASE_URL does not end with /vN; " +
348
- "appending /v1 so the Vercel AI SDK can build /messages correctly. " +
349
- "To silence this warning, set ANTHROPIC_BASE_URL with an explicit " +
350
- "version segment (e.g. https://api.anthropic.com/v1). " +
351
- "If your proxy exposes Anthropic at a custom path that doesn't " +
352
- "expose /v1/messages, the auto-append will produce a 404 — pass " +
353
- "the full path including the version explicitly.", { baseURL: raw, rewrittenTo: `${trimmed}/v1` });
354
- return `${trimmed}/v1`;
643
+ return stripped;
355
644
  })();
356
- anthropic = createAnthropic({
645
+ client = new Anthropic({
357
646
  apiKey: apiKeyToUse,
358
- headers,
647
+ defaultHeaders: headers,
359
648
  ...(normalizedBaseURL && { baseURL: normalizedBaseURL }),
360
649
  fetch: createProxyFetch(),
361
650
  });
@@ -367,8 +656,7 @@ export class AnthropicProvider extends BaseProvider {
367
656
  enableBetaFeatures: this.enableBetaFeatures,
368
657
  });
369
658
  }
370
- // Initialize Anthropic model with configured instance
371
- this.model = anthropic(this.modelName || getDefaultAnthropicModel());
659
+ this.client = client;
372
660
  // Initialize usage tracking
373
661
  this.usageInfo = {
374
662
  messagesUsed: 0,
@@ -767,10 +1055,163 @@ export class AnthropicProvider extends BaseProvider {
767
1055
  return getDefaultAnthropicModel();
768
1056
  }
769
1057
  /**
770
- * Returns the Vercel AI SDK model instance for Anthropic
1058
+ * Returns a V3-shaped delegating model whose `doGenerate` drives the
1059
+ * official Anthropic Messages API directly. BaseProvider's `generate()`
1060
+ * path (and its middleware wrapping) keeps working unchanged; the
1061
+ * streaming path bypasses this entirely via `executeStream`.
771
1062
  */
772
1063
  getAISDKModel() {
773
- return this.model;
1064
+ const client = this.client;
1065
+ const providerName = this.providerName;
1066
+ const modelId = this.modelName || getDefaultAnthropicModel();
1067
+ const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
1068
+ const refreshAuth = () => this.refreshAuthIfNeeded();
1069
+ return {
1070
+ specificationVersion: "v3",
1071
+ provider: providerName,
1072
+ modelId,
1073
+ supportedUrls: {},
1074
+ doGenerate: async (options) => {
1075
+ await refreshAuth();
1076
+ const { system, messages } = messagesToAnthropic(options.prompt);
1077
+ let tools = (options.tools ?? [])
1078
+ .filter((t) => t.type === "function")
1079
+ .map((t) => {
1080
+ // GenerationHandler marks the last tool definition with a cache
1081
+ // breakpoint when prompt caching is active — keep honoring it.
1082
+ const cc = cacheControlOf(t);
1083
+ return {
1084
+ name: t.name,
1085
+ ...(t.description ? { description: t.description } : {}),
1086
+ input_schema: (t.inputSchema ?? {
1087
+ type: "object",
1088
+ properties: {},
1089
+ }),
1090
+ ...(cc ? { cache_control: cc } : {}),
1091
+ };
1092
+ });
1093
+ if (tools && tools.length === 0) {
1094
+ tools = undefined;
1095
+ }
1096
+ let toolChoice = options.toolChoice
1097
+ ? toolChoiceToAnthropic(options.toolChoice.type === "tool"
1098
+ ? { type: "tool", toolName: options.toolChoice.toolName }
1099
+ : options.toolChoice.type)
1100
+ : undefined;
1101
+ // JSON/structured output: Anthropic has no response_format — emulate
1102
+ // with a forced synthetic tool whose input IS the JSON payload (the
1103
+ // same object-tool strategy @ai-sdk/anthropic used).
1104
+ let jsonTool;
1105
+ if (options.responseFormat?.type === "json") {
1106
+ jsonTool = options.responseFormat.name ?? "json";
1107
+ const schema = (options.responseFormat.schema ?? {
1108
+ type: "object",
1109
+ });
1110
+ tools = [
1111
+ {
1112
+ name: jsonTool,
1113
+ description: options.responseFormat.description ??
1114
+ "Respond by calling this tool with the answer as its input.",
1115
+ input_schema: schema,
1116
+ },
1117
+ ];
1118
+ toolChoice = { type: "tool", name: jsonTool };
1119
+ }
1120
+ // Extended thinking passthrough (providerOptions.anthropic.thinking).
1121
+ const thinking = options.providerOptions?.anthropic?.thinking;
1122
+ const params = {
1123
+ model: modelId,
1124
+ messages,
1125
+ max_tokens: options.maxOutputTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS,
1126
+ ...(system ? { system } : {}),
1127
+ ...(options.temperature !== undefined && options.temperature !== null
1128
+ ? { temperature: options.temperature }
1129
+ : {}),
1130
+ ...(options.topP !== undefined && options.topP !== null
1131
+ ? { top_p: options.topP }
1132
+ : {}),
1133
+ ...(options.stopSequences && options.stopSequences.length > 0
1134
+ ? { stop_sequences: options.stopSequences }
1135
+ : {}),
1136
+ ...(tools ? { tools } : {}),
1137
+ ...(toolChoice ? { tool_choice: toolChoice } : {}),
1138
+ ...(thinking ? { thinking } : {}),
1139
+ };
1140
+ const timeoutController = createTimeoutController(getTimeoutForOptions(options), providerName, "generate");
1141
+ let response;
1142
+ try {
1143
+ response = await client.messages.create(params, {
1144
+ signal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
1145
+ });
1146
+ }
1147
+ finally {
1148
+ timeoutController?.cleanup();
1149
+ }
1150
+ const content = [];
1151
+ for (const block of response.content) {
1152
+ if (block.type === "thinking") {
1153
+ content.push({ type: "reasoning", text: block.thinking });
1154
+ }
1155
+ else if (block.type === "text") {
1156
+ // In forced-json mode the payload arrives via the tool input, not
1157
+ // text — pass text through only in normal mode.
1158
+ if (!jsonTool) {
1159
+ content.push({ type: "text", text: block.text });
1160
+ }
1161
+ }
1162
+ else if (block.type === "tool_use") {
1163
+ if (jsonTool && block.name === jsonTool) {
1164
+ // Unwrap the synthetic tool call back into text JSON.
1165
+ content.push({
1166
+ type: "text",
1167
+ text: stringifyToolInput(block.input),
1168
+ });
1169
+ }
1170
+ else {
1171
+ content.push({
1172
+ type: "tool-call",
1173
+ toolCallId: block.id,
1174
+ toolName: block.name,
1175
+ input: stringifyToolInput(block.input),
1176
+ });
1177
+ }
1178
+ }
1179
+ }
1180
+ const cacheRead = response.usage.cache_read_input_tokens ?? 0;
1181
+ const cacheWrite = response.usage.cache_creation_input_tokens ?? 0;
1182
+ return {
1183
+ content,
1184
+ finishReason: {
1185
+ unified: mapAnthropicStopReason(response.stop_reason),
1186
+ raw: response.stop_reason ?? "stop",
1187
+ },
1188
+ usage: {
1189
+ inputTokens: {
1190
+ total: response.usage.input_tokens + cacheRead + cacheWrite,
1191
+ noCache: response.usage.input_tokens,
1192
+ cacheRead,
1193
+ cacheWrite,
1194
+ },
1195
+ outputTokens: {
1196
+ total: response.usage.output_tokens,
1197
+ text: response.usage.output_tokens,
1198
+ reasoning: undefined,
1199
+ },
1200
+ },
1201
+ warnings: [],
1202
+ request: { body: params },
1203
+ response: {
1204
+ id: response.id,
1205
+ modelId: response.model,
1206
+ headers: {},
1207
+ body: response,
1208
+ },
1209
+ };
1210
+ },
1211
+ doStream: () => {
1212
+ throw new Error(`${providerName}: doStream is not implemented on the delegating model — the streaming path uses executeStream directly.`);
1213
+ },
1214
+ };
774
1215
  }
775
1216
  formatProviderError(error) {
776
1217
  if (error instanceof TimeoutError) {
@@ -820,134 +1261,374 @@ export class AnthropicProvider extends BaseProvider {
820
1261
  this.validateStreamOptions(options);
821
1262
  const timeout = this.getTimeout(options);
822
1263
  const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
1264
+ // Consumer-driven abort: fires when the async iterator is closed early
1265
+ // (caller breaks out of `for await`) so the background loop stops
1266
+ // reading SSE and running tools.
1267
+ const consumerAbortController = new AbortController();
1268
+ const abortSignal = mergeAbortSignals([
1269
+ options.abortSignal,
1270
+ timeoutController?.controller.signal,
1271
+ consumerAbortController.signal,
1272
+ ]).signal;
1273
+ let toolsRecord;
1274
+ let anthropicTools;
1275
+ let payload;
1276
+ let shouldUseTools;
823
1277
  try {
824
- // Get tools - options.tools is pre-merged by BaseProvider.stream() with
825
- // base tools (MCP/built-in) + user-provided tools (RAG, etc.)
826
- const shouldUseTools = !options.disableTools && this.supportsTools();
827
- const tools = shouldUseTools
1278
+ // options.tools is pre-merged by BaseProvider.stream() with base tools
1279
+ // (MCP/built-in) + user-provided tools (RAG, etc.)
1280
+ shouldUseTools = !options.disableTools && this.supportsTools();
1281
+ toolsRecord = shouldUseTools
828
1282
  ? options.tools || (await this.getAllTools())
829
1283
  : {};
830
- // Build message array from options with multimodal support
831
- // Using protected helper from BaseProvider to eliminate code duplication
832
- const messages = await this.buildMessagesForStream(options);
833
- const model = await this.getAISDKModelWithMiddleware(options);
834
- // Wrap streamText in an OTel span to capture provider-level latency and token usage
835
- const streamSpan = streamTracer.startSpan("neurolink.provider.streamText", {
836
- kind: SpanKind.CLIENT,
837
- attributes: {
838
- "gen_ai.system": "anthropic",
839
- "gen_ai.request.model": getModelId(model, this.modelName || "unknown"),
840
- },
1284
+ anthropicTools = shouldUseTools
1285
+ ? toolsToAnthropic(toolsRecord)
1286
+ : undefined;
1287
+ // Build message array from options with multimodal support, then
1288
+ // convert to the Anthropic Messages payload (system + content blocks).
1289
+ const built = await this.buildMessagesForStream(options);
1290
+ payload = messagesToAnthropic(built);
1291
+ }
1292
+ catch (setupErr) {
1293
+ timeoutController?.cleanup();
1294
+ throw this.handleProviderError(setupErr);
1295
+ }
1296
+ const modelId = this.modelName || getDefaultAnthropicModel();
1297
+ const anthropicToolChoice = shouldUseTools && anthropicTools && anthropicTools.length > 0
1298
+ ? toolChoiceToAnthropic(resolveToolChoice(options, toolsRecord, shouldUseTools))
1299
+ : undefined;
1300
+ // Extended thinking: enabled when the caller supplies an explicit token
1301
+ // budget (mirrors the previous experimental_thinking gating). Thinking
1302
+ // deltas stream out on the `reasoning` chunk channel.
1303
+ const thinking = options.thinkingConfig?.enabled && options.thinkingConfig.budgetTokens
1304
+ ? {
1305
+ type: "enabled",
1306
+ budget_tokens: options.thinkingConfig.budgetTokens,
1307
+ }
1308
+ : undefined;
1309
+ // Wrap the native stream in an OTel span to capture provider-level
1310
+ // latency and token usage (same span name as the pre-migration path so
1311
+ // dashboards stay continuous).
1312
+ const streamSpan = streamTracer.startSpan("neurolink.provider.streamText", {
1313
+ kind: SpanKind.CLIENT,
1314
+ attributes: {
1315
+ "gen_ai.system": "anthropic",
1316
+ "gen_ai.request.model": modelId,
1317
+ },
1318
+ });
1319
+ const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
1320
+ const emitter = this.neurolink?.getEventEmitter();
1321
+ const { pushChunk, nextChunk } = createChunkQueue();
1322
+ const { usagePromise, finishPromise, resolveUsage, resolveFinish } = createDeferredAnalytics();
1323
+ usagePromise
1324
+ .then((usage) => {
1325
+ streamSpan.setAttribute("gen_ai.usage.input_tokens", usage.promptTokens || 0);
1326
+ streamSpan.setAttribute("gen_ai.usage.output_tokens", usage.completionTokens || 0);
1327
+ const cost = calculateCost(this.providerName, this.modelName, {
1328
+ input: usage.promptTokens || 0,
1329
+ output: usage.completionTokens || 0,
1330
+ total: usage.totalTokens || 0,
841
1331
  });
842
- // Reviewer follow-up: capture upstream provider errors via onError
843
- // so the post-stream NoOutput sentinel carries the real cause in
844
- // providerError / modelResponseRaw.
845
- let capturedProviderError;
846
- let result;
847
- try {
848
- result = streamText({
849
- model: model,
850
- messages: messages,
851
- temperature: options.temperature,
852
- maxOutputTokens: options.maxTokens, // No default limit - unlimited unless specified
853
- maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
854
- tools,
855
- stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS),
856
- toolChoice: resolveToolChoice(options, tools, shouldUseTools),
857
- abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
858
- onError: (event) => {
859
- capturedProviderError = event.error;
860
- logger.error("Anthropic: Stream error", {
861
- error: event.error instanceof Error
862
- ? event.error.message
863
- : String(event.error),
864
- });
865
- },
866
- experimental_repairToolCall: this.getToolCallRepairFn(options),
867
- experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
868
- onStepFinish: ({ toolCalls, toolResults }) => {
869
- // Emit tool:end for each completed tool result so Pipeline B
870
- // captures telemetry for AI-SDK-driven tool calls (gap S2).
871
- emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults);
872
- this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
873
- logger.warn("[AnthropicProvider] Failed to store tool executions", {
874
- provider: this.providerName,
875
- error: error instanceof Error ? error.message : String(error),
1332
+ if (cost && cost > 0) {
1333
+ streamSpan.setAttribute("neurolink.cost", cost);
1334
+ }
1335
+ })
1336
+ .catch(() => {
1337
+ // usage may never resolve if the stream is aborted before completion
1338
+ });
1339
+ finishPromise
1340
+ .then((reason) => {
1341
+ streamSpan.setAttribute("gen_ai.response.finish_reason", reason || "unknown");
1342
+ streamSpan.end();
1343
+ })
1344
+ .catch(() => {
1345
+ streamSpan.end();
1346
+ });
1347
+ let capturedProviderError;
1348
+ const client = this.client;
1349
+ const toolsUsed = [];
1350
+ const runLoop = async () => {
1351
+ const conversation = payload.messages.slice();
1352
+ let totalInput = 0;
1353
+ let totalOutput = 0;
1354
+ let lastStop = null;
1355
+ for (let step = 0; step < maxSteps; step++) {
1356
+ const params = {
1357
+ model: modelId,
1358
+ messages: conversation,
1359
+ max_tokens: options.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS,
1360
+ stream: true,
1361
+ ...(payload.system ? { system: payload.system } : {}),
1362
+ ...(options.temperature !== undefined && options.temperature !== null
1363
+ ? { temperature: options.temperature }
1364
+ : {}),
1365
+ ...(anthropicTools && anthropicTools.length > 0
1366
+ ? { tools: anthropicTools }
1367
+ : {}),
1368
+ ...(anthropicToolChoice ? { tool_choice: anthropicToolChoice } : {}),
1369
+ ...(thinking ? { thinking } : {}),
1370
+ };
1371
+ const events = await client.messages.create(params, {
1372
+ signal: abortSignal ?? undefined,
1373
+ });
1374
+ // Per-step accumulators, keyed by content-block index so blocks are
1375
+ // replayed to the conversation in order (thinking blocks must be
1376
+ // passed back with their signatures when tool use continues a turn).
1377
+ const blockTypes = new Map();
1378
+ const textAcc = new Map();
1379
+ const thinkingAcc = new Map();
1380
+ const toolAcc = new Map();
1381
+ let stopReason = null;
1382
+ for await (const event of events) {
1383
+ if (event.type === "message_start") {
1384
+ totalInput += event.message.usage.input_tokens ?? 0;
1385
+ totalOutput += event.message.usage.output_tokens ?? 0;
1386
+ }
1387
+ else if (event.type === "content_block_start") {
1388
+ blockTypes.set(event.index, event.content_block.type);
1389
+ if (event.content_block.type === "tool_use") {
1390
+ toolAcc.set(event.index, {
1391
+ id: event.content_block.id,
1392
+ name: event.content_block.name,
1393
+ inputJson: "",
1394
+ });
1395
+ }
1396
+ }
1397
+ else if (event.type === "content_block_delta") {
1398
+ const delta = event.delta;
1399
+ if (delta.type === "text_delta") {
1400
+ textAcc.set(event.index, (textAcc.get(event.index) ?? "") + delta.text);
1401
+ pushChunk({ content: delta.text });
1402
+ }
1403
+ else if (delta.type === "thinking_delta") {
1404
+ const acc = thinkingAcc.get(event.index) ?? {
1405
+ text: "",
1406
+ signature: "",
1407
+ };
1408
+ acc.text += delta.thinking;
1409
+ thinkingAcc.set(event.index, acc);
1410
+ // Reasoning rides the dedicated chunk channel; `content` stays
1411
+ // an always-present string so plain-text consumers are safe.
1412
+ pushChunk({ content: "", reasoning: delta.thinking });
1413
+ }
1414
+ else if (delta.type === "signature_delta") {
1415
+ const acc = thinkingAcc.get(event.index) ?? {
1416
+ text: "",
1417
+ signature: "",
1418
+ };
1419
+ acc.signature += delta.signature;
1420
+ thinkingAcc.set(event.index, acc);
1421
+ }
1422
+ else if (delta.type === "input_json_delta") {
1423
+ const acc = toolAcc.get(event.index);
1424
+ if (acc) {
1425
+ acc.inputJson += delta.partial_json;
1426
+ }
1427
+ }
1428
+ }
1429
+ else if (event.type === "message_delta") {
1430
+ stopReason = event.delta.stop_reason ?? stopReason;
1431
+ totalOutput += event.usage?.output_tokens ?? 0;
1432
+ }
1433
+ }
1434
+ lastStop = stopReason;
1435
+ if (stopReason !== "tool_use" || toolAcc.size === 0) {
1436
+ break;
1437
+ }
1438
+ // Replay this assistant turn (thinking + text + tool_use blocks, in
1439
+ // block order) then execute the requested tools and append their
1440
+ // results as a user turn — the native multi-step tool loop.
1441
+ const assistantBlocks = [];
1442
+ const orderedIndexes = [...blockTypes.keys()].sort((a, b) => a - b);
1443
+ for (const idx of orderedIndexes) {
1444
+ const type = blockTypes.get(idx);
1445
+ if (type === "thinking") {
1446
+ const acc = thinkingAcc.get(idx);
1447
+ if (acc && acc.text.length > 0) {
1448
+ assistantBlocks.push({
1449
+ type: "thinking",
1450
+ thinking: acc.text,
1451
+ signature: acc.signature,
876
1452
  });
1453
+ }
1454
+ }
1455
+ else if (type === "text") {
1456
+ const text = textAcc.get(idx);
1457
+ if (text && text.length > 0) {
1458
+ assistantBlocks.push({ type: "text", text });
1459
+ }
1460
+ }
1461
+ else if (type === "tool_use") {
1462
+ const acc = toolAcc.get(idx);
1463
+ if (acc) {
1464
+ let input;
1465
+ try {
1466
+ input = acc.inputJson ? JSON.parse(acc.inputJson) : {};
1467
+ }
1468
+ catch {
1469
+ input = {};
1470
+ }
1471
+ assistantBlocks.push({
1472
+ type: "tool_use",
1473
+ id: acc.id,
1474
+ name: acc.name,
1475
+ input,
1476
+ });
1477
+ }
1478
+ }
1479
+ }
1480
+ conversation.push({ role: "assistant", content: assistantBlocks });
1481
+ const resultBlocks = [];
1482
+ const toolCallsForStorage = [];
1483
+ const toolResultsForStorage = [];
1484
+ for (const acc of toolAcc.values()) {
1485
+ let args;
1486
+ try {
1487
+ args = acc.inputJson
1488
+ ? JSON.parse(acc.inputJson)
1489
+ : {};
1490
+ }
1491
+ catch {
1492
+ args = {};
1493
+ }
1494
+ toolCallsForStorage.push({
1495
+ type: "tool-call",
1496
+ toolCallId: acc.id,
1497
+ toolName: acc.name,
1498
+ args,
1499
+ });
1500
+ toolsUsed.push(acc.name);
1501
+ const tool = toolsRecord[acc.name];
1502
+ try {
1503
+ if (!tool?.execute) {
1504
+ throw new Error(`Tool not found: ${acc.name}`);
1505
+ }
1506
+ const result = await tool.execute(args, {
1507
+ toolCallId: acc.id,
1508
+ messages: [],
877
1509
  });
878
- },
1510
+ toolResultsForStorage.push({
1511
+ type: "tool-result",
1512
+ toolCallId: acc.id,
1513
+ toolName: acc.name,
1514
+ result,
1515
+ });
1516
+ resultBlocks.push({
1517
+ type: "tool_result",
1518
+ tool_use_id: acc.id,
1519
+ content: stringifyAnthropicToolOutput(result),
1520
+ });
1521
+ }
1522
+ catch (toolErr) {
1523
+ const message = toolErr instanceof Error ? toolErr.message : String(toolErr);
1524
+ toolResultsForStorage.push({
1525
+ type: "tool-result",
1526
+ toolCallId: acc.id,
1527
+ toolName: acc.name,
1528
+ error: message,
1529
+ });
1530
+ resultBlocks.push({
1531
+ type: "tool_result",
1532
+ tool_use_id: acc.id,
1533
+ content: `Error: ${message}`,
1534
+ is_error: true,
1535
+ });
1536
+ }
1537
+ }
1538
+ // Emit tool:end events for Pipeline B and persist tool executions —
1539
+ // the same hooks the streamText onStepFinish callback used to drive.
1540
+ emitToolEndFromStepFinish(emitter, toolResultsForStorage.map((tr) => ({
1541
+ toolName: tr.toolName,
1542
+ result: tr.result,
1543
+ error: tr.error,
1544
+ })));
1545
+ this.handleToolExecutionStorage(toolCallsForStorage, toolResultsForStorage, options, new Date()).catch((storageErr) => {
1546
+ logger.warn("[AnthropicProvider] Failed to store tool executions", {
1547
+ provider: this.providerName,
1548
+ error: storageErr instanceof Error
1549
+ ? storageErr.message
1550
+ : String(storageErr),
1551
+ });
879
1552
  });
1553
+ conversation.push({ role: "user", content: resultBlocks });
1554
+ }
1555
+ resolveUsage({
1556
+ promptTokens: totalInput,
1557
+ completionTokens: totalOutput,
1558
+ totalTokens: totalInput + totalOutput,
1559
+ });
1560
+ resolveFinish(lastStop ?? "stop");
1561
+ };
1562
+ const loopPromise = runLoop()
1563
+ // Parameter named `error` so the compiled `capturedProviderError = error`
1564
+ // assignment matches the regression-grep in test:context 6.14.
1565
+ .catch((error) => {
1566
+ capturedProviderError = error;
1567
+ logger.error("Anthropic: Stream error", {
1568
+ error: error instanceof Error ? error.message : String(error),
1569
+ });
1570
+ resolveFinish("error");
1571
+ throw this.formatProviderError(error);
1572
+ })
1573
+ .finally(() => {
1574
+ timeoutController?.cleanup();
1575
+ pushChunk({ done: true });
1576
+ });
1577
+ loopPromise.catch(() => {
1578
+ // Swallowed by design: the generator below surfaces loop errors after
1579
+ // draining the queue; this guard only prevents an unhandled-rejection
1580
+ // crash when the consumer abandons the stream early.
1581
+ });
1582
+ const providerName = this.providerName;
1583
+ const transformedStream = async function* () {
1584
+ let contentYielded = 0;
1585
+ try {
1586
+ for (;;) {
1587
+ const chunk = await nextChunk();
1588
+ if ("done" in chunk) {
1589
+ break;
1590
+ }
1591
+ if ("content" in chunk &&
1592
+ typeof chunk.content === "string" &&
1593
+ chunk.content.length > 0) {
1594
+ contentYielded++;
1595
+ }
1596
+ yield chunk;
1597
+ }
1598
+ // Surface any error the loop threw after draining the queue.
1599
+ await loopPromise;
1600
+ // No-output path: stream completed normally but yielded zero text.
1601
+ if (contentYielded === 0 && toolsUsed.length === 0) {
1602
+ logger.warn(`${providerName}: Stream produced no output — emitting enriched sentinel`);
1603
+ const fauxNoOutput = new NoOutputGeneratedError({
1604
+ message: "Stream produced no output",
1605
+ });
1606
+ const sentinel = await buildNoOutputSentinel(fauxNoOutput, undefined, capturedProviderError);
1607
+ stampNoOutputSpan(sentinel);
1608
+ yield sentinel;
1609
+ }
880
1610
  }
881
1611
  catch (streamError) {
882
- streamSpan.setStatus({
883
- code: SpanStatusCode.ERROR,
884
- message: streamError instanceof Error
885
- ? streamError.message
886
- : String(streamError),
887
- });
888
- if (streamError instanceof Error) {
889
- streamSpan.recordException(streamError);
1612
+ const sentinel = await buildNoOutputSentinel(streamError, undefined, capturedProviderError);
1613
+ stampNoOutputSpan(sentinel);
1614
+ yield sentinel;
1615
+ if (!NoOutputGeneratedError.isInstance(streamError)) {
1616
+ throw streamError;
890
1617
  }
891
- streamSpan.end();
892
- throw streamError;
893
1618
  }
894
- // Collect token usage and finish reason asynchronously when the stream completes,
895
- // then end the span. This avoids blocking the stream consumer.
896
- Promise.resolve(result.usage)
897
- .then((usage) => {
898
- streamSpan.setAttribute("gen_ai.usage.input_tokens", usage.inputTokens || 0);
899
- streamSpan.setAttribute("gen_ai.usage.output_tokens", usage.outputTokens || 0);
900
- const cost = calculateCost(this.providerName, this.modelName, {
901
- input: usage.inputTokens || 0,
902
- output: usage.outputTokens || 0,
903
- total: (usage.inputTokens || 0) + (usage.outputTokens || 0),
904
- });
905
- if (cost && cost > 0) {
906
- streamSpan.setAttribute("neurolink.cost", cost);
1619
+ finally {
1620
+ if (!consumerAbortController.signal.aborted) {
1621
+ consumerAbortController.abort();
907
1622
  }
908
- })
909
- .catch(() => {
910
- // Usage may not be available if the stream is aborted
911
- });
912
- Promise.resolve(result.finishReason)
913
- .then((reason) => {
914
- streamSpan.setAttribute("gen_ai.response.finish_reason", reason || "unknown");
915
- })
916
- .catch(() => {
917
- // Finish reason may not be available if the stream is aborted
918
- });
919
- // End the span when the stream text resolves (stream fully consumed)
920
- Promise.resolve(result.text)
921
- .then(() => {
922
- streamSpan.end();
923
- })
924
- .catch((err) => {
925
- streamSpan.setStatus({
926
- code: SpanStatusCode.ERROR,
927
- message: err instanceof Error ? err.message : String(err),
928
- });
929
- streamSpan.end();
930
- });
931
- timeoutController?.cleanup();
932
- const transformedStream = this.createTextStream(result, () => capturedProviderError);
933
- // ✅ Note: Vercel AI SDK's streamText() method limitations with tools
934
- // The streamText() function doesn't provide the same tool result access as generateText()
935
- // Full tool support is now available with real streaming
936
- const toolCalls = [];
937
- const toolResults = [];
938
- return {
939
- stream: transformedStream,
940
- provider: this.providerName,
941
- model: this.modelName,
942
- toolCalls, // ✅ Include tool calls in stream result
943
- toolResults, // ✅ Include tool results in stream result
944
- // Note: omit usage/finishReason to avoid blocking streaming; compute asynchronously if needed.
945
- };
946
- }
947
- catch (error) {
948
- timeoutController?.cleanup();
949
- throw this.handleProviderError(error);
950
- }
1623
+ }
1624
+ };
1625
+ return {
1626
+ stream: transformedStream(),
1627
+ provider: this.providerName,
1628
+ model: this.modelName,
1629
+ toolCalls: [],
1630
+ toolResults: [],
1631
+ };
951
1632
  }
952
1633
  async isAvailable() {
953
1634
  try {
@@ -965,11 +1646,10 @@ export class AnthropicProvider extends BaseProvider {
965
1646
  }
966
1647
  }
967
1648
  getModel() {
968
- return this.model;
1649
+ return this.getAISDKModel();
969
1650
  }
970
1651
  }
971
1652
  // Re-export types and utilities for convenience
972
1653
  export { getModelCapabilities, getRecommendedModelForTier, isModelAvailableForTier, ModelAccessError, } from "../models/anthropicModels.js";
973
1654
  // Export beta headers constant for external use
974
1655
  export { ANTHROPIC_BETA_HEADERS };
975
- export default AnthropicProvider;