Skip to content

Commit 315aae9

Browse files
authored
Merge pull request #187 from tghamm/feature/total_cost_usd
Feature/total cost usd
2 parents 70cbef8 + 6690410 commit 315aae9

5 files changed

Lines changed: 421 additions & 1 deletion

File tree

Anthropic.SDK.Tests/CostTest.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Anthropic.SDK.Constants;
2+
using Anthropic.SDK.Messaging;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text;
7+
using System.Threading.Tasks;
8+
using Anthropic.SDK.Extensions;
9+
10+
namespace Anthropic.SDK.Tests
11+
{
12+
[TestClass]
13+
public class CostTest
14+
{
15+
16+
[TestMethod]
17+
public async Task TestCostEstimation()
18+
{
19+
var client = new AnthropicClient();
20+
var parameters = new MessageParameters()
21+
{
22+
Messages = new List<Message> { new Message(RoleType.User, "Hello!") },
23+
MaxTokens = 1024,
24+
Model = AnthropicModels.Claude46Sonnet,
25+
};
26+
var response = await client.Messages.GetClaudeMessageAsync(parameters);
27+
28+
// Get total estimated cost
29+
var cost = response.CalculateCost();
30+
Console.WriteLine($"Total cost: ${cost.TotalCostUsd:F6}");
31+
Console.WriteLine($" Input tokens: ${cost.InputTokenCost:F6}");
32+
Console.WriteLine($" Output tokens: ${cost.OutputTokenCost:F6}");
33+
Console.WriteLine($" Cache read: ${cost.CacheReadCost:F6}");
34+
Console.WriteLine($" Cache creation: ${cost.CacheCreationCost:F6}");
35+
Console.WriteLine($" Web search: ${cost.WebSearchCost:F6}");
36+
}
37+
}
38+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
using System;
2+
using Anthropic.SDK.Messaging;
3+
4+
namespace Anthropic.SDK.Extensions
5+
{
6+
/// <summary>
7+
/// Detailed breakdown of estimated costs for an API request.
8+
/// All values are in USD.
9+
/// </summary>
10+
public class CostBreakdown
11+
{
12+
/// <summary>
13+
/// Cost of base input tokens.
14+
/// </summary>
15+
public decimal InputTokenCost { get; set; }
16+
17+
/// <summary>
18+
/// Cost of output tokens.
19+
/// </summary>
20+
public decimal OutputTokenCost { get; set; }
21+
22+
/// <summary>
23+
/// Cost of cache read tokens.
24+
/// </summary>
25+
public decimal CacheReadCost { get; set; }
26+
27+
/// <summary>
28+
/// Cost of cache creation tokens (combined 5-minute and 1-hour).
29+
/// When detailed cache creation breakdown is unavailable, the legacy
30+
/// <c>cache_creation_input_tokens</c> field is priced at the 5-minute write rate.
31+
/// </summary>
32+
public decimal CacheCreationCost { get; set; }
33+
34+
/// <summary>
35+
/// Cost of web search requests ($0.01 per search).
36+
/// </summary>
37+
public decimal WebSearchCost { get; set; }
38+
39+
/// <summary>
40+
/// Total estimated cost in USD (sum of all components).
41+
/// </summary>
42+
public decimal TotalCostUsd =>
43+
InputTokenCost + OutputTokenCost + CacheReadCost + CacheCreationCost + WebSearchCost;
44+
45+
/// <summary>
46+
/// The <see cref="ModelPricing"/> used for this calculation.
47+
/// </summary>
48+
public ModelPricing Pricing { get; set; }
49+
}
50+
51+
/// <summary>
52+
/// Extension methods for calculating estimated API costs from usage data.
53+
/// </summary>
54+
public static class CostCalculationExtensions
55+
{
56+
private const decimal PerMillionDivisor = 1_000_000m;
57+
private const decimal Per1000Divisor = 1_000m;
58+
59+
/// <summary>
60+
/// Calculate the estimated cost of an API request from its <see cref="Usage"/> data.
61+
/// When the service tier is <see cref="ServiceTier.Batch"/>, a 50% discount is applied
62+
/// to all token costs automatically.
63+
/// </summary>
64+
/// <param name="usage">The usage data from the API response.</param>
65+
/// <param name="modelId">The model ID string used for the request.</param>
66+
/// <param name="overridePricing">
67+
/// Optional pricing to use instead of the built-in/registered pricing.
68+
/// </param>
69+
/// <returns>A <see cref="CostBreakdown"/> with per-category costs.</returns>
70+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="usage"/> is null.</exception>
71+
/// <exception cref="InvalidOperationException">
72+
/// Thrown when no pricing can be found for <paramref name="modelId"/>
73+
/// and <paramref name="overridePricing"/> is not provided.
74+
/// </exception>
75+
public static CostBreakdown CalculateCost(
76+
this Usage usage,
77+
string modelId,
78+
ModelPricing overridePricing = null)
79+
{
80+
if (usage == null)
81+
throw new ArgumentNullException(nameof(usage));
82+
83+
var pricing = overridePricing ?? ModelPricing.ForModel(modelId);
84+
if (pricing == null)
85+
{
86+
throw new InvalidOperationException(
87+
$"No pricing found for model '{modelId}'. " +
88+
"Use ModelPricing.Register() to add pricing, or pass overridePricing.");
89+
}
90+
91+
decimal batchMultiplier = usage.ServiceTier == ServiceTier.Batch ? 0.5m : 1m;
92+
93+
decimal inputCost = usage.InputTokens / PerMillionDivisor
94+
* pricing.InputTokenCostPerMillion * batchMultiplier;
95+
96+
decimal outputCost = usage.OutputTokens / PerMillionDivisor
97+
* pricing.OutputTokenCostPerMillion * batchMultiplier;
98+
99+
decimal cacheReadCost = usage.CacheReadInputTokens / PerMillionDivisor
100+
* pricing.CacheReadCostPerMillion * batchMultiplier;
101+
102+
decimal cacheCreationCost = 0m;
103+
104+
if (usage.CacheCreation != null)
105+
{
106+
int tokens5m = usage.CacheCreation.Ephemeral5mInputTokens ?? 0;
107+
int tokens1h = usage.CacheCreation.Ephemeral1hInputTokens ?? 0;
108+
109+
cacheCreationCost =
110+
(tokens5m / PerMillionDivisor * pricing.Cache5mWriteCostPerMillion * batchMultiplier) +
111+
(tokens1h / PerMillionDivisor * pricing.Cache1hWriteCostPerMillion * batchMultiplier);
112+
}
113+
114+
if (usage.CacheCreationInputTokens > 0 && cacheCreationCost == 0m)
115+
{
116+
cacheCreationCost = usage.CacheCreationInputTokens / PerMillionDivisor
117+
* pricing.Cache5mWriteCostPerMillion * batchMultiplier;
118+
}
119+
120+
decimal webSearchCost = 0m;
121+
if (usage.ServerToolUse?.WebSearchRequests is > 0)
122+
{
123+
webSearchCost = usage.ServerToolUse.WebSearchRequests.Value / Per1000Divisor
124+
* pricing.WebSearchCostPer1000;
125+
}
126+
127+
return new CostBreakdown
128+
{
129+
InputTokenCost = inputCost,
130+
OutputTokenCost = outputCost,
131+
CacheReadCost = cacheReadCost,
132+
CacheCreationCost = cacheCreationCost,
133+
WebSearchCost = webSearchCost,
134+
Pricing = pricing,
135+
};
136+
}
137+
138+
/// <summary>
139+
/// Calculate the estimated cost of an API request directly from the <see cref="MessageResponse"/>.
140+
/// Uses the model from the response and its usage data.
141+
/// </summary>
142+
/// <param name="response">The message response from the API.</param>
143+
/// <param name="overridePricing">
144+
/// Optional pricing to use instead of the built-in/registered pricing.
145+
/// </param>
146+
/// <returns>A <see cref="CostBreakdown"/> with per-category costs.</returns>
147+
/// <exception cref="ArgumentNullException">
148+
/// Thrown when <paramref name="response"/> or its Usage is null.
149+
/// </exception>
150+
public static CostBreakdown CalculateCost(
151+
this MessageResponse response,
152+
ModelPricing overridePricing = null)
153+
{
154+
if (response == null)
155+
throw new ArgumentNullException(nameof(response));
156+
if (response.Usage == null)
157+
throw new ArgumentNullException(nameof(response), "Response.Usage is null.");
158+
159+
return response.Usage.CalculateCost(response.Model, overridePricing);
160+
}
161+
}
162+
}

Anthropic.SDK/Messaging/MessageResponse.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using Anthropic.SDK.Common;
33
using System.Collections.Generic;
44
using System.Text.Json.Serialization;
@@ -57,6 +57,8 @@ public class MessageResponse
5757

5858
[JsonPropertyName("container")]
5959
public ContainerResponse Container { get; set; }
60+
61+
6062
}
6163

6264
public class StreamMessage
@@ -182,6 +184,9 @@ public class Usage
182184
[JsonPropertyName("service_tier")]
183185
[JsonConverter(typeof(ServiceTierConverter))]
184186
public ServiceTier ServiceTier { get; set; }
187+
188+
[JsonPropertyName("inference_geo")]
189+
public string InferenceGeo { get; set; }
185190
}
186191

187192
public class CacheCreation
@@ -197,5 +202,11 @@ public class ServerToolUse
197202
{
198203
[JsonPropertyName("web_search_requests")]
199204
public int? WebSearchRequests { get; set; }
205+
206+
[JsonPropertyName("code_execution_requests")]
207+
public int? CodeExecutionRequests { get; set; }
208+
209+
[JsonPropertyName("web_fetch_requests")]
210+
public int? WebFetchRequests { get; set; }
200211
}
201212
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
6+
namespace Anthropic.SDK.Messaging
7+
{
8+
/// <summary>
9+
/// Represents per-model pricing data for estimating API request costs.
10+
/// All costs are in USD per million tokens unless otherwise noted.
11+
/// </summary>
12+
public class ModelPricing
13+
{
14+
/// <summary>
15+
/// Cost per million input tokens.
16+
/// </summary>
17+
public decimal InputTokenCostPerMillion { get; }
18+
19+
/// <summary>
20+
/// Cost per million output tokens.
21+
/// </summary>
22+
public decimal OutputTokenCostPerMillion { get; }
23+
24+
/// <summary>
25+
/// Cost per million cache read tokens (typically 0.1x input cost).
26+
/// </summary>
27+
public decimal CacheReadCostPerMillion { get; }
28+
29+
/// <summary>
30+
/// Cost per million 5-minute cache write tokens (typically 1.25x input cost).
31+
/// </summary>
32+
public decimal Cache5mWriteCostPerMillion { get; }
33+
34+
/// <summary>
35+
/// Cost per million 1-hour cache write tokens (typically 2x input cost).
36+
/// </summary>
37+
public decimal Cache1hWriteCostPerMillion { get; }
38+
39+
/// <summary>
40+
/// Cost per 1,000 web search requests. Default is $10 per 1,000 searches.
41+
/// </summary>
42+
public decimal WebSearchCostPer1000 { get; }
43+
44+
public ModelPricing(
45+
decimal inputTokenCostPerMillion,
46+
decimal outputTokenCostPerMillion,
47+
decimal? cacheReadCostPerMillion = null,
48+
decimal? cache5mWriteCostPerMillion = null,
49+
decimal? cache1hWriteCostPerMillion = null,
50+
decimal? webSearchCostPer1000 = null)
51+
{
52+
InputTokenCostPerMillion = inputTokenCostPerMillion;
53+
OutputTokenCostPerMillion = outputTokenCostPerMillion;
54+
CacheReadCostPerMillion = cacheReadCostPerMillion ?? inputTokenCostPerMillion * 0.1m;
55+
Cache5mWriteCostPerMillion = cache5mWriteCostPerMillion ?? inputTokenCostPerMillion * 1.25m;
56+
Cache1hWriteCostPerMillion = cache1hWriteCostPerMillion ?? inputTokenCostPerMillion * 2m;
57+
WebSearchCostPer1000 = webSearchCostPer1000 ?? 10m;
58+
}
59+
60+
private static readonly ConcurrentDictionary<string, ModelPricing> CustomPricing = new();
61+
62+
// Ordered longest-prefix-first so that more specific entries match before shorter ones.
63+
private static readonly List<(string Prefix, ModelPricing Pricing)> BuiltInPricing = new()
64+
{
65+
// Opus 4.6 / 4.5 — $5 input, $25 output
66+
("claude-opus-4-6", new ModelPricing(5m, 25m)),
67+
("claude-opus-4-5", new ModelPricing(5m, 25m)),
68+
69+
// Opus 4.1 — $15 input, $75 output
70+
("claude-opus-4-1", new ModelPricing(15m, 75m)),
71+
72+
// Opus 4 — $15 input, $75 output
73+
("claude-opus-4", new ModelPricing(15m, 75m)),
74+
75+
// Sonnet 4.6 — $3 input, $15 output
76+
("claude-sonnet-4-6", new ModelPricing(3m, 15m)),
77+
78+
// Sonnet 4.5 — $3 input, $15 output
79+
("claude-sonnet-4-5", new ModelPricing(3m, 15m)),
80+
81+
// Sonnet 4 — $3 input, $15 output
82+
("claude-sonnet-4", new ModelPricing(3m, 15m)),
83+
84+
// Sonnet 3.7 — $3 input, $15 output
85+
("claude-3-7-sonnet", new ModelPricing(3m, 15m)),
86+
87+
// Haiku 4.5 — $1 input, $5 output
88+
("claude-haiku-4-5", new ModelPricing(1m, 5m)),
89+
90+
// Haiku 3.5 — $0.80 input, $4 output
91+
("claude-3-5-haiku", new ModelPricing(0.80m, 4m)),
92+
};
93+
94+
/// <summary>
95+
/// Register or override pricing for a model ID prefix.
96+
/// Custom registrations take priority over built-in pricing.
97+
/// </summary>
98+
/// <param name="modelIdPrefix">The model ID or prefix to match (e.g. "claude-sonnet-4-6").</param>
99+
/// <param name="pricing">The pricing to use for matching models.</param>
100+
public static void Register(string modelIdPrefix, ModelPricing pricing)
101+
{
102+
if (string.IsNullOrWhiteSpace(modelIdPrefix))
103+
throw new ArgumentException("Model ID prefix cannot be null or empty.", nameof(modelIdPrefix));
104+
if (pricing == null)
105+
throw new ArgumentNullException(nameof(pricing));
106+
107+
CustomPricing[modelIdPrefix] = pricing;
108+
}
109+
110+
/// <summary>
111+
/// Remove a previously registered custom pricing entry.
112+
/// </summary>
113+
public static bool Unregister(string modelIdPrefix)
114+
{
115+
return CustomPricing.TryRemove(modelIdPrefix, out _);
116+
}
117+
118+
/// <summary>
119+
/// Clear all custom pricing registrations, reverting to built-in pricing only.
120+
/// </summary>
121+
public static void ClearCustomPricing()
122+
{
123+
CustomPricing.Clear();
124+
}
125+
126+
/// <summary>
127+
/// Look up pricing for a given model ID. Custom registrations are checked first
128+
/// (longest prefix match), then built-in pricing.
129+
/// </summary>
130+
/// <returns>The matching <see cref="ModelPricing"/>, or null if no match is found.</returns>
131+
public static ModelPricing ForModel(string modelId)
132+
{
133+
if (string.IsNullOrWhiteSpace(modelId))
134+
return null;
135+
136+
// Check custom registrations first (longest prefix match)
137+
if (!CustomPricing.IsEmpty)
138+
{
139+
var customMatch = CustomPricing.Keys
140+
.Where(prefix => modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
141+
.OrderByDescending(prefix => prefix.Length)
142+
.FirstOrDefault();
143+
144+
if (customMatch != null)
145+
return CustomPricing[customMatch];
146+
}
147+
148+
// Fall back to built-in pricing (already ordered longest-first)
149+
foreach (var (prefix, pricing) in BuiltInPricing)
150+
{
151+
if (modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
152+
return pricing;
153+
}
154+
155+
return null;
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)