UNPKG

@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

975 lines (974 loc) 45.2 kB
import { createAnthropic } from "@ai-sdk/anthropic"; import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { stepCountIs, streamText } from "ai"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "fs"; import { homedir } from "os"; import { join } from "path"; import { ANTHROPIC_TOKEN_URL, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_CLIENT_ID, CLAUDE_CODE_OAUTH_BETAS, } from "../auth/anthropicOAuth.js"; import { AnthropicModels, TOKEN_EXPIRY_BUFFER_MS, } from "../constants/enums.js"; import { BaseProvider } from "../core/baseProvider.js"; import { DEFAULT_MAX_STEPS } from "../core/constants.js"; import { getModelCapabilities, getRecommendedModelForTier, isModelAvailableForTier, } from "../models/anthropicModels.js"; import { createOAuthFetch } from "../proxy/oauthFetch.js"; import { createProxyFetch } from "../proxy/proxyFetch.js"; import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { calculateCost } from "../utils/pricing.js"; import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js"; import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { getModelId } from "./providerTypeUtils.js"; /** * Beta headers for Claude Code integration. * These enable experimental features: * - claude-code-20250219: Claude Code specific features * - fine-grained-tool-streaming-2025-05-14: Fine-grained tool streaming * * Note: interleaved-thinking-2025-05-14 was removed — it was claude-3-7-sonnet * specific and causes invalid_request_error (HTTP 400) on claude-4 models * (claude-opus-4-6, claude-sonnet-4-6) which handle thinking via the * `thinking` request body parameter instead. */ const ANTHROPIC_BETA_HEADERS = { "anthropic-beta": [ "claude-code-20250219", "fine-grained-tool-streaming-2025-05-14", ].join(","), }; // AnthropicProviderConfig is imported from types/providers.ts // Re-export for backward compatibility // Configuration helpers - now using consolidated utility const getAnthropicApiKey = () => { return validateApiKey(createAnthropicConfig()); }; const getDefaultAnthropicModel = () => { return getProviderModel("ANTHROPIC_MODEL", AnthropicModels.CLAUDE_SONNET_4_6); }; const streamTracer = trace.getTracer("neurolink.provider.anthropic"); /** * Get OAuth token from stored credentials file or environment. * Priority: * 1. Stored credentials file (~/.neurolink/anthropic-credentials.json) * 2. Environment variables (ANTHROPIC_OAUTH_TOKEN or CLAUDE_OAUTH_TOKEN) */ const getOAuthToken = () => { // First, check stored credentials file (highest priority) try { const credentialsPath = join(homedir(), ".neurolink", "anthropic-credentials.json"); if (existsSync(credentialsPath)) { const credentialsContent = readFileSync(credentialsPath, "utf-8"); const credentials = JSON.parse(credentialsContent); if (credentials.type === "oauth" && credentials.oauth?.accessToken) { logger.debug("[AnthropicProvider] Using OAuth token from stored credentials file"); return credentials.oauth; } } } catch (error) { logger.debug("[AnthropicProvider] Failed to read stored credentials:", error); } // Fallback to environment variables const tokenString = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.CLAUDE_OAUTH_TOKEN; if (!tokenString) { return null; } // Try to parse as JSON (for full token object with refresh token and expiry) try { const parsed = JSON.parse(tokenString); if (typeof parsed === "object" && parsed.accessToken) { return parsed; } // If it's a simple string in JSON, use it as access token if (typeof parsed === "string") { return { accessToken: parsed }; } } catch { // Not JSON, treat as plain access token string } // Treat as plain access token string return { accessToken: tokenString }; }; /** * Detect subscription tier from environment or token. * Environment variable ANTHROPIC_SUBSCRIPTION_TIER takes precedence. */ const detectSubscriptionTier = (oauthToken) => { // Check explicit environment variable first const envTier = process.env.ANTHROPIC_SUBSCRIPTION_TIER?.toLowerCase(); if (envTier) { const validTiers = [ "free", "pro", "max", "max_5", "max_20", "api", ]; if (validTiers.includes(envTier)) { logger.debug("[detectSubscriptionTier] Using environment override", { tier: envTier, }); return envTier; } logger.warn("[detectSubscriptionTier] Invalid ANTHROPIC_SUBSCRIPTION_TIER", { value: envTier, validTiers, }); } // If using OAuth, default to 'pro' (most common subscription tier) if (oauthToken) { // Check if token scopes indicate tier (future-proofing) const scopes = oauthToken.scopes ?? []; let detectedTier = "pro"; if (scopes.includes("max_20")) { detectedTier = "max_20"; } else if (scopes.includes("max_5")) { detectedTier = "max_5"; } else if (scopes.includes("max")) { detectedTier = "max"; } logger.debug("[detectSubscriptionTier] Detected from OAuth token", { tier: detectedTier, scopes, }); return detectedTier; } // Default to 'api' for API key authentication logger.debug("[detectSubscriptionTier] No OAuth token, defaulting to API tier"); return "api"; }; /** * Determine authentication method based on available credentials. * OAuth takes precedence over API key if both are available. */ const detectAuthMethod = (oauthToken) => { // Explicit env var takes highest precedence — allows forcing api_key mode // even when OAuth credentials exist (e.g., when using a proxy that handles auth) const explicit = process.env.ANTHROPIC_AUTH_METHOD?.toLowerCase(); if (explicit === "api_key" || explicit === "apikey") { logger.debug("[detectAuthMethod] Forced to api_key by ANTHROPIC_AUTH_METHOD env var"); return "api_key"; } if (explicit === "oauth") { if (oauthToken) { logger.debug("[detectAuthMethod] Forced to oauth by ANTHROPIC_AUTH_METHOD env var"); return "oauth"; } logger.warn("[detectAuthMethod] ANTHROPIC_AUTH_METHOD=oauth but no OAuth token found; falling through to auto-detection"); } else if (explicit) { logger.warn("[detectAuthMethod] Unrecognized ANTHROPIC_AUTH_METHOD value; falling through to auto-detection", { value: explicit, }); } // Auto-detect: OAuth takes precedence if available const method = oauthToken ? "oauth" : "api_key"; logger.debug("[detectAuthMethod] Auth method resolved", { method, hasOAuthToken: !!oauthToken, }); return method; }; /** * Parse rate limit information from Anthropic API response headers. * @param headers - Response headers from Anthropic API * @returns Parsed rate limit information */ const parseRateLimitHeaders = (headers) => { const getHeader = (name) => { if (headers instanceof Headers) { return headers.get(name); } return headers[name] || headers[name.toLowerCase()] || null; }; const parseNumber = (value) => { if (!value) { return undefined; } const num = parseInt(value, 10); return isNaN(num) ? undefined : num; }; return { requestsLimit: parseNumber(getHeader("anthropic-ratelimit-requests-limit")), requestsRemaining: parseNumber(getHeader("anthropic-ratelimit-requests-remaining")), requestsReset: getHeader("anthropic-ratelimit-requests-reset") || undefined, tokensLimit: parseNumber(getHeader("anthropic-ratelimit-tokens-limit")), tokensRemaining: parseNumber(getHeader("anthropic-ratelimit-tokens-remaining")), tokensReset: getHeader("anthropic-ratelimit-tokens-reset") || undefined, retryAfter: parseNumber(getHeader("retry-after")), }; }; /** * Anthropic Provider v2 - BaseProvider Implementation * Enhanced with OAuth support, subscription tiers, and beta headers for Claude Code integration. */ export class AnthropicProvider extends BaseProvider { model; authMethod; subscriptionTier; enableBetaFeatures; oauthToken; lastResponseMetadata = null; usageInfo = null; refreshPromise; /** * Create a new Anthropic provider instance. * * @param modelName - Optional model name to use (defaults to CLAUDE_3_5_SONNET) * @param sdk - Optional NeuroLink SDK instance * @param config - Optional configuration options for auth, subscription tier, and beta features */ constructor(modelName, sdk, config, credentials) { // Pre-compute effective model with tier validation before calling super. // // When per-request credentials supply an apiKey (without oauthToken), // force api_key auth — skip OAuth detection entirely so the caller's // key is used rather than a stale OAuth token from ~/.neurolink/. const forceApiKey = !!(credentials?.apiKey && !credentials?.oauthToken); const oauthToken = forceApiKey ? null : ((credentials?.oauthToken ? { accessToken: credentials.oauthToken } : null) ?? config?.oauthToken ?? getOAuthToken()); // Resolve auth method FIRST so that tier detection uses the chosen method. // If ANTHROPIC_AUTH_METHOD=api_key wins over an existing OAuth token, the // tier must reflect api_key mode (full model access) rather than the OAuth // token's subscription level. const authMethod = forceApiKey ? "api_key" : (config?.authMethod ?? detectAuthMethod(oauthToken)); const subscriptionTier = config?.subscriptionTier ?? (authMethod === "oauth" ? detectSubscriptionTier(oauthToken) : "api"); const targetModel = modelName || getDefaultAnthropicModel(); // Determine effective model based on tier access. // Skip tier validation when a proxy is in use (ANTHROPIC_BASE_URL is set) // — the proxy handles model access and auth, so the SDK should pass // the requested model through without downgrading. let effectiveModel = targetModel; const usingProxy = !!process.env.ANTHROPIC_BASE_URL; if (!usingProxy && subscriptionTier !== "api" && !isModelAvailableForTier(targetModel, subscriptionTier)) { effectiveModel = getRecommendedModelForTier(subscriptionTier); logger.warn("Model not available for subscription tier, using recommended model", { requestedModel: targetModel, subscriptionTier, recommendedModel: effectiveModel, }); } super(effectiveModel, "anthropic", sdk); // Apply configuration with defaults this.enableBetaFeatures = config?.enableBetaFeatures ?? true; // Store computed values this.oauthToken = oauthToken; this.subscriptionTier = subscriptionTier; // Use the auth method already resolved above (before tier computation) this.authMethod = authMethod; // Build headers based on auth method and subscription tier const headers = this.getAuthHeaders(); // Create Anthropic instance based on auth method let anthropic; logger.debug("[AnthropicProvider] Constructor - checking OAuth:", { authMethod: this.authMethod, hasOAuthToken: !!this.oauthToken, hasAccessToken: !!this.oauthToken?.accessToken, }); if (this.authMethod === "oauth" && this.oauthToken) { // OAuth authentication - use custom fetch wrapper that handles: // - Bearer token authorization // - OAuth beta headers (oauth-2025-04-20, NOT claude-code-20250219) // - User-Agent spoofing // - ?beta=true query param // - Tool name prefixing/stripping logger.debug("[AnthropicProvider] Creating OAuth fetch wrapper..."); // Pass a getter so the fetch wrapper always uses the current token, // even after an automatic token refresh. // oauthToken is guaranteed non-null here (checked by the enclosing if-guard). const tokenRef = this.oauthToken; // skipBodyTransform=true: For the SDK provider path, body transforms ARE // intentionally skipped because the Vercel AI SDK builds its own request // format (system prompts, metadata, tool definitions). The billing header, // agent block, user_id injection, and mcp_ tool-name prefixing are only // needed for proxy passthrough of raw Claude API requests where we must // make the request look like it came from Claude Code / CLIProxyAPI. const oauthFetch = createOAuthFetch(() => tokenRef.accessToken, this.enableBetaFeatures, false, // No mcp_ prefix — tool names pass through as-is (matches CLIProxyAPI) true); // For OAuth, we use a dummy API key since our fetch wrapper handles auth // IMPORTANT: Do NOT pass beta headers here - our fetch wrapper handles them // The claude-code-20250219 beta header triggers "credential only for Claude Code" error anthropic = createAnthropic({ apiKey: "oauth-authenticated", // Placeholder, actual auth is in fetch wrapper // Note: No headers passed - fetch wrapper sets oauth-2025-04-20 beta header fetch: oauthFetch, }); logger.debug("[AnthropicProvider] Anthropic SDK created with OAuth fetch wrapper"); logger.debug("Anthropic Provider initialized with OAuth", { modelName: this.modelName, provider: this.providerName, authMethod: this.authMethod, subscriptionTier: this.subscriptionTier, enableBetaFeatures: this.enableBetaFeatures, hasRefreshToken: !!this.oauthToken.refreshToken, tokenExpiry: this.oauthToken.expiresAt ? new Date(this.oauthToken.expiresAt).toISOString() : "none", }); } else { // Traditional API key authentication const apiKeyToUse = credentials?.apiKey ?? config?.apiKey ?? getAnthropicApiKey(); // The Vercel AI SDK builds `${baseURL}/messages` (no version prefix). // Anthropic's REST API lives under `/v1/messages`, so a user-supplied // bare host like `http://localhost:55669` 404s. Normalize: if the // baseURL doesn't already end with `/vN`, append `/v1`. // // CAVEAT: this heuristic assumes the proxy exposes Anthropic-compatible // endpoints rooted at `/vN/...`. Path-rooted proxies like // `https://proxy.example.com/anthropic` will be silently rewritten to // `…/anthropic/v1` and 404 if the proxy doesn't expose that path. To // disable auto-append, set `ANTHROPIC_BASE_URL` explicitly ending in a // version segment (e.g. `https://api.anthropic.com/v1`). const normalizedBaseURL = (() => { const raw = process.env.ANTHROPIC_BASE_URL; if (!raw) { return undefined; } const trimmed = raw.replace(/\/+$/, ""); if (/\/v\d+$/.test(trimmed)) { return trimmed; } logger.warn("[AnthropicProvider] ANTHROPIC_BASE_URL does not end with /vN; " + "appending /v1 so the Vercel AI SDK can build /messages correctly. " + "To silence this warning, set ANTHROPIC_BASE_URL with an explicit " + "version segment (e.g. https://api.anthropic.com/v1). " + "If your proxy exposes Anthropic at a custom path that doesn't " + "expose /v1/messages, the auto-append will produce a 404 — pass " + "the full path including the version explicitly.", { baseURL: raw, rewrittenTo: `${trimmed}/v1` }); return `${trimmed}/v1`; })(); anthropic = createAnthropic({ apiKey: apiKeyToUse, headers, ...(normalizedBaseURL && { baseURL: normalizedBaseURL }), fetch: createProxyFetch(), }); logger.debug("Anthropic Provider initialized with API key", { modelName: this.modelName, provider: this.providerName, authMethod: this.authMethod, subscriptionTier: this.subscriptionTier, enableBetaFeatures: this.enableBetaFeatures, }); } // Initialize Anthropic model with configured instance this.model = anthropic(this.modelName || getDefaultAnthropicModel()); // Initialize usage tracking this.usageInfo = { messagesUsed: 0, messagesRemaining: -1, // Unknown until we get rate limit headers tokensUsed: 0, tokensRemaining: -1, inputTokensUsed: 0, outputTokensUsed: 0, lastRequestTimestamp: 0, isRateLimited: false, requestCount: 0, messageQuotaPercent: 0, tokenQuotaPercent: 0, }; logger.debug("Anthropic Provider v2 initialized", { modelName: this.modelName, provider: this.providerName, authMethod: this.authMethod, subscriptionTier: this.subscriptionTier, enableBetaFeatures: this.enableBetaFeatures, betaFeatures: this.enableBetaFeatures ? ANTHROPIC_BETA_HEADERS["anthropic-beta"] : "disabled", }); } /** * Get authentication headers based on current auth method and configuration. * * @returns Headers object containing auth and beta feature headers */ getAuthHeaders() { const headers = {}; // When routing through proxy (ANTHROPIC_BASE_URL set), use the full // OAuth beta set so the proxy forwards them upstream. Without these, // Anthropic treats the request with tighter non-subscription rate limits. const usingProxy = !!process.env.ANTHROPIC_BASE_URL; if (this.enableBetaFeatures) { if (usingProxy) { // The 1M-context beta requires a plan upgrade on most accounts; // surfacing it by default forces a "The long context beta is not // yet available for this subscription." failure for everyone else. // Gate behind ANTHROPIC_ENABLE_LONG_CONTEXT_BETA=1 so default-tier // accounts (and CI) can use the proxy without the gated feature. const longContextOptIn = process.env.ANTHROPIC_ENABLE_LONG_CONTEXT_BETA === "1" || process.env.ANTHROPIC_ENABLE_LONG_CONTEXT_BETA === "true"; const betas = [ ...CLAUDE_CODE_OAUTH_BETAS, "fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14", "redact-thinking-2026-02-12", ]; if (longContextOptIn) { betas.push("context-1m-2025-08-07"); } headers["anthropic-beta"] = betas.join(","); } else { headers["anthropic-beta"] = ANTHROPIC_BETA_HEADERS["anthropic-beta"]; } } // Add subscription-specific headers if applicable if (this.subscriptionTier !== "api") { headers["x-subscription-tier"] = this.subscriptionTier; } return headers; } /** * Validate if a model is accessible with the current subscription tier. * * @param model - The model ID to validate * @returns true if the model is accessible, false otherwise * * @example * ```typescript * const provider = new AnthropicProvider(); * if (provider.validateModelAccess("claude-opus-4-5-20251101")) { * // Use the model * } else { * // Fall back to a different model or show upgrade prompt * } * ``` */ validateModelAccess(model) { // Proxy mode: bypass tier validation entirely — the proxy handles model // access. Log at debug level so users can tell why an unknown model name // "validated" when their proxy may not actually expose it. if (process.env.ANTHROPIC_BASE_URL) { logger.debug("[validateModelAccess] Bypassing tier check (ANTHROPIC_BASE_URL set — proxy enforces access)", { model }); return true; } // API tier has access to all models if (this.subscriptionTier === "api") { return true; } const hasAccess = isModelAvailableForTier(model, this.subscriptionTier); if (!hasAccess) { logger.debug("[validateModelAccess] Model not available for tier", { model, tier: this.subscriptionTier, }); } return hasAccess; } /** * Get current usage information. * * Returns usage tracking data including messages sent, tokens consumed, * and remaining quotas. This information is updated after each API request. * * @returns Current usage info or null if no requests have been made * * @example * ```typescript * const usage = provider.getUsageInfo(); * if (usage && usage.tokenQuotaPercent > 80) { * console.warn("Approaching token quota limit"); * } * ``` */ getUsageInfo() { return this.usageInfo; } /** * Check if beta features are enabled for this provider instance. * * @returns true if beta features are enabled */ areBetaFeaturesEnabled() { return this.enableBetaFeatures; } /** * Get model capabilities for the current model. * * @returns The model capabilities or undefined if not found */ getModelCapabilities() { return getModelCapabilities(this.modelName || this.getDefaultModel()); } /** * Get the current subscription tier. * @returns The detected or configured subscription tier */ getSubscriptionTier() { return this.subscriptionTier; } /** * Get the authentication method being used. * @returns The current authentication method */ getAuthMethod() { return this.authMethod; } /** * Refresh OAuth token if needed and possible. * This method checks if the token is expired or about to expire, * and attempts to refresh it using the refresh token if available. * * @returns Promise that resolves when refresh is complete (or not needed) * @throws Error if refresh is needed but fails */ async refreshAuthIfNeeded() { // Only applicable for OAuth authentication if (this.authMethod !== "oauth" || !this.oauthToken) { logger.debug("Token refresh not applicable for API key authentication"); return; } // Check if token has expiry information if (!this.oauthToken.expiresAt) { logger.debug("Token has no expiry information, assuming valid"); return; } // expiresAt is stored as Unix milliseconds (matching how auth status/refresh stores it). // Compare against Date.now() so both sides are in milliseconds. const now = Date.now(); const isExpired = this.oauthToken.expiresAt <= now; const isExpiringSoon = this.oauthToken.expiresAt <= now + TOKEN_EXPIRY_BUFFER_MS; if (!isExpired && !isExpiringSoon) { logger.debug("OAuth token is still valid", { expiresInMs: this.oauthToken.expiresAt - now, }); return; } // Check if we have a refresh token if (!this.oauthToken.refreshToken) { if (isExpired) { throw new AuthenticationError("OAuth token expired and no refresh token available. Please re-authenticate.", this.providerName); } logger.warn("OAuth token expiring soon but no refresh token available", { expiresInMs: this.oauthToken.expiresAt - now, }); return; } // Serialize concurrent refresh attempts — if a refresh is already in flight, // wait for it rather than issuing a duplicate request. if (this.refreshPromise) { await this.refreshPromise; return; } // Attempt to refresh the token using the correct Anthropic token endpoint. logger.info("Refreshing OAuth token", { isExpired, expiresInMs: this.oauthToken.expiresAt - now, }); // Capture the token reference before entering the async IIFE; // the enclosing guards already verified both fields are non-null. const tokenRef = this.oauthToken; const refreshToken = tokenRef.refreshToken; this.refreshPromise = (async () => { const REFRESH_TIMEOUT_MS = 30_000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS); // User-Agent is set to CLAUDE_CLI_USER_AGENT so the refresh request // matches what the official Claude CLI / CLIProxyAPI sends. Anthropic // gates parts of the OAuth flow on this UA (the same `client_id` is // rejected by `ANTHROPIC_TOKEN_URL` if the UA looks like a generic // SDK), so this is required for OAuth refresh to succeed — not a // cosmetic choice. If Anthropic ever publishes a separate UA for // third-party OAuth clients, switch to that. See `auth/anthropicOAuth.ts` // for the source of `CLAUDE_CLI_USER_AGENT` / `CLAUDE_CODE_CLIENT_ID`. const response = await fetch(ANTHROPIC_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": CLAUDE_CLI_USER_AGENT, }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLAUDE_CODE_CLIENT_ID, }), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); throw new AuthenticationError(`Failed to refresh OAuth token: ${response.status} ${errorText}`, this.providerName); } const newToken = (await response.json()); // Mutate the existing oauthToken object in-place so that the fetch wrapper // closure (which captured the object reference, not a copy) picks up the // new accessToken automatically on the next request. // Store expiresAt as milliseconds to match the format used by auth status/refresh. tokenRef.accessToken = newToken.access_token; tokenRef.refreshToken = newToken.refresh_token || tokenRef.refreshToken; tokenRef.expiresAt = newToken.expires_in ? Date.now() + newToken.expires_in * 1000 : undefined; tokenRef.tokenType = newToken.token_type || "Bearer"; const updatedToken = tokenRef; // Persist the refreshed token to disk atomically (tmp + rename) so // subsequent provider instances and the CLI pick up the new credentials. try { const credentialsDir = join(homedir(), ".neurolink"); if (!existsSync(credentialsDir)) { mkdirSync(credentialsDir, { recursive: true }); } const credentialsPath = join(credentialsDir, "anthropic-credentials.json"); const tmpPath = `${credentialsPath}.tmp`; const existingRaw = existsSync(credentialsPath) ? JSON.parse(readFileSync(credentialsPath, "utf-8")) : {}; const updated = { ...existingRaw, type: "oauth", oauth: updatedToken, updatedAt: Date.now(), }; writeFileSync(tmpPath, JSON.stringify(updated, null, 2), { mode: 0o600, }); renameSync(tmpPath, credentialsPath); logger.debug("Refreshed OAuth credentials persisted to disk"); } catch (persistError) { // Non-fatal: in-memory token is already updated; next CLI start will // need a manual refresh but the current session will work. logger.warn("Failed to persist refreshed OAuth token to disk", { error: persistError instanceof Error ? persistError.message : String(persistError), }); } logger.info("OAuth token refreshed successfully", { hasNewRefreshToken: !!newToken.refresh_token, expiresIn: newToken.expires_in, }); })(); try { await this.refreshPromise; } catch (error) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError(`Failed to refresh OAuth token: ${error instanceof Error ? error.message : String(error)}`, this.providerName); } finally { this.refreshPromise = undefined; } } /** * Get the last response metadata including rate limit information. * @returns The last response metadata or null if no request has been made */ getLastResponseMetadata() { return this.lastResponseMetadata; } /** * Update response metadata from API response headers. * This should be called after each API request to track rate limits. * @param headers - Response headers from the API * @param requestId - Optional request ID */ updateResponseMetadata(headers, requestId, usageUpdate) { this.lastResponseMetadata = { rateLimit: parseRateLimitHeaders(headers), requestId: requestId || (headers instanceof Headers ? headers.get("x-request-id") || undefined : headers["x-request-id"]), serverTiming: headers instanceof Headers ? headers.get("server-timing") || undefined : headers["server-timing"], }; // Update usage tracking const rateLimit = this.lastResponseMetadata.rateLimit; if (this.usageInfo) { this.usageInfo.requestCount++; this.usageInfo.messagesUsed++; this.usageInfo.lastRequestTimestamp = Date.now(); // Update token usage if provided if (usageUpdate) { if (usageUpdate.inputTokens !== undefined) { this.usageInfo.inputTokensUsed += usageUpdate.inputTokens; this.usageInfo.tokensUsed += usageUpdate.inputTokens; } if (usageUpdate.outputTokens !== undefined) { this.usageInfo.outputTokensUsed += usageUpdate.outputTokens; this.usageInfo.tokensUsed += usageUpdate.outputTokens; } } // Update remaining quotas from rate limit headers if (rateLimit?.requestsRemaining !== undefined) { this.usageInfo.messagesRemaining = rateLimit.requestsRemaining; } if (rateLimit?.tokensRemaining !== undefined) { this.usageInfo.tokensRemaining = rateLimit.tokensRemaining; } // Calculate quota percentages if (rateLimit?.requestsLimit && rateLimit.requestsLimit > 0) { this.usageInfo.messageQuotaPercent = Math.round(((rateLimit.requestsLimit - (rateLimit.requestsRemaining ?? 0)) / rateLimit.requestsLimit) * 100); } if (rateLimit?.tokensLimit && rateLimit.tokensLimit > 0) { this.usageInfo.tokenQuotaPercent = Math.round(((rateLimit.tokensLimit - (rateLimit.tokensRemaining ?? 0)) / rateLimit.tokensLimit) * 100); } // Check for rate limiting if (rateLimit?.retryAfter !== undefined) { this.usageInfo.isRateLimited = true; this.usageInfo.rateLimitExpiresAt = Date.now() + rateLimit.retryAfter * 1000; } else { this.usageInfo.isRateLimited = false; this.usageInfo.rateLimitExpiresAt = undefined; } } // Log rate limit warnings if approaching limits if (rateLimit?.requestsRemaining !== undefined) { if (rateLimit.requestsRemaining <= 5) { logger.warn("Approaching Anthropic request rate limit", { remaining: rateLimit.requestsRemaining, limit: rateLimit.requestsLimit, reset: rateLimit.requestsReset, }); } } if (rateLimit?.tokensRemaining !== undefined) { if (rateLimit.tokensLimit && rateLimit.tokensRemaining < rateLimit.tokensLimit * 0.1) { logger.warn("Approaching Anthropic token rate limit", { remaining: rateLimit.tokensRemaining, limit: rateLimit.tokensLimit, reset: rateLimit.tokensReset, }); } } } getProviderName() { return "anthropic"; } getDefaultModel() { return getDefaultAnthropicModel(); } /** * Returns the Vercel AI SDK model instance for Anthropic */ getAISDKModel() { return this.model; } formatProviderError(error) { if (error instanceof TimeoutError) { return new NetworkError(`Request timed out after ${error.timeout}ms`, this.providerName); } const errorRecord = error; const message = typeof errorRecord?.message === "string" ? errorRecord.message : "Unknown error"; if (message.includes("API_KEY_INVALID") || message.includes("Invalid API key")) { return new AuthenticationError("Invalid Anthropic API key. Please check your ANTHROPIC_API_KEY environment variable.", this.providerName); } if (message.includes("rate limit") || message.includes("too_many_requests") || message.includes("429")) { return new RateLimitError("Anthropic rate limit exceeded. Please try again later.", this.providerName); } if (message.includes("ECONNRESET") || message.includes("ENOTFOUND") || message.includes("ECONNREFUSED") || message.includes("network") || message.includes("connection")) { return new NetworkError(`Connection error: ${message}`, this.providerName); } if (message.includes("500") || message.includes("502") || message.includes("503") || message.includes("504") || message.includes("server error")) { return new ProviderError(`Server error: ${message}`, this.providerName); } return new ProviderError(`Anthropic error: ${message}`, this.providerName); } // executeGenerate removed - BaseProvider handles all generation with tools /** * Override generate to refresh the OAuth token before delegating to * BaseProvider so that expired tokens are renewed automatically. */ async generate(optionsOrPrompt, analysisSchema) { await this.refreshAuthIfNeeded(); return super.generate(optionsOrPrompt, analysisSchema); } async executeStream(options, _analysisSchema) { // Refresh OAuth token if needed before making any API request. await this.refreshAuthIfNeeded(); this.validateStreamOptions(options); const timeout = this.getTimeout(options); const timeoutController = createTimeoutController(timeout, this.providerName, "stream"); try { // Get tools - options.tools is pre-merged by BaseProvider.stream() with // base tools (MCP/built-in) + user-provided tools (RAG, etc.) const shouldUseTools = !options.disableTools && this.supportsTools(); const tools = shouldUseTools ? options.tools || (await this.getAllTools()) : {}; // Build message array from options with multimodal support // Using protected helper from BaseProvider to eliminate code duplication const messages = await this.buildMessagesForStream(options); const model = await this.getAISDKModelWithMiddleware(options); // 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 sentinel carries the real cause in // providerError / modelResponseRaw. let capturedProviderError; let result; try { result = streamText({ model: model, messages: messages, 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, stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS), toolChoice: resolveToolChoice(options, tools, shouldUseTools), abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal), onError: (event) => { capturedProviderError = event.error; logger.error("Anthropic: Stream error", { error: event.error instanceof Error ? event.error.message : String(event.error), }); }, experimental_repairToolCall: this.getToolCallRepairFn(options), experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options), onStepFinish: ({ toolCalls, toolResults }) => { // Emit tool:end for each completed tool result so Pipeline B // captures telemetry for AI-SDK-driven tool calls (gap S2). emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults); this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => { logger.warn("[AnthropicProvider] Failed to store tool executions", { provider: this.providerName, error: error instanceof Error ? error.message : String(error), }); }); }, }); } catch (streamError) { streamSpan.setStatus({ code: SpanStatusCode.ERROR, message: streamError instanceof Error ? streamError.message : String(streamError), }); if (streamError instanceof Error) { streamSpan.recordException(streamError); } streamSpan.end(); throw streamError; } // 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 }); // End the span when the stream text resolves (stream fully consumed) 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(); const transformedStream = this.createTextStream(result, () => capturedProviderError); // ✅ Note: Vercel AI SDK's streamText() method limitations with tools // The streamText() function doesn't provide the same tool result access as generateText() // Full tool support is now available with real streaming const toolCalls = []; const toolResults = []; return { stream: transformedStream, provider: this.providerName, model: this.modelName, toolCalls, // ✅ Include tool calls in stream result toolResults, // ✅ Include tool results in stream result // Note: omit usage/finishReason to avoid blocking streaming; compute asynchronously if needed. }; } catch (error) { timeoutController?.cleanup(); throw this.handleProviderError(error); } } async isAvailable() { try { // Check OAuth token first const oauthToken = getOAuthToken(); if (oauthToken) { return true; } // Fall back to API key check getAnthropicApiKey(); return true; } catch { return false; } } getModel() { return this.model; } } // Re-export types and utilities for convenience export { getModelCapabilities, getRecommendedModelForTier, isModelAvailableForTier, ModelAccessError, } from "../models/anthropicModels.js"; // Export beta headers constant for external use export { ANTHROPIC_BETA_HEADERS }; export default AnthropicProvider;