From 6bf725ba54ec2e05d05b4a285ead37991bdcbe35 Mon Sep 17 00:00:00 2001 From: tghamm Date: Fri, 20 Feb 2026 05:15:39 -0500 Subject: [PATCH 1/3] mcp-tune-up --- Anthropic.SDK.Tests/Anthropic.SDK.Tests.csproj | 8 ++++---- Anthropic.SDK.Tests/MCPTests.cs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Anthropic.SDK.Tests/Anthropic.SDK.Tests.csproj b/Anthropic.SDK.Tests/Anthropic.SDK.Tests.csproj index 3920640..b05f7e9 100644 --- a/Anthropic.SDK.Tests/Anthropic.SDK.Tests.csproj +++ b/Anthropic.SDK.Tests/Anthropic.SDK.Tests.csproj @@ -34,12 +34,12 @@ - + - - - + + + diff --git a/Anthropic.SDK.Tests/MCPTests.cs b/Anthropic.SDK.Tests/MCPTests.cs index 4236e06..e693d64 100644 --- a/Anthropic.SDK.Tests/MCPTests.cs +++ b/Anthropic.SDK.Tests/MCPTests.cs @@ -18,15 +18,15 @@ public async Task TestMCP() var client = new AnthropicClient(); var parameters = new MessageParameters() { - Model = AnthropicModels.Claude37Sonnet, + Model = AnthropicModels.Claude46Sonnet, MaxTokens = 5000, Temperature = 1, MCPServers = new List() { new MCPServer() { - Url = "https://mcp.deepwiki.com/sse", - Name = "DeepWiki", + Url = "https://learn.microsoft.com/api/mcp", + Name = "MSFT", } } }; @@ -37,7 +37,7 @@ public async Task TestMCP() Role = RoleType.User, Content = new List { - new TextContent { Text = "Tell me about the repo tghamm/Anthropic.SDK" } + new TextContent { Text = "Tell me about the Latest Microsoft.Extensions.AI Library" } } } }; @@ -55,7 +55,7 @@ public async Task TestMCPExtendedStreaming() var client = new AnthropicClient(); var parameters = new MessageParameters() { - Model = AnthropicModels.Claude37Sonnet, + Model = AnthropicModels.Claude46Sonnet, MaxTokens = 3000, Temperature = 1, Stream = true, @@ -63,8 +63,8 @@ public async Task TestMCPExtendedStreaming() { new MCPServer() { - Url = "https://mcp.deepwiki.com/sse", - Name = "DeepWiki", + Url = "https://learn.microsoft.com/api/mcp", + Name = "MSFT", } } }; @@ -75,7 +75,7 @@ public async Task TestMCPExtendedStreaming() Role = RoleType.User, Content = new List { - new TextContent { Text = "Tell me about the repo tghamm/Anthropic.SDK" } + new TextContent { Text = "Tell me about the latest Microsoft.Extensions.AI Library" } } } }; From 6690410f3ec438d337dfc55db8c40ad00f85c554 Mon Sep 17 00:00:00 2001 From: tghamm Date: Fri, 20 Feb 2026 06:01:54 -0500 Subject: [PATCH 2/3] adds a costing mechanism #178 --- Anthropic.SDK.Tests/CostTest.cs | 38 ++++ .../Extensions/CostCalculationExtensions.cs | 162 ++++++++++++++++++ Anthropic.SDK/Messaging/MessageResponse.cs | 13 +- Anthropic.SDK/Messaging/ModelPricing.cs | 158 +++++++++++++++++ README.md | 51 ++++++ 5 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 Anthropic.SDK.Tests/CostTest.cs create mode 100644 Anthropic.SDK/Extensions/CostCalculationExtensions.cs create mode 100644 Anthropic.SDK/Messaging/ModelPricing.cs diff --git a/Anthropic.SDK.Tests/CostTest.cs b/Anthropic.SDK.Tests/CostTest.cs new file mode 100644 index 0000000..502b040 --- /dev/null +++ b/Anthropic.SDK.Tests/CostTest.cs @@ -0,0 +1,38 @@ +using Anthropic.SDK.Constants; +using Anthropic.SDK.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Anthropic.SDK.Extensions; + +namespace Anthropic.SDK.Tests +{ + [TestClass] + public class CostTest + { + + [TestMethod] + public async Task TestCostEstimation() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Messages = new List { new Message(RoleType.User, "Hello!") }, + MaxTokens = 1024, + Model = AnthropicModels.Claude46Sonnet, + }; + var response = await client.Messages.GetClaudeMessageAsync(parameters); + + // Get total estimated cost + var cost = response.CalculateCost(); + Console.WriteLine($"Total cost: ${cost.TotalCostUsd:F6}"); + Console.WriteLine($" Input tokens: ${cost.InputTokenCost:F6}"); + Console.WriteLine($" Output tokens: ${cost.OutputTokenCost:F6}"); + Console.WriteLine($" Cache read: ${cost.CacheReadCost:F6}"); + Console.WriteLine($" Cache creation: ${cost.CacheCreationCost:F6}"); + Console.WriteLine($" Web search: ${cost.WebSearchCost:F6}"); + } + } +} diff --git a/Anthropic.SDK/Extensions/CostCalculationExtensions.cs b/Anthropic.SDK/Extensions/CostCalculationExtensions.cs new file mode 100644 index 0000000..8f652f0 --- /dev/null +++ b/Anthropic.SDK/Extensions/CostCalculationExtensions.cs @@ -0,0 +1,162 @@ +using System; +using Anthropic.SDK.Messaging; + +namespace Anthropic.SDK.Extensions +{ + /// + /// Detailed breakdown of estimated costs for an API request. + /// All values are in USD. + /// + public class CostBreakdown + { + /// + /// Cost of base input tokens. + /// + public decimal InputTokenCost { get; set; } + + /// + /// Cost of output tokens. + /// + public decimal OutputTokenCost { get; set; } + + /// + /// Cost of cache read tokens. + /// + public decimal CacheReadCost { get; set; } + + /// + /// Cost of cache creation tokens (combined 5-minute and 1-hour). + /// When detailed cache creation breakdown is unavailable, the legacy + /// cache_creation_input_tokens field is priced at the 5-minute write rate. + /// + public decimal CacheCreationCost { get; set; } + + /// + /// Cost of web search requests ($0.01 per search). + /// + public decimal WebSearchCost { get; set; } + + /// + /// Total estimated cost in USD (sum of all components). + /// + public decimal TotalCostUsd => + InputTokenCost + OutputTokenCost + CacheReadCost + CacheCreationCost + WebSearchCost; + + /// + /// The used for this calculation. + /// + public ModelPricing Pricing { get; set; } + } + + /// + /// Extension methods for calculating estimated API costs from usage data. + /// + public static class CostCalculationExtensions + { + private const decimal PerMillionDivisor = 1_000_000m; + private const decimal Per1000Divisor = 1_000m; + + /// + /// Calculate the estimated cost of an API request from its data. + /// When the service tier is , a 50% discount is applied + /// to all token costs automatically. + /// + /// The usage data from the API response. + /// The model ID string used for the request. + /// + /// Optional pricing to use instead of the built-in/registered pricing. + /// + /// A with per-category costs. + /// Thrown when is null. + /// + /// Thrown when no pricing can be found for + /// and is not provided. + /// + public static CostBreakdown CalculateCost( + this Usage usage, + string modelId, + ModelPricing overridePricing = null) + { + if (usage == null) + throw new ArgumentNullException(nameof(usage)); + + var pricing = overridePricing ?? ModelPricing.ForModel(modelId); + if (pricing == null) + { + throw new InvalidOperationException( + $"No pricing found for model '{modelId}'. " + + "Use ModelPricing.Register() to add pricing, or pass overridePricing."); + } + + decimal batchMultiplier = usage.ServiceTier == ServiceTier.Batch ? 0.5m : 1m; + + decimal inputCost = usage.InputTokens / PerMillionDivisor + * pricing.InputTokenCostPerMillion * batchMultiplier; + + decimal outputCost = usage.OutputTokens / PerMillionDivisor + * pricing.OutputTokenCostPerMillion * batchMultiplier; + + decimal cacheReadCost = usage.CacheReadInputTokens / PerMillionDivisor + * pricing.CacheReadCostPerMillion * batchMultiplier; + + decimal cacheCreationCost = 0m; + + if (usage.CacheCreation != null) + { + int tokens5m = usage.CacheCreation.Ephemeral5mInputTokens ?? 0; + int tokens1h = usage.CacheCreation.Ephemeral1hInputTokens ?? 0; + + cacheCreationCost = + (tokens5m / PerMillionDivisor * pricing.Cache5mWriteCostPerMillion * batchMultiplier) + + (tokens1h / PerMillionDivisor * pricing.Cache1hWriteCostPerMillion * batchMultiplier); + } + + if (usage.CacheCreationInputTokens > 0 && cacheCreationCost == 0m) + { + cacheCreationCost = usage.CacheCreationInputTokens / PerMillionDivisor + * pricing.Cache5mWriteCostPerMillion * batchMultiplier; + } + + decimal webSearchCost = 0m; + if (usage.ServerToolUse?.WebSearchRequests is > 0) + { + webSearchCost = usage.ServerToolUse.WebSearchRequests.Value / Per1000Divisor + * pricing.WebSearchCostPer1000; + } + + return new CostBreakdown + { + InputTokenCost = inputCost, + OutputTokenCost = outputCost, + CacheReadCost = cacheReadCost, + CacheCreationCost = cacheCreationCost, + WebSearchCost = webSearchCost, + Pricing = pricing, + }; + } + + /// + /// Calculate the estimated cost of an API request directly from the . + /// Uses the model from the response and its usage data. + /// + /// The message response from the API. + /// + /// Optional pricing to use instead of the built-in/registered pricing. + /// + /// A with per-category costs. + /// + /// Thrown when or its Usage is null. + /// + public static CostBreakdown CalculateCost( + this MessageResponse response, + ModelPricing overridePricing = null) + { + if (response == null) + throw new ArgumentNullException(nameof(response)); + if (response.Usage == null) + throw new ArgumentNullException(nameof(response), "Response.Usage is null."); + + return response.Usage.CalculateCost(response.Model, overridePricing); + } + } +} diff --git a/Anthropic.SDK/Messaging/MessageResponse.cs b/Anthropic.SDK/Messaging/MessageResponse.cs index ba9c062..224b501 100644 --- a/Anthropic.SDK/Messaging/MessageResponse.cs +++ b/Anthropic.SDK/Messaging/MessageResponse.cs @@ -1,4 +1,4 @@ -using System; +using System; using Anthropic.SDK.Common; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -57,6 +57,8 @@ public class MessageResponse [JsonPropertyName("container")] public ContainerResponse Container { get; set; } + + } public class StreamMessage @@ -182,6 +184,9 @@ public class Usage [JsonPropertyName("service_tier")] [JsonConverter(typeof(ServiceTierConverter))] public ServiceTier ServiceTier { get; set; } + + [JsonPropertyName("inference_geo")] + public string InferenceGeo { get; set; } } public class CacheCreation @@ -197,5 +202,11 @@ public class ServerToolUse { [JsonPropertyName("web_search_requests")] public int? WebSearchRequests { get; set; } + + [JsonPropertyName("code_execution_requests")] + public int? CodeExecutionRequests { get; set; } + + [JsonPropertyName("web_fetch_requests")] + public int? WebFetchRequests { get; set; } } } diff --git a/Anthropic.SDK/Messaging/ModelPricing.cs b/Anthropic.SDK/Messaging/ModelPricing.cs new file mode 100644 index 0000000..959de40 --- /dev/null +++ b/Anthropic.SDK/Messaging/ModelPricing.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Anthropic.SDK.Messaging +{ + /// + /// Represents per-model pricing data for estimating API request costs. + /// All costs are in USD per million tokens unless otherwise noted. + /// + public class ModelPricing + { + /// + /// Cost per million input tokens. + /// + public decimal InputTokenCostPerMillion { get; } + + /// + /// Cost per million output tokens. + /// + public decimal OutputTokenCostPerMillion { get; } + + /// + /// Cost per million cache read tokens (typically 0.1x input cost). + /// + public decimal CacheReadCostPerMillion { get; } + + /// + /// Cost per million 5-minute cache write tokens (typically 1.25x input cost). + /// + public decimal Cache5mWriteCostPerMillion { get; } + + /// + /// Cost per million 1-hour cache write tokens (typically 2x input cost). + /// + public decimal Cache1hWriteCostPerMillion { get; } + + /// + /// Cost per 1,000 web search requests. Default is $10 per 1,000 searches. + /// + public decimal WebSearchCostPer1000 { get; } + + public ModelPricing( + decimal inputTokenCostPerMillion, + decimal outputTokenCostPerMillion, + decimal? cacheReadCostPerMillion = null, + decimal? cache5mWriteCostPerMillion = null, + decimal? cache1hWriteCostPerMillion = null, + decimal? webSearchCostPer1000 = null) + { + InputTokenCostPerMillion = inputTokenCostPerMillion; + OutputTokenCostPerMillion = outputTokenCostPerMillion; + CacheReadCostPerMillion = cacheReadCostPerMillion ?? inputTokenCostPerMillion * 0.1m; + Cache5mWriteCostPerMillion = cache5mWriteCostPerMillion ?? inputTokenCostPerMillion * 1.25m; + Cache1hWriteCostPerMillion = cache1hWriteCostPerMillion ?? inputTokenCostPerMillion * 2m; + WebSearchCostPer1000 = webSearchCostPer1000 ?? 10m; + } + + private static readonly ConcurrentDictionary CustomPricing = new(); + + // Ordered longest-prefix-first so that more specific entries match before shorter ones. + private static readonly List<(string Prefix, ModelPricing Pricing)> BuiltInPricing = new() + { + // Opus 4.6 / 4.5 — $5 input, $25 output + ("claude-opus-4-6", new ModelPricing(5m, 25m)), + ("claude-opus-4-5", new ModelPricing(5m, 25m)), + + // Opus 4.1 — $15 input, $75 output + ("claude-opus-4-1", new ModelPricing(15m, 75m)), + + // Opus 4 — $15 input, $75 output + ("claude-opus-4", new ModelPricing(15m, 75m)), + + // Sonnet 4.6 — $3 input, $15 output + ("claude-sonnet-4-6", new ModelPricing(3m, 15m)), + + // Sonnet 4.5 — $3 input, $15 output + ("claude-sonnet-4-5", new ModelPricing(3m, 15m)), + + // Sonnet 4 — $3 input, $15 output + ("claude-sonnet-4", new ModelPricing(3m, 15m)), + + // Sonnet 3.7 — $3 input, $15 output + ("claude-3-7-sonnet", new ModelPricing(3m, 15m)), + + // Haiku 4.5 — $1 input, $5 output + ("claude-haiku-4-5", new ModelPricing(1m, 5m)), + + // Haiku 3.5 — $0.80 input, $4 output + ("claude-3-5-haiku", new ModelPricing(0.80m, 4m)), + }; + + /// + /// Register or override pricing for a model ID prefix. + /// Custom registrations take priority over built-in pricing. + /// + /// The model ID or prefix to match (e.g. "claude-sonnet-4-6"). + /// The pricing to use for matching models. + public static void Register(string modelIdPrefix, ModelPricing pricing) + { + if (string.IsNullOrWhiteSpace(modelIdPrefix)) + throw new ArgumentException("Model ID prefix cannot be null or empty.", nameof(modelIdPrefix)); + if (pricing == null) + throw new ArgumentNullException(nameof(pricing)); + + CustomPricing[modelIdPrefix] = pricing; + } + + /// + /// Remove a previously registered custom pricing entry. + /// + public static bool Unregister(string modelIdPrefix) + { + return CustomPricing.TryRemove(modelIdPrefix, out _); + } + + /// + /// Clear all custom pricing registrations, reverting to built-in pricing only. + /// + public static void ClearCustomPricing() + { + CustomPricing.Clear(); + } + + /// + /// Look up pricing for a given model ID. Custom registrations are checked first + /// (longest prefix match), then built-in pricing. + /// + /// The matching , or null if no match is found. + public static ModelPricing ForModel(string modelId) + { + if (string.IsNullOrWhiteSpace(modelId)) + return null; + + // Check custom registrations first (longest prefix match) + if (!CustomPricing.IsEmpty) + { + var customMatch = CustomPricing.Keys + .Where(prefix => modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(prefix => prefix.Length) + .FirstOrDefault(); + + if (customMatch != null) + return CustomPricing[customMatch]; + } + + // Fall back to built-in pricing (already ordered longest-first) + foreach (var (prefix, pricing) in BuiltInPricing) + { + if (modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return pricing; + } + + return null; + } + } +} diff --git a/README.md b/README.md index 2166806..32cab5a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Note: This package is not affiliated with, endorsed by, or sponsored by Anthropi - [MCP Connector](#mcp-connector) - [MCP Client Integration](#mcp-client-integration) - [Web Search](#web-search) + - [Cost Calculation](#cost-calculation) - [List Models](#list-models) - [Batching](#batching) - [Tools](#tools) @@ -810,6 +811,56 @@ Console.WriteLine(res.Content.OfType().Last().Text); ``` See integration tests for more examples like streaming. +### Cost Calculation + +The SDK includes a client-side cost estimation utility that calculates the estimated USD cost of a request from its token usage data and built-in model pricing. Pricing is sourced from the [Anthropic pricing page](https://docs.anthropic.com/en/docs/about-claude/pricing) and batch-tier discounts are applied automatically. + +```csharp +using Anthropic.SDK.Extensions; + +var client = new AnthropicClient(); +var parameters = new MessageParameters() +{ + Messages = new List { new Message(RoleType.User, "Hello!") }, + MaxTokens = 1024, + Model = AnthropicModels.Claude46Sonnet, +}; +var response = await client.Messages.GetClaudeMessageAsync(parameters); + +// Calculate estimated cost directly from the response +var cost = response.CalculateCost(); +Console.WriteLine($"Total cost: ${cost.TotalCostUsd:F6}"); +Console.WriteLine($" Input tokens: ${cost.InputTokenCost:F6}"); +Console.WriteLine($" Output tokens: ${cost.OutputTokenCost:F6}"); +Console.WriteLine($" Cache read: ${cost.CacheReadCost:F6}"); +Console.WriteLine($" Cache creation: ${cost.CacheCreationCost:F6}"); +Console.WriteLine($" Web search: ${cost.WebSearchCost:F6}"); + +// Or calculate from a Usage object directly with a model ID +var breakdown = response.Usage.CalculateCost(AnthropicModels.Claude46Sonnet); +``` + +**Custom / Enterprise Pricing** + +If you have negotiated pricing or need to add support for a model not yet in the built-in table, you can register custom pricing at runtime: + +```csharp +using Anthropic.SDK.Messaging; + +// Register custom pricing for a model prefix +ModelPricing.Register("claude-sonnet-4-6", new ModelPricing( + inputTokenCostPerMillion: 2.5m, + outputTokenCostPerMillion: 12.5m)); + +// Or pass override pricing directly +var customPricing = new ModelPricing( + inputTokenCostPerMillion: 4m, + outputTokenCostPerMillion: 20m); +var cost = response.CalculateCost(overridePricing: customPricing); + +// Clear all custom registrations to revert to built-in pricing +ModelPricing.ClearCustomPricing(); +``` ### List Models From 2dca08bd1bc21ae34c1dc349626c1b433a15f4b1 Mon Sep 17 00:00:00 2001 From: tghamm Date: Fri, 20 Feb 2026 06:28:38 -0500 Subject: [PATCH 3/3] support for enhanced web search + web fetch #173 --- Anthropic.SDK.Tests/WebSearchFunctionality.cs | 240 +++++++++++++++++- .../Extensions/ChatOptionsExtensions.cs | 63 +++++ Anthropic.SDK/Extensions/ContentConverter.cs | 8 +- Anthropic.SDK/Messaging/ChatClientHelper.cs | 43 +++- Anthropic.SDK/Messaging/Content.cs | 71 +++++- Anthropic.SDK/Messaging/ContentType.cs | 8 +- Anthropic.SDK/Messaging/Message.cs | 16 +- Anthropic.SDK/Messaging/ServerTools.cs | 72 +++++- README.md | 91 ++++++- 9 files changed, 596 insertions(+), 16 deletions(-) diff --git a/Anthropic.SDK.Tests/WebSearchFunctionality.cs b/Anthropic.SDK.Tests/WebSearchFunctionality.cs index e767742..015afe8 100644 --- a/Anthropic.SDK.Tests/WebSearchFunctionality.cs +++ b/Anthropic.SDK.Tests/WebSearchFunctionality.cs @@ -1,12 +1,15 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; using Anthropic.SDK.Constants; +using Anthropic.SDK.Extensions; using Anthropic.SDK.Messaging; +using Microsoft.Extensions.AI; using Tool = Anthropic.SDK.Common.Tool; +using TextContent = Anthropic.SDK.Messaging.TextContent; namespace Anthropic.SDK.Tests { @@ -158,5 +161,240 @@ public async Task TestWebSearchExtendedStreaming() Console.WriteLine("Final Result:"); Console.WriteLine(textResult.Last().Text); } + + [TestMethod] + public async Task TestWebSearchDynamicFiltering() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, + Temperature = 1, + Tools = new List() + { + ServerTools.GetWebSearchTool() + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, + }; + var messages = new List + { + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "Search for the current population of Tokyo and give me the number." } + } + } + }; + parameters.Messages = messages; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + + Assert.IsNotNull(res); + Assert.IsTrue(res.Content.OfType().Any()); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("Dynamic Filtering Result:"); + Console.WriteLine(res.Content.OfType().Last().Text); + } + + [TestMethod] + public async Task TestWebFetch() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, + Temperature = 1, + Tools = new List() + { + ServerTools.GetWebFetchTool(maxUses: 5, enableCitations: true, + toolVersion: ServerTools.WebFetchVersionLegacy) + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, + }; + var messages = new List + { + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "Fetch the content at https://www.wikipedia.org and tell me what it says." } + } + } + }; + parameters.Messages = messages; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + + Assert.IsNotNull(res); + Assert.IsTrue(res.Content.OfType().Any()); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("Web Fetch Result:"); + Console.WriteLine(res.Content.OfType().Last().Text); + } + + [TestMethod] + public async Task TestWebFetchStreaming() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, + Temperature = 1, + Stream = true, + Tools = new List() + { + ServerTools.GetWebFetchTool(maxUses: 5, + toolVersion: ServerTools.WebFetchVersionLegacy) + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, + }; + var messages = new List + { + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "Fetch the content at https://example.com and summarize it." } + } + } + }; + parameters.Messages = messages; + var outputs = new List(); + await foreach (var res in client.Messages.StreamClaudeMessageAsync(parameters)) + { + if (res.Delta != null) + { + Debug.Write(res.Delta.Text); + } + + outputs.Add(res); + } + + var message = new Message(outputs); + Assert.IsNotNull(message); + Assert.IsTrue(message.Content.OfType().Any()); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("Web Fetch Streaming Result:"); + Console.WriteLine(message.Content.OfType().Last().Text); + } + + [TestMethod] + public async Task TestWebSearchWithLegacyVersion() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 3000, + Temperature = 1, + Tools = new List() + { + ServerTools.GetWebSearchTool(toolVersion: ServerTools.WebSearchVersionLegacy) + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, + }; + var messages = new List + { + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "What is the weather like in San Francisco right now?" } + } + } + }; + parameters.Messages = messages; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + + Assert.IsNotNull(res); + Assert.IsTrue(res.Content.OfType().Any()); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("Legacy Web Search Result:"); + Console.WriteLine(res.Content.OfType().Last().Text); + } + + [TestMethod] + public async Task TestNonStreamingWebFetchChatClient() + { + IChatClient client = new AnthropicClient().Messages + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + ChatOptions options = new ChatOptions() + { + ModelId = AnthropicModels.Claude46Sonnet, + MaxOutputTokens = 4096, + }.WithWebFetch(maxUses: 5, enableCitations: true); + + var res = await client.GetResponseAsync( + "Fetch the content at https://example.com and tell me what it says.", options); + + Assert.IsNotNull(res); + Assert.IsTrue(!string.IsNullOrEmpty(res.Text)); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("IChatClient Web Fetch Result:"); + Console.WriteLine(res.Text); + } + + [TestMethod] + public async Task TestCombinedWebSearchAndFetch() + { + var client = new AnthropicClient(); + var parameters = new MessageParameters() + { + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, + Temperature = 1, + Tools = new List() + { + ServerTools.GetWebSearchTool(maxUses: 3, + toolVersion: ServerTools.WebSearchVersionLegacy), + ServerTools.GetWebFetchTool(maxUses: 5, enableCitations: true, + toolVersion: ServerTools.WebFetchVersionLegacy) + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, + }; + var messages = new List + { + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "Search for the Anthropic homepage and then fetch its content to summarize it." } + } + } + }; + parameters.Messages = messages; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + + Assert.IsNotNull(res); + Assert.IsTrue(res.Content.OfType().Any()); + Console.WriteLine("----------------------------------------------"); + Console.WriteLine("Combined Search + Fetch Result:"); + Console.WriteLine(res.Content.OfType().Last().Text); + } } } diff --git a/Anthropic.SDK/Extensions/ChatOptionsExtensions.cs b/Anthropic.SDK/Extensions/ChatOptionsExtensions.cs index f0dd699..269e57f 100644 --- a/Anthropic.SDK/Extensions/ChatOptionsExtensions.cs +++ b/Anthropic.SDK/Extensions/ChatOptionsExtensions.cs @@ -1,9 +1,22 @@ using System; +using System.Collections.Generic; using Microsoft.Extensions.AI; using Anthropic.SDK.Messaging; namespace Anthropic.SDK.Extensions { + /// + /// Configuration for the web fetch tool when used via IChatClient. + /// + public class WebFetchConfiguration + { + public int? MaxUses { get; set; } + public List AllowedDomains { get; set; } + public List BlockedDomains { get; set; } + public bool EnableCitations { get; set; } + public int? MaxContentTokens { get; set; } + } + /// /// Extensions for ChatOptions to support Anthropic-specific features /// @@ -11,6 +24,7 @@ public static class ChatOptionsExtensions { private const string ThinkingParametersKey = "Anthropic.ThinkingParameters"; private const string StrictToolsKey = "Anthropic.StrictTools"; + private const string WebFetchKey = "Anthropic.WebFetch"; /// /// Sets thinking parameters for extended thinking support in compatible models like Claude 3.7 Sonnet @@ -187,5 +201,54 @@ public static bool GetStrictToolsEnabled(this ChatOptions options) return false; } + + /// + /// Enables the web fetch tool for IChatClient requests. Since Microsoft.Extensions.AI does not + /// have a built-in HostedWebFetchTool, this extension method provides an Anthropic-specific way + /// to enable web fetch capability. + /// + /// The ChatOptions instance + /// Optional limit on the number of fetches per request + /// Optional list of domains to allow fetching from + /// Optional list of domains to block fetching from + /// Whether to enable citations for fetched content + /// Optional maximum content length in tokens + /// The ChatOptions instance for fluent chaining + public static ChatOptions WithWebFetch(this ChatOptions options, + int? maxUses = null, + List allowedDomains = null, + List blockedDomains = null, + bool enableCitations = false, + int? maxContentTokens = null) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + (options.AdditionalProperties ??= new())[WebFetchKey] = new WebFetchConfiguration + { + MaxUses = maxUses, + AllowedDomains = allowedDomains, + BlockedDomains = blockedDomains, + EnableCitations = enableCitations, + MaxContentTokens = maxContentTokens + }; + + return options; + } + + /// + /// Gets the web fetch configuration from ChatOptions, if set. + /// + /// The ChatOptions instance + /// The web fetch configuration, or null if not set + public static WebFetchConfiguration GetWebFetchConfiguration(this ChatOptions options) + { + if (options?.AdditionalProperties?.TryGetValue(WebFetchKey, out var value) == true) + { + return value as WebFetchConfiguration; + } + + return null; + } } } \ No newline at end of file diff --git a/Anthropic.SDK/Extensions/ContentConverter.cs b/Anthropic.SDK/Extensions/ContentConverter.cs index 7e659cd..ddabb41 100644 --- a/Anthropic.SDK/Extensions/ContentConverter.cs +++ b/Anthropic.SDK/Extensions/ContentConverter.cs @@ -1,4 +1,4 @@ -using Anthropic.SDK.Messaging; +using Anthropic.SDK.Messaging; using System; using System.Text.Json.Serialization; using System.Text.Json; @@ -44,6 +44,12 @@ public override ContentBase Read(ref Utf8JsonReader reader, Type typeToConvert, return JsonSerializer.Deserialize(root.GetRawText(), options); case "web_search_tool_result_error": return JsonSerializer.Deserialize(root.GetRawText(), options); + case "web_fetch_tool_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "web_fetch_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "web_fetch_tool_error": + return JsonSerializer.Deserialize(root.GetRawText(), options); case "mcp_tool_use": return JsonSerializer.Deserialize(root.GetRawText(), options); case "mcp_tool_result": diff --git a/Anthropic.SDK/Messaging/ChatClientHelper.cs b/Anthropic.SDK/Messaging/ChatClientHelper.cs index 09f980f..79d077e 100644 --- a/Anthropic.SDK/Messaging/ChatClientHelper.cs +++ b/Anthropic.SDK/Messaging/ChatClientHelper.cs @@ -112,7 +112,7 @@ public static MessageParameters CreateMessageParameters(IChatClient client, IEnu tools.Add(ServerTools.GetCodeExecutionTool()); break; - case HostedWebSearchTool: + case HostedWebSearchTool hwst: tools.Add(ServerTools.GetWebSearchTool(5)); break; @@ -138,6 +138,19 @@ public static MessageParameters CreateMessageParameters(IChatClient client, IEnu } } + // Map web fetch configuration from ChatOptions extension + var webFetchConfig = options.GetWebFetchConfiguration(); + if (webFetchConfig != null) + { + IList tools = parameters.Tools ??= []; + tools.Add(ServerTools.GetWebFetchTool( + maxUses: webFetchConfig.MaxUses, + allowedDomains: webFetchConfig.AllowedDomains, + blockedDomains: webFetchConfig.BlockedDomains, + enableCitations: webFetchConfig.EnableCitations, + maxContentTokens: webFetchConfig.MaxContentTokens)); + } + // Map thinking parameters from ChatOptions var thinkingParameters = options.GetThinkingParameters(); if (thinkingParameters != null) @@ -375,6 +388,34 @@ public static List ProcessResponseContent(MessageResponse response) trc.ToolUseId, trc.Content)); break; + + case WebSearchToolResultContent wsResult: + var wsText = new Microsoft.Extensions.AI.TextContent($"[Web Search Results: {wsResult.Content?.Count ?? 0} results]") + { + RawRepresentation = wsResult + }; + if (wsResult.Content != null) + { + wsText.Annotations ??= new List(); + foreach (var item in wsResult.Content.OfType()) + { + wsText.Annotations.Add(new CitationAnnotation + { + RawRepresentation = item, + FileId = item.Title + }); + } + } + contents.Add(wsText); + break; + + case WebFetchToolResultContent wfResult: + var wfText = new Microsoft.Extensions.AI.TextContent("[Web Fetch Result]") + { + RawRepresentation = wfResult + }; + contents.Add(wfText); + break; } } diff --git a/Anthropic.SDK/Messaging/Content.cs b/Anthropic.SDK/Messaging/Content.cs index 59ca596..5d8efc0 100644 --- a/Anthropic.SDK/Messaging/Content.cs +++ b/Anthropic.SDK/Messaging/Content.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Text.Json.Nodes; @@ -85,6 +85,12 @@ public class ServerToolInput /// [JsonPropertyName("file_text")] public string FileText { get; set; } + + /// + /// URL for web_fetch tool + /// + [JsonPropertyName("url")] + public string Url { get; set; } } @@ -851,6 +857,69 @@ public class TextEditorCodeExecutionStrReplaceResultContent : ContentBase public int? OldStart { get; set; } } + /// + /// Web Fetch Tool Result Content Returned From Claude + /// + public class WebFetchToolResultContent : ContentBase + { + /// + /// Type of Content (web_fetch_tool_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.web_fetch_tool_result; + + /// + /// Tool Use Id + /// + [JsonPropertyName("tool_use_id")] + public string ToolUseId { get; set; } + + /// + /// Content of the Tool Result + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + } + + /// + /// Web Fetch Result Content containing the fetched document + /// + public class WebFetchResultContent : ContentBase + { + /// + /// Type of Content (web_fetch_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.web_fetch_result; + + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("retrieved_at")] + public string RetrievedAt { get; set; } + } + + /// + /// Web Fetch Tool Error Content + /// + public class WebFetchToolErrorContent : ContentBase + { + /// + /// Type of Content (web_fetch_tool_error, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.web_fetch_tool_error; + + [JsonPropertyName("error_code")] + public string ErrorCode { get; set; } + } + /// /// Unknown content type - used as fallback for forward compatibility when the API returns new content types /// diff --git a/Anthropic.SDK/Messaging/ContentType.cs b/Anthropic.SDK/Messaging/ContentType.cs index 7e60ae2..abe464c 100644 --- a/Anthropic.SDK/Messaging/ContentType.cs +++ b/Anthropic.SDK/Messaging/ContentType.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.Serialization; using System.Text; @@ -35,6 +35,12 @@ public enum ContentType web_search_tool_result_error, + web_fetch_tool_result, + + web_fetch_result, + + web_fetch_tool_error, + mcp_tool_use, mcp_tool_result, diff --git a/Anthropic.SDK/Messaging/Message.cs b/Anthropic.SDK/Messaging/Message.cs index 8a1584b..4b317c1 100644 --- a/Anthropic.SDK/Messaging/Message.cs +++ b/Anthropic.SDK/Messaging/Message.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -247,7 +247,19 @@ public Message(List asyncResponses) } - + foreach (var result in asyncResponses) + { + if (result.ContentBlock != null && result.ContentBlock.Type == "web_fetch_tool_result") + { + var webFetchContent = new WebFetchToolResultContent() + { + ToolUseId = result.ContentBlock.ToolUseId, + Content = result.ContentBlock.Content?.FirstOrDefault() + }; + Content.Add(webFetchContent); + } + } + BashCodeExecutionToolResultContent bashCodeExecutionToolResultContent = null; foreach (var result in asyncResponses) { diff --git a/Anthropic.SDK/Messaging/ServerTools.cs b/Anthropic.SDK/Messaging/ServerTools.cs index 7968ad8..68d2743 100644 --- a/Anthropic.SDK/Messaging/ServerTools.cs +++ b/Anthropic.SDK/Messaging/ServerTools.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Text.Json.Serialization; @@ -8,8 +8,34 @@ namespace Anthropic.SDK.Messaging { public class ServerTools { + /// + /// Default web search tool version with dynamic filtering support (requires code execution tool). + /// + public const string WebSearchVersionDynamicFiltering = "web_search_20260209"; + + /// + /// Legacy web search tool version without dynamic filtering. + /// + public const string WebSearchVersionLegacy = "web_search_20250305"; + + /// + /// Default web fetch tool version with dynamic filtering support (requires code execution tool). + /// + public const string WebFetchVersionDynamicFiltering = "web_fetch_20260209"; + + /// + /// Legacy web fetch tool version without dynamic filtering. + /// + public const string WebFetchVersionLegacy = "web_fetch_20250910"; + + /// + /// Creates a web search tool configuration. + /// The default version (web_search_20260209) supports dynamic filtering with Claude Opus 4.6 and Sonnet 4.6, + /// which requires the code execution tool to be enabled. Pass for the older version. + /// public static Common.Tool GetWebSearchTool(int maxUses = 5, List allowedDomains = null, - List blockedDomains = null, UserLocation userLocation = null) + List blockedDomains = null, UserLocation userLocation = null, + string toolVersion = null) { var dict = new Dictionary(); dict.Add("max_uses", maxUses); @@ -26,8 +52,46 @@ public static Common.Tool GetWebSearchTool(int maxUses = 5, List allowed { dict.Add("user_location", userLocation); } - - return new Function("web_search", "web_search_20250305", dict); + + return new Function("web_search", toolVersion ?? WebSearchVersionDynamicFiltering, dict); + } + + /// + /// Creates a web fetch tool configuration. + /// The default version (web_fetch_20260209) supports dynamic filtering with Claude Opus 4.6 and Sonnet 4.6, + /// which requires the code execution tool to be enabled. Pass for the older version. + /// + public static Common.Tool GetWebFetchTool(int? maxUses = null, + List allowedDomains = null, + List blockedDomains = null, + bool enableCitations = false, + int? maxContentTokens = null, + string toolVersion = null) + { + var dict = new Dictionary(); + + if (maxUses.HasValue) + { + dict.Add("max_uses", maxUses.Value); + } + if (allowedDomains != null && allowedDomains.Count > 0) + { + dict.Add("allowed_domains", allowedDomains); + } + if (blockedDomains != null && blockedDomains.Count > 0) + { + dict.Add("blocked_domains", blockedDomains); + } + if (enableCitations) + { + dict.Add("citations", new Dictionary { { "enabled", true } }); + } + if (maxContentTokens.HasValue) + { + dict.Add("max_content_tokens", maxContentTokens.Value); + } + + return new Function("web_fetch", toolVersion ?? WebFetchVersionDynamicFiltering, dict); } public static Common.Tool GetCodeExecutionTool() diff --git a/README.md b/README.md index 32cab5a..e45dec9 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Note: This package is not affiliated with, endorsed by, or sponsored by Anthropi - [MCP Connector](#mcp-connector) - [MCP Client Integration](#mcp-client-integration) - [Web Search](#web-search) + - [Web Fetch](#web-fetch) - [Cost Calculation](#cost-calculation) - [List Models](#list-models) - [Batching](#batching) @@ -762,15 +763,16 @@ See the `McpClientTests.cs` in the test project for comprehensive examples. ### Web Search -The `AnthropicClient` supports the Web Search API. -Please note that citations are automatically turned on when Web search is used and you are responsible for displaying links to sources in a production setting per Anthropic documentation. +The `AnthropicClient` supports the Web Search API. The default tool version (`web_search_20260209`) supports **dynamic filtering** with Claude Opus 4.6 and Sonnet 4.6, which allows Claude to write and execute code to filter search results before they reach the context window. Dynamic filtering requires the code execution tool to be enabled alongside web search. + +Please note that citations are automatically turned on when web search is used and you are responsible for displaying links to sources in a production setting per Anthropic documentation. ```csharp var client = new AnthropicClient(); var parameters = new MessageParameters() { - Model = AnthropicModels.Claude37Sonnet, - MaxTokens = 3000, + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, Temperature = 1, Tools = new List() { @@ -780,7 +782,8 @@ var parameters = new MessageParameters() Region = "California", Country = "US", Timezone = "America/Los_Angeles" - }) + }), + ServerTools.GetCodeExecutionTool() // Required for dynamic filtering }, ToolChoice = new ToolChoice() { @@ -809,8 +812,86 @@ Console.WriteLine("----------------------------------------------"); Console.WriteLine("Final Result:"); Console.WriteLine(res.Content.OfType().Last().Text); ``` + +To use the older web search version without dynamic filtering: + +```csharp +ServerTools.GetWebSearchTool(toolVersion: ServerTools.WebSearchVersionLegacy) +``` + See integration tests for more examples like streaming. +### Web Fetch + +The `AnthropicClient` supports the Web Fetch API, which allows Claude to retrieve full content from specified web pages and PDF documents. Like web search, the default version (`web_fetch_20260209`) supports dynamic filtering with Claude Opus 4.6 and Sonnet 4.6. Use `ServerTools.WebFetchVersionLegacy` for the version without dynamic filtering. + +```csharp +var client = new AnthropicClient(); +var parameters = new MessageParameters() +{ + Model = AnthropicModels.Claude46Sonnet, + MaxTokens = 4096, + Temperature = 1, + Tools = new List() + { + ServerTools.GetWebFetchTool(maxUses: 5, enableCitations: true, + toolVersion: ServerTools.WebFetchVersionLegacy) + }, + ToolChoice = new ToolChoice() + { + Type = ToolChoiceType.Auto + }, +}; +var messages = new List +{ + new Message + { + Role = RoleType.User, + Content = new List + { + new TextContent { Text = "Fetch the content at https://example.com and tell me what it says." } + } + } +}; +parameters.Messages = messages; +var res = await client.Messages.GetClaudeMessageAsync(parameters); +Console.WriteLine(res.Content.OfType().Last().Text); +``` + +**Combined Web Search + Web Fetch:** + +```csharp +var tools = new List() +{ + ServerTools.GetWebSearchTool(maxUses: 3), + ServerTools.GetWebFetchTool(maxUses: 5, enableCitations: true) +}; +``` + +**IChatClient Usage:** + +Since `Microsoft.Extensions.AI` does not have a built-in `HostedWebFetchTool`, the SDK provides the `WithWebFetch()` extension method on `ChatOptions`: + +```csharp +using Anthropic.SDK.Extensions; + +IChatClient client = new AnthropicClient().Messages + .AsBuilder() + .UseFunctionInvocation() + .Build(); + +ChatOptions options = new ChatOptions() +{ + ModelId = AnthropicModels.Claude46Sonnet, + MaxOutputTokens = 4096, +}.WithWebFetch(maxUses: 5, enableCitations: true); + +var res = await client.GetResponseAsync("Fetch https://example.com and summarize it.", options); +Console.WriteLine(res.Text); +``` + +See integration tests for more examples including streaming. + ### Cost Calculation The SDK includes a client-side cost estimation utility that calculates the estimated USD cost of a request from its token usage data and built-in model pricing. Pricing is sourced from the [Anthropic pricing page](https://docs.anthropic.com/en/docs/about-claude/pricing) and batch-tier discounts are applied automatically.