UNPKG

langsmith

Version:

Client library to connect to the LangSmith Observability and Evaluation Platform.

345 lines (344 loc) 13.8 kB
import { isTraceableFunction, traceable, } from "../traceable.js"; import { convertAnthropicUsageToInputTokenDetails } from "../utils/usage.js"; const TRACED_INVOCATION_KEYS = ["top_k", "top_p", "stream", "thinking"]; /** * Create usage metadata from Anthropic's token usage format. */ export function createUsageMetadata(anthropicUsage) { if (!anthropicUsage) { return undefined; } const inputTokens = typeof anthropicUsage.input_tokens === "number" ? anthropicUsage.input_tokens : 0; const outputTokens = typeof anthropicUsage.output_tokens === "number" ? anthropicUsage.output_tokens : 0; const inputTokenDetails = convertAnthropicUsageToInputTokenDetails( // eslint-disable-next-line @typescript-eslint/no-explicit-any anthropicUsage); // Anthropic cache tokens are ADDITIVE (not subsets of input_tokens like OpenAI). // Sum them into input_tokens so the backend cost calculation is correct. const cacheTokenSum = Object.values(inputTokenDetails).reduce((sum, v) => sum + (v ?? 0), 0); const adjustedInputTokens = inputTokens + cacheTokenSum; const adjustedTotalTokens = adjustedInputTokens + outputTokens; return { input_tokens: adjustedInputTokens, output_tokens: outputTokens, total_tokens: adjustedTotalTokens, ...(Object.keys(inputTokenDetails).length > 0 && { input_token_details: inputTokenDetails, }), }; } /** * Process Anthropic message outputs */ function processMessageOutput(outputs) { const message = outputs; const result = { ...message }; delete result.type; if (message.usage) { result.usage_metadata = createUsageMetadata(message.usage); delete result.usage; } return result; } /** * Accumulate a single content block delta into the content array. */ function accumulateContentBlockDelta(content, event) { const block = content[event.index]; if (!block) return; if (block.type === "text" && event.delta.type === "text_delta") { block.text += event.delta.text; } else if (block.type === "tool_use" && event.delta.type === "input_json_delta") { // Accumulate JSON input for tool use const toolBlock = block; toolBlock._partial_json = (toolBlock._partial_json ?? "") + event.delta.partial_json; } } /** * Aggregate streaming chunks into a complete message response */ const messageAggregator = (chunks) => { if (!chunks || chunks.length === 0) { return { role: "assistant", content: [], }; } let message = { role: "assistant", content: [], model: "", stop_reason: null, stop_sequence: null, }; // Track usage let usage = { input_tokens: 0, output_tokens: 0, }; for (const chunk of chunks) { switch (chunk.type) { case "message_start": // Initialize message message = { id: chunk.message.id, role: chunk.message.role, content: [], model: chunk.message.model, stop_reason: chunk.message.stop_reason, stop_sequence: chunk.message.stop_sequence, }; // Capture initial usage if (chunk.message.usage) { usage = chunk.message.usage; } break; case "content_block_start": // Add new content block if (message.content) { message.content[chunk.index] = chunk.content_block; } break; case "content_block_delta": // Accumulate delta if (message.content) { accumulateContentBlockDelta(message.content, chunk); } break; case "content_block_stop": // Finalize content block if (message.content) { const block = message.content[chunk.index]; if (block?.type === "tool_use") { const toolBlock = block; if (toolBlock._partial_json) { try { toolBlock.input = JSON.parse(toolBlock._partial_json); } catch { // Keep partial JSON as-is if parsing fails toolBlock.input = toolBlock._partial_json; } delete toolBlock._partial_json; } } } break; case "message_delta": // Update message metadata message.stop_reason = chunk.delta.stop_reason; message.stop_sequence = chunk.delta.stop_sequence ?? null; if (chunk.usage) { // Override only non-null keys for (const [key, value] of Object.entries(chunk.usage)) { if (value != null) { usage[key] = value; } } } break; case "message_stop": // Message complete break; } } // Build final output const result = { ...message, }; delete result.type; // Add usage metadata result.usage_metadata = createUsageMetadata(usage); return result; }; /** * Wraps an Anthropic client's completion methods, enabling automatic LangSmith * tracing. Method signatures are unchanged, with the exception that you can pass * an additional and optional "langsmithExtra" field within the second parameter. * * @param anthropic An Anthropic client instance. * @param options LangSmith options. * @returns The wrapped client. * * @example * ```ts * import Anthropic from "@anthropic-ai/sdk"; * import { wrapAnthropic } from "langsmith/wrappers/anthropic"; * * const anthropic = wrapAnthropic(new Anthropic()); * * // Non-streaming * const message = await anthropic.messages.create({ * model: "claude-sonnet-4-20250514", * max_tokens: 1024, * messages: [{ role: "user", content: "Hello!" }], * }); * * // Streaming * const messageStream = anthropic.messages.stream({ * model: "claude-sonnet-4-20250514", * max_tokens: 1024, * messages: [{ role: "user", content: "Hello!" }], * }); * const finalMessage = await messageStream.finalMessage(); * ``` */ export const wrapAnthropic = (anthropic, options) => { if (isTraceableFunction(anthropic.messages.create) || isTraceableFunction(anthropic.messages.stream)) { throw new Error("This instance of Anthropic client has been already wrapped once."); } const tracedAnthropicClient = { ...anthropic }; // Extract ls_invocation_params from metadata const prepopulatedInvocationParams = typeof options?.metadata?.ls_invocation_params === "object" ? options.metadata.ls_invocation_params : {}; // Remove ls_invocation_params from metadata to avoid duplication const { ls_invocation_params, ...restMetadata } = options?.metadata ?? {}; const cleanedOptions = { ...options, metadata: restMetadata, }; /** * Transform system parameter into visible message for playground editability. * This provides parity with the Python SDK behavior and enables system prompts * to be viewed and edited in the LangSmith playground. */ function processSystemMessage(params) { if (!params.system) { return params; } const processed = { ...params }; // Handle both string and ContentBlock[] formats const systemContent = Array.isArray(params.system) ? params.system .map((block) => typeof block === "string" ? block : block.text) .join("\n") : params.system; // Transform into first message processed.messages = [ { role: "system", content: systemContent }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(params.messages || []), ]; delete processed.system; return processed; } // Common configuration for messages.create const messagesCreateConfig = { name: "ChatAnthropic", run_type: "llm", aggregator: messageAggregator, argsConfigPath: [1, "langsmithExtra"], processInputs: processSystemMessage, getInvocationParams: (payload) => { if (typeof payload !== "object" || payload == null) return undefined; const params = payload; const ls_stop = (typeof params.stop_sequences === "string" ? [params.stop_sequences] : params.stop_sequences) ?? undefined; const ls_invocation_params = {}; for (const [key, value] of Object.entries(params)) { if (TRACED_INVOCATION_KEYS.includes(key)) { ls_invocation_params[key] = value; } } return { ls_provider: "anthropic", ls_model_type: "chat", ls_model_name: params.model, ls_max_tokens: params.max_tokens ?? undefined, ls_temperature: params.temperature ?? undefined, ls_stop, ls_invocation_params: { ...prepopulatedInvocationParams, ...ls_invocation_params, }, }; }, processOutputs: processMessageOutput, ...cleanedOptions, }; // Create a new messages object preserving the prototype tracedAnthropicClient.messages = Object.create(Object.getPrototypeOf(anthropic.messages)); // Copy all own properties Object.assign(tracedAnthropicClient.messages, anthropic.messages); // Wrap messages.create tracedAnthropicClient.messages.create = traceable(anthropic.messages.create.bind(anthropic.messages), messagesCreateConfig); // Shared function to wrap stream methods // eslint-disable-next-line @typescript-eslint/no-explicit-any const wrapStreamMethod = (originalStreamFn) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (...args) { const stream = originalStreamFn(...args); if ("finalMessage" in stream && typeof stream.finalMessage === "function") { const originalFinalMessage = stream.finalMessage.bind(stream); // eslint-disable-next-line @typescript-eslint/no-explicit-any stream.finalMessage = async (...args) => { if ("done" in stream && typeof stream.done === "function") { await stream.done(); } for await (const _ of stream) { // Finish consuming the stream if it has not already been consumed // It should be relatively uncommon to consume an iterator after calling // .finalMessage() } return originalFinalMessage(...args); }; } return stream; }; }; // Wrap messages.stream tracedAnthropicClient.messages.stream = traceable(wrapStreamMethod(anthropic.messages.stream.bind(anthropic.messages)), { name: "ChatAnthropic", run_type: "llm", aggregator: messageAggregator, argsConfigPath: [1, "langsmithExtra"], processInputs: processSystemMessage, getInvocationParams: messagesCreateConfig.getInvocationParams, processOutputs: processMessageOutput, ...cleanedOptions, }); // Wrap beta.messages if it exists if (anthropic.beta && anthropic.beta.messages && typeof anthropic.beta.messages.create === "function") { // eslint-disable-next-line @typescript-eslint/no-explicit-any const tracedBeta = { ...anthropic.beta }; tracedBeta.messages = Object.create(Object.getPrototypeOf(anthropic.beta.messages)); Object.assign(tracedBeta.messages, anthropic.beta.messages); // Wrap beta.messages.create tracedBeta.messages.create = traceable(anthropic.beta.messages.create.bind(anthropic.beta.messages), messagesCreateConfig); // Wrap beta.messages.parse if it exists if (typeof anthropic.beta.messages.parse === "function") { tracedBeta.messages.parse = traceable(anthropic.beta.messages.parse.bind(anthropic.beta.messages), messagesCreateConfig); } // Wrap beta.messages.stream if it exists if (typeof anthropic.beta.messages.stream === "function") { tracedBeta.messages.stream = traceable(wrapStreamMethod(anthropic.beta.messages.stream.bind(anthropic.beta.messages)), { name: "ChatAnthropic", run_type: "llm", aggregator: messageAggregator, argsConfigPath: [1, "langsmithExtra"], processInputs: processSystemMessage, getInvocationParams: messagesCreateConfig.getInvocationParams, processOutputs: processMessageOutput, ...cleanedOptions, }); } tracedAnthropicClient.beta = tracedBeta; } return tracedAnthropicClient; };