Skip to content

Commit f58ccf0

Browse files
committed
Refactor billing logic for custom vs. standard agents
Updated `Analyze` method to include `isCustomAgent` parameter, enabling differentiation between billable custom agents and non-billable standard agents. Introduced `NoCost` static property for zero-cost scenarios. Enhanced cost estimation logic to ensure only custom agents incur charges while preserving analytics for standard agents. Added comprehensive test coverage for various billing scenarios, including edge cases and mixed billing components. Updated `CopilotAuditLogContent` to handle agent-specific billing and ensure zero-cost for events without agent information. Incremented cost estimation model version to `1.0.0.1`. Improved documentation, code clarity, and maintainability by explicitly separating logic for custom and standard agents.
1 parent 713e855 commit f58ccf0

File tree

4 files changed

+300
-26
lines changed

4 files changed

+300
-26
lines changed

src/AnalyticsEngine/Tests.UnitTests/CopilotExtendedDataTests.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public void Copilot_CostEstimation_GenerativeAnswersOnly_CalculatesCorrectly()
5050
}";
5151

5252
// Act
53-
var cost = CopilotCreditEstimation.Analyze(json);
53+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
5454

5555
// Assert
5656
// 3 messages × 2 credits = 6 credits
@@ -79,7 +79,7 @@ public void Copilot_CostEstimation_TenantGraphGrounding_CalculatesCorrectly()
7979
}";
8080

8181
// Act
82-
var cost = CopilotCreditEstimation.Analyze(json);
82+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
8383

8484
// Assert
8585
// 3 messages × (2 credits generative + 10 credits tenant graph) = 36 credits
@@ -129,7 +129,7 @@ public void Copilot_CostEstimation_DeepReasoningWithTenantGraph_CalculatesCorrec
129129
}";
130130

131131
// Act
132-
var cost = CopilotCreditEstimation.Analyze(json);
132+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
133133

134134
// Assert
135135
// 3 messages × 2 credits (generative) = 6 credits
@@ -163,7 +163,7 @@ public void Copilot_CostEstimation_DeepReasoningWithoutTenantGraph_CalculatesCor
163163
}";
164164

165165
// Act
166-
var cost = CopilotCreditEstimation.Analyze(json);
166+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
167167

168168
// Assert
169169
// 2 messages × 2 credits (generative) = 4 credits
@@ -191,7 +191,7 @@ public void Copilot_CostEstimation_OneDriveResources_DetectedAsTenantGraph()
191191
}";
192192

193193
// Act
194-
var cost = CopilotCreditEstimation.Analyze(json);
194+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
195195

196196
// Assert
197197
// 1 message × (2 + 10) = 12 credits
@@ -213,7 +213,7 @@ public void Copilot_CostEstimation_TeamsAsyncGatewayResources_DetectedAsTenantGr
213213
}";
214214

215215
// Act
216-
var cost = CopilotCreditEstimation.Analyze(json);
216+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
217217

218218
// Assert
219219
Assert.AreEqual(12, cost.TotalCredits);
@@ -237,7 +237,7 @@ public void Copilot_CostEstimation_MultipleResourceTypes_CountedOnce()
237237
}";
238238

239239
// Act
240-
var cost = CopilotCreditEstimation.Analyze(json);
240+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
241241

242242
// Assert
243243
// Still just 1 message × (2 + 10) = 12 credits (not multiplied by resource count)
@@ -249,9 +249,9 @@ public void Copilot_CostEstimation_MultipleResourceTypes_CountedOnce()
249249
public void Copilot_CostEstimation_NullOrEmptyInput_ReturnsZero()
250250
{
251251
// Arrange & Act
252-
var costNull = CopilotCreditEstimation.Analyze((string)null);
253-
var costEmpty = CopilotCreditEstimation.Analyze("");
254-
var costWhitespace = CopilotCreditEstimation.Analyze(" ");
252+
var costNull = CopilotCreditEstimation.Analyze((string)null, isCustomAgent: true);
253+
var costEmpty = CopilotCreditEstimation.Analyze("", isCustomAgent: true);
254+
var costWhitespace = CopilotCreditEstimation.Analyze(" ", isCustomAgent: true);
255255

256256
// Assert
257257
Assert.AreEqual(0, costNull.TotalCredits);
@@ -271,7 +271,7 @@ public void Copilot_CostEstimation_NoMessages_ReturnsZero()
271271
}";
272272

273273
// Act
274-
var cost = CopilotCreditEstimation.Analyze(json);
274+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
275275

276276
// Assert
277277
Assert.AreEqual(0, cost.TotalCredits);
@@ -290,7 +290,7 @@ public void Copilot_CostEstimation_OnlyPromptMessages_ReturnsZero()
290290
}";
291291

292292
// Act
293-
var cost = CopilotCreditEstimation.Analyze(json);
293+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
294294

295295
// Assert
296296
Assert.AreEqual(0, cost.TotalCredits);
@@ -315,7 +315,7 @@ public void Copilot_CostEstimation_ResourceTypeBreakdown_PopulatedCorrectly()
315315
}";
316316

317317
// Act
318-
var cost = CopilotCreditEstimation.Analyze(json);
318+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
319319

320320
// Assert
321321
Assert.AreEqual(2, cost.ResourceTypeBreakdown["docx"]);
@@ -338,7 +338,7 @@ public void Copilot_CostEstimation_CaseInsensitiveModelDetection_WorksCorrectly(
338338
}";
339339

340340
// Act
341-
var cost = CopilotCreditEstimation.Analyze(json);
341+
var cost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
342342

343343
// Assert
344344
Assert.AreEqual(7, cost.TotalCredits); // 2 + 5
@@ -1075,7 +1075,7 @@ public async Task Copilot_SaveCopilotEvent_WithDeepReasoningModel_CalculatesCost
10751075
Assert.AreEqual("DEEP_LEO", aiModels[0].AIModel.Name);
10761076

10771077
// Assert - Verify cost calculation
1078-
var cost = CopilotCreditEstimation.Analyze(auditLogContent.ParsedAuditEvent);
1078+
var cost = CopilotCreditEstimation.Analyze(auditLogContent.ParsedAuditEvent, isCustomAgent: true);
10791079
// 2 messages × (2 generative + 10 tenant graph) + 5 deep reasoning = 29 credits
10801080
Assert.AreEqual(29, cost.TotalCredits);
10811081
Assert.AreEqual(1, cost.DeepReasoningActions);

src/AnalyticsEngine/Tests.UnitTests/CopilotTests.cs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,5 +1431,238 @@ await copilotEventManager.SaveSingleCopilotEventToSqlStaging(new CopilotAuditLog
14311431
Assert.IsFalse(standardChat.Agent.IsCustomAgent.HasValue && standardChat.Agent.IsCustomAgent.Value, "IsCustomAgent should be false/null for standard agent");
14321432
}
14331433
}
1434+
1435+
/// <summary>
1436+
/// Tests that only custom agents are charged Copilot Credits.
1437+
/// Standard M365 Copilot should have 0 credits.
1438+
/// </summary>
1439+
[TestMethod]
1440+
public void CopilotCreditEstimation_CustomAgentOnly_ChargesCopilotCredits()
1441+
{
1442+
// Arrange - create audit event with messages and tenant graph resources
1443+
var json = @"{
1444+
""Messages"": [
1445+
{ ""Id"": ""msg1"", ""isPrompt"": true },
1446+
{ ""Id"": ""msg2"", ""isPrompt"": false },
1447+
{ ""Id"": ""msg3"", ""isPrompt"": false }
1448+
],
1449+
""AccessedResources"": [
1450+
{ ""Type"": ""File"", ""SiteUrl"": ""https://contoso.sharepoint.com/sites/team"" }
1451+
],
1452+
""ModelTransparencyDetails"": []
1453+
}";
1454+
1455+
// Act - analyze for custom agent
1456+
var customAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
1457+
1458+
// Assert - custom agent should be charged
1459+
Assert.AreEqual(2, customAgentCost.GenerativeAnswers, "Custom agent should have 2 generative answers");
1460+
Assert.AreEqual(2, customAgentCost.TenantGraphGroundedAnswers, "Custom agent should have 2 tenant graph grounded answers");
1461+
Assert.AreEqual(24, customAgentCost.TotalCredits, "Custom agent should be charged 24 credits (2 * (2 + 10))");
1462+
1463+
// Act - analyze for non-custom (standard M365) agent
1464+
var standardAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: false);
1465+
1466+
// Assert - standard agent should NOT be charged
1467+
Assert.AreEqual(0, standardAgentCost.GenerativeAnswers, "Standard agent should have 0 generative answers counted");
1468+
Assert.AreEqual(0, standardAgentCost.TenantGraphGroundedAnswers, "Standard agent should have 0 tenant graph answers counted");
1469+
Assert.AreEqual(0, standardAgentCost.TotalCredits, "Standard agent should have 0 credits");
1470+
1471+
// Verify analytics data is still captured for standard agents
1472+
Assert.IsTrue(standardAgentCost.ResourceTypeBreakdown.Count > 0, "Resource breakdown should still be captured for analytics");
1473+
}
1474+
1475+
/// <summary>
1476+
/// Tests that deep reasoning (premium model) is only charged for custom agents
1477+
/// </summary>
1478+
[TestMethod]
1479+
public void CopilotCreditEstimation_CustomAgentOnly_ChargesDeepReasoning()
1480+
{
1481+
// Arrange - create audit event with deep reasoning model
1482+
var json = @"{
1483+
""Messages"": [
1484+
{ ""Id"": ""msg1"", ""isPrompt"": true },
1485+
{ ""Id"": ""msg2"", ""isPrompt"": false }
1486+
],
1487+
""AccessedResources"": [
1488+
{ ""Type"": ""File"", ""SiteUrl"": ""https://contoso.sharepoint.com/sites/team"" }
1489+
],
1490+
""ModelTransparencyDetails"": [
1491+
{ ""ModelName"": ""DEEP_LEO"" }
1492+
]
1493+
}";
1494+
1495+
// Act - analyze for custom agent
1496+
var customAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
1497+
1498+
// Assert - custom agent should be charged for deep reasoning
1499+
Assert.AreEqual(1, customAgentCost.DeepReasoningActions, "Custom agent should have 1 deep reasoning action");
1500+
Assert.AreEqual(17, customAgentCost.TotalCredits, "Custom agent should be charged 17 credits (2 + 10 + 5)");
1501+
Assert.IsTrue(customAgentCost.ModelsUsed.Contains("DEEP_LEO"), "DEEP_LEO model should be tracked for custom agent");
1502+
1503+
// Act - analyze for non-custom (standard M365) agent
1504+
var standardAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: false);
1505+
1506+
// Assert - standard agent should NOT be charged for deep reasoning
1507+
Assert.AreEqual(0, standardAgentCost.DeepReasoningActions, "Standard agent should have 0 deep reasoning actions counted");
1508+
Assert.AreEqual(0, standardAgentCost.TotalCredits, "Standard agent should have 0 credits");
1509+
1510+
// Verify model is still tracked for analytics even though not charged
1511+
Assert.IsTrue(standardAgentCost.ModelsUsed.Contains("DEEP_LEO"), "DEEP_LEO model should still be tracked for analytics");
1512+
}
1513+
1514+
/// <summary>
1515+
/// Tests that web-only searches (no tenant resources) still result in 0 credits for standard agents
1516+
/// </summary>
1517+
[TestMethod]
1518+
public void CopilotCreditEstimation_StandardAgent_WebSearchesHaveZeroCredits()
1519+
{
1520+
// Arrange - create audit event with web-only resources (no tenant graph)
1521+
var json = @"{
1522+
""Messages"": [
1523+
{ ""Id"": ""msg1"", ""isPrompt"": true },
1524+
{ ""Id"": ""msg2"", ""isPrompt"": false },
1525+
{ ""Id"": ""msg3"", ""isPrompt"": false }
1526+
],
1527+
""AccessedResources"": [
1528+
{ ""Type"": ""WebPage"", ""SiteUrl"": ""https://www.example.com"" }
1529+
],
1530+
""ModelTransparencyDetails"": []
1531+
}";
1532+
1533+
// Act - analyze for custom agent (should only charge for generative, not tenant graph)
1534+
var customAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
1535+
1536+
// Assert - custom agent charged for generative only (no tenant graph)
1537+
Assert.AreEqual(2, customAgentCost.GenerativeAnswers, "Custom agent should have 2 generative answers");
1538+
Assert.AreEqual(0, customAgentCost.TenantGraphGroundedAnswers, "No tenant graph resources accessed");
1539+
Assert.AreEqual(4, customAgentCost.TotalCredits, "Custom agent should be charged 4 credits (2 * 2)");
1540+
1541+
// Act - analyze for standard agent
1542+
var standardAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: false);
1543+
1544+
// Assert - standard agent has 0 credits
1545+
Assert.AreEqual(0, standardAgentCost.TotalCredits, "Standard agent should have 0 credits even with web searches");
1546+
}
1547+
1548+
/// <summary>
1549+
/// Tests comprehensive scenario with all billing components for custom vs standard agents
1550+
/// </summary>
1551+
[TestMethod]
1552+
public void CopilotCreditEstimation_ComprehensiveScenario_DifferentiatesAgentTypes()
1553+
{
1554+
// Arrange - complex scenario with multiple messages, tenant resources, and deep reasoning
1555+
var json = @"{
1556+
""Messages"": [
1557+
{ ""Id"": ""msg1"", ""isPrompt"": true },
1558+
{ ""Id"": ""msg2"", ""isPrompt"": false },
1559+
{ ""Id"": ""msg3"", ""isPrompt"": false },
1560+
{ ""Id"": ""msg4"", ""isPrompt"": true },
1561+
{ ""Id"": ""msg5"", ""isPrompt"": false }
1562+
],
1563+
""AccessedResources"": [
1564+
{ ""Type"": ""docx"", ""Name"": ""Document1.docx"", ""SiteUrl"": ""https://contoso.sharepoint.com/sites/team"" },
1565+
{ ""Type"": ""xlsx"", ""Name"": ""Spreadsheet1.xlsx"", ""SiteUrl"": ""https://contoso-my.sharepoint.com/personal/user"" },
1566+
{ ""Type"": ""Email"", ""Name"": ""Meeting Notes"" }
1567+
],
1568+
""ModelTransparencyDetails"": [
1569+
{ ""ModelName"": ""DEEP_LEO"" }
1570+
]
1571+
}";
1572+
1573+
// Act - analyze for custom agent
1574+
var customAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: true);
1575+
1576+
// Assert - custom agent full billing
1577+
Assert.AreEqual(3, customAgentCost.GenerativeAnswers, "3 response messages");
1578+
Assert.AreEqual(3, customAgentCost.TenantGraphGroundedAnswers, "All 3 responses use tenant graph");
1579+
Assert.AreEqual(1, customAgentCost.DeepReasoningActions, "1 deep reasoning action");
1580+
Assert.AreEqual(41, customAgentCost.TotalCredits, "Total: 3*(2+10) + 5 = 36 + 5 = 41 credits");
1581+
Assert.AreEqual(3, customAgentCost.ResourceTypeBreakdown.Count, "3 resource types accessed");
1582+
Assert.IsTrue(customAgentCost.CreditBreakdown.ContainsKey("Generative Answers"), "Should have generative breakdown");
1583+
Assert.IsTrue(customAgentCost.CreditBreakdown.ContainsKey("Tenant Graph Grounding"), "Should have tenant graph breakdown");
1584+
Assert.IsTrue(customAgentCost.CreditBreakdown.ContainsKey("Agent Actions (Deep Reasoning)"), "Should have deep reasoning breakdown");
1585+
1586+
// Act - analyze for standard agent
1587+
var standardAgentCost = CopilotCreditEstimation.Analyze(json, isCustomAgent: false);
1588+
1589+
// Assert - standard agent no billing but analytics preserved
1590+
Assert.AreEqual(0, standardAgentCost.GenerativeAnswers, "No answers counted for standard agent");
1591+
Assert.AreEqual(0, standardAgentCost.TenantGraphGroundedAnswers, "No grounding counted for standard agent");
1592+
Assert.AreEqual(0, standardAgentCost.DeepReasoningActions, "No actions counted for standard agent");
1593+
Assert.AreEqual(0, standardAgentCost.TotalCredits, "Standard M365 Copilot has 0 credits");
1594+
Assert.AreEqual(0, standardAgentCost.CreditBreakdown.Count, "No credit breakdown for standard agent");
1595+
1596+
// Analytics data still captured
1597+
Assert.AreEqual(3, standardAgentCost.ResourceTypeBreakdown.Count, "Resource analytics still captured");
1598+
Assert.IsTrue(standardAgentCost.ModelsUsed.Contains("DEEP_LEO"), "Model analytics still captured");
1599+
}
1600+
1601+
/// <summary>
1602+
/// Tests empty/null scenarios for both agent types
1603+
/// </summary>
1604+
[TestMethod]
1605+
public void CopilotCreditEstimation_EmptyEvents_BothAgentTypesReturnZero()
1606+
{
1607+
// Arrange - empty event
1608+
var emptyJson = @"{
1609+
""Messages"": [],
1610+
""AccessedResources"": [],
1611+
""ModelTransparencyDetails"": []
1612+
}";
1613+
1614+
// Act
1615+
var customAgentCost = CopilotCreditEstimation.Analyze(emptyJson, isCustomAgent: true);
1616+
var standardAgentCost = CopilotCreditEstimation.Analyze(emptyJson, isCustomAgent: false);
1617+
1618+
// Assert - both should return 0
1619+
Assert.AreEqual(0, customAgentCost.TotalCredits, "Empty custom agent event should have 0 credits");
1620+
Assert.AreEqual(0, standardAgentCost.TotalCredits, "Empty standard agent event should have 0 credits");
1621+
1622+
// Test null string
1623+
var nullCost = CopilotCreditEstimation.Analyze((string)null, isCustomAgent: true);
1624+
Assert.AreEqual(0, nullCost.TotalCredits, "Null event should have 0 credits");
1625+
1626+
// Test empty string
1627+
var emptyCost = CopilotCreditEstimation.Analyze(string.Empty, isCustomAgent: true);
1628+
Assert.AreEqual(0, emptyCost.TotalCredits, "Empty string should have 0 credits");
1629+
}
1630+
1631+
/// <summary>
1632+
/// Tests that when there's no agent information (AgentName is null/empty), Cost is set to NoCost (0 credits).
1633+
/// This verifies the logic in CopilotAuditLogContent.FromJson() that assigns NoCost when AgentName is empty.
1634+
/// </summary>
1635+
[TestMethod]
1636+
public void CopilotAuditLogContent_NoAgentName_HasNoCost()
1637+
{
1638+
// Arrange - JSON with Copilot event data but NO AgentName or AgentId
1639+
// This would normally result in charges, but without an agent identifier, no cost should be assigned
1640+
var jsonWithoutAgent = @"{
1641+
""CopilotEventData"": {
1642+
""AppHost"": ""Teams"",
1643+
""AccessedResources"": [
1644+
{
1645+
""Id"": ""file123"",
1646+
""Type"": ""File"",
1647+
""SiteUrl"": ""https://contoso.sharepoint.com/sites/test""
1648+
}
1649+
]
1650+
},
1651+
""Messages"": [
1652+
{ ""IsPrompt"": true },
1653+
{ ""IsPrompt"": false }
1654+
]
1655+
}";
1656+
1657+
// Act - Deserialize using FromJson (which applies the cost logic)
1658+
var auditLog = CopilotAuditLogContent.FromJson(jsonWithoutAgent);
1659+
1660+
// Assert - When AgentName is null/empty, Cost should be NoCost (0 credits)
1661+
Assert.IsNotNull(auditLog.Cost, "Cost should not be null");
1662+
Assert.AreEqual(0, auditLog.Cost.TotalCredits, "When AgentName is empty, TotalCredits should be 0");
1663+
Assert.IsTrue(string.IsNullOrEmpty(auditLog.AgentName), "AgentName should be null or empty");
1664+
Assert.IsTrue(string.IsNullOrEmpty(auditLog.AgentId), "AgentId should be null or empty");
1665+
Assert.IsNull(auditLog.IsCustomAgent, "IsCustomAgent should be null when agent info is not present");
1666+
}
14341667
}
14351668
}

0 commit comments

Comments
 (0)