@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
212 lines • 10.6 kB
JavaScript
import { createAnthropic } from "@ai-sdk/anthropic";
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import { NoOutputGeneratedError, streamText, } from "ai";
import { AnthropicModels } from "../constants/enums.js";
import { BaseProvider } from "../core/baseProvider.js";
import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
import { logger } from "../utils/logger.js";
import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
import { calculateCost } from "../utils/pricing.js";
import { createAnthropicBaseConfig, validateApiKey, } from "../utils/providerConfig.js";
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
import { resolveToolChoice } from "../utils/toolChoice.js";
import { getModelId } from "./providerTypeUtils.js";
const streamTracer = trace.getTracer("neurolink.provider.anthropic");
/**
* Anthropic provider implementation using BaseProvider pattern
* Migrated from direct API calls to Vercel AI SDK (@ai-sdk/anthropic)
* Follows exact Google AI interface patterns for compatibility
*/
export class AnthropicProviderV2 extends BaseProvider {
constructor(modelName) {
super(modelName, "anthropic");
logger.debug("AnthropicProviderV2 initialized", {
model: this.modelName,
provider: this.providerName,
});
}
// ===================
// ABSTRACT METHOD IMPLEMENTATIONS
// ===================
getProviderName() {
return "anthropic";
}
getDefaultModel() {
return process.env.ANTHROPIC_MODEL || AnthropicModels.CLAUDE_3_5_SONNET;
}
/**
* Returns the Vercel AI SDK model instance for Anthropic
*/
getAISDKModel() {
const apiKey = this.getApiKey();
const anthropic = createAnthropic({ apiKey });
return anthropic(this.modelName);
}
formatProviderError(error) {
if (error instanceof TimeoutError) {
return new NetworkError(`Request timed out: ${error.message}`, this.providerName);
}
const errorWithStatus = error;
if (errorWithStatus?.status === 401) {
return new AuthenticationError("Invalid Anthropic API key. Please check your ANTHROPIC_API_KEY environment variable.", this.providerName);
}
if (errorWithStatus?.status === 429) {
return new RateLimitError("Anthropic rate limit exceeded. Please try again later.", this.providerName);
}
if (errorWithStatus?.status === 400) {
return new ProviderError(`Bad request: ${errorWithStatus?.message || "Invalid request parameters"}`, this.providerName);
}
return new ProviderError(`Anthropic error: ${errorWithStatus?.message || String(error) || "Unknown error"}`, this.providerName);
}
// Configuration helper - now using consolidated utility
getApiKey() {
return validateApiKey(createAnthropicBaseConfig());
}
// executeGenerate removed - BaseProvider handles all generation with tools
async executeStream(options, _analysisSchema) {
// Note: StreamOptions validation handled differently than TextGenerationOptions
const model = await this.getAISDKModelWithMiddleware(options);
const timeout = this.getTimeout(options);
const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
try {
// Get tools - options.tools is pre-merged by BaseProvider.stream()
const shouldUseTools = !options.disableTools && this.supportsTools();
const tools = shouldUseTools
? options.tools || (await this.getAllTools())
: {};
// Wrap streamText in an OTel span to capture provider-level latency and token usage
const streamSpan = streamTracer.startSpan("neurolink.provider.streamText", {
kind: SpanKind.CLIENT,
attributes: {
"gen_ai.system": "anthropic",
"gen_ai.request.model": getModelId(model, this.modelName || "unknown"),
},
});
// Reviewer follow-up: capture upstream provider errors via onError
// so the post-stream NoOutput detect can propagate the real cause
// into the sentinel's providerError / modelResponseRaw.
let capturedProviderError;
let result;
try {
result = streamText({
model,
prompt: options.input.text ?? "",
system: options.systemPrompt,
temperature: options.temperature,
maxOutputTokens: options.maxTokens, // No default limit - unlimited unless specified
maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
tools,
toolChoice: resolveToolChoice(options, tools, shouldUseTools),
abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
experimental_repairToolCall: this.getToolCallRepairFn(options),
onError: (event) => {
capturedProviderError = event.error;
logger.error("AnthropicBaseProvider: Stream error", {
error: event.error instanceof Error
? event.error.message
: String(event.error),
});
},
onStepFinish: ({ toolCalls, toolResults }) => {
this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
logger.warn("[AnthropicBaseProvider] Failed to store tool executions", {
provider: this.providerName,
error: error instanceof Error ? error.message : String(error),
});
});
},
});
}
catch (err) {
streamSpan.recordException(err instanceof Error ? err : new Error(String(err)));
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
streamSpan.end();
throw err;
}
// Collect token usage and finish reason asynchronously when the stream completes,
// then end the span. This avoids blocking the stream consumer.
Promise.resolve(result.usage)
.then((usage) => {
streamSpan.setAttribute("gen_ai.usage.input_tokens", usage.inputTokens || 0);
streamSpan.setAttribute("gen_ai.usage.output_tokens", usage.outputTokens || 0);
const cost = calculateCost(this.providerName, this.modelName, {
input: usage.inputTokens || 0,
output: usage.outputTokens || 0,
total: (usage.inputTokens || 0) + (usage.outputTokens || 0),
});
if (cost && cost > 0) {
streamSpan.setAttribute("neurolink.cost", cost);
}
})
.catch(() => {
// Usage may not be available if the stream is aborted
});
Promise.resolve(result.finishReason)
.then((reason) => {
streamSpan.setAttribute("gen_ai.response.finish_reason", reason || "unknown");
})
.catch(() => {
// Finish reason may not be available if the stream is aborted
});
Promise.resolve(result.text)
.then(() => {
streamSpan.end();
})
.catch((err) => {
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
streamSpan.end();
});
timeoutController?.cleanup();
// Transform string stream to content object stream (match Google AI pattern)
const transformedStream = async function* () {
let chunkCount = 0;
try {
for await (const chunk of result.textStream) {
chunkCount++;
yield { content: chunk };
}
}
catch (streamError) {
if (NoOutputGeneratedError.isInstance(streamError)) {
logger.warn("AnthropicBaseProvider: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
const sentinel = await buildNoOutputSentinel(streamError, result, capturedProviderError);
stampNoOutputSpan(sentinel);
yield sentinel;
return;
}
throw streamError;
}
// Curator P3-6 (round-2 fix): production trigger sets the error
// on result.finishReason rejection, not on textStream iteration.
// Surface that path here so the sentinel actually fires.
if (chunkCount === 0) {
const detected = await detectPostStreamNoOutput(result, capturedProviderError);
if (detected) {
logger.warn("AnthropicBaseProvider: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
stampNoOutputSpan(detected.sentinel);
yield detected.sentinel;
}
}
};
return {
stream: transformedStream(),
provider: this.providerName,
model: this.modelName,
};
}
catch (error) {
timeoutController?.cleanup();
throw this.handleProviderError(error);
}
}
}
// Export for testing
export default AnthropicProviderV2;
//# sourceMappingURL=anthropicBaseProvider.js.map