Skip to content

Commit b877f2b

Browse files
HavenDVclaude
andcommitted
chore: migrate FluentAssertions to AwesomeAssertions 9.4.0
FluentAssertions changed its license away from MIT. AwesomeAssertions is an MIT-licensed fork with the same API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a2104a5 commit b877f2b

File tree

7 files changed

+213
-32
lines changed

7 files changed

+213
-32
lines changed

src/Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<ItemGroup>
66
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
77
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="2.0.2" />
8-
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
8+
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
99
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
1010
<PackageVersion Include="H.Resources.Generator" Version="1.8.0">
1111
<PrivateAssets>all</PrivateAssets>

src/libs/Tiktoken.Core/ChatFunction.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ public class FunctionParameter
7979
/// </summary>
8080
public string? ArrayItemType { get; set; }
8181

82+
/// <summary>
83+
/// Union types for <c>anyOf</c> schemas (e.g., ["string", "number"] becomes "string | number").
84+
/// When set, <see cref="Type"/> is ignored and this list is used instead.
85+
/// </summary>
86+
public IReadOnlyList<string>? AnyOf { get; set; }
87+
8288
/// <summary>
8389
/// Creates a new function parameter.
8490
/// </summary>
@@ -96,7 +102,8 @@ public FunctionParameter(
96102
bool isRequired = false,
97103
IReadOnlyList<string>? enumValues = null,
98104
IReadOnlyList<FunctionParameter>? properties = null,
99-
string? arrayItemType = null)
105+
string? arrayItemType = null,
106+
IReadOnlyList<string>? anyOf = null)
100107
{
101108
Name = name ?? throw new ArgumentNullException(nameof(name));
102109
Type = type ?? throw new ArgumentNullException(nameof(type));
@@ -105,5 +112,6 @@ public FunctionParameter(
105112
EnumValues = enumValues;
106113
Properties = properties;
107114
ArrayItemType = arrayItemType;
115+
AnyOf = anyOf;
108116
}
109117
}

src/libs/Tiktoken.Core/Encoder.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,13 +334,22 @@ private int CountParameterTokens(IReadOnlyList<FunctionParameter> parameters)
334334

335335
// Tokenize "key:type:description" (trailing period stripped)
336336
var paramDesc = param.Description.TrimEnd('.');
337-
var type = param.Type;
337+
string type;
338338

339-
// For array types, append item type (e.g., "array:string[]")
340-
if (type == "array" && param.ArrayItemType != null)
339+
if (param.AnyOf != null && param.AnyOf.Count > 0)
341340
{
341+
// anyOf union: "string | number"
342+
type = string.Join(" | ", param.AnyOf);
343+
}
344+
else if (param.Type == "array" && param.ArrayItemType != null)
345+
{
346+
// Array with item type: "string[]"
342347
type = param.ArrayItemType + "[]";
343348
}
349+
else
350+
{
351+
type = param.Type;
352+
}
344353

345354
count += CountTokens(param.Name + ":" + type + ":" + paramDesc);
346355

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
namespace Tiktoken.UnitTests;
2+
3+
[TestClass]
4+
public static class TestInitializer
5+
{
6+
[AssemblyInitialize]
7+
public static void Initialize(TestContext _)
8+
{
9+
LoadEnvFile();
10+
}
11+
12+
private static void LoadEnvFile()
13+
{
14+
// Walk up from the test output directory to find .env at repo root
15+
var dir = AppContext.BaseDirectory;
16+
while (dir != null)
17+
{
18+
var envPath = Path.Combine(dir, ".env");
19+
if (File.Exists(envPath))
20+
{
21+
foreach (var line in File.ReadAllLines(envPath))
22+
{
23+
var trimmed = line.Trim();
24+
if (trimmed.Length == 0 || trimmed.StartsWith('#'))
25+
{
26+
continue;
27+
}
28+
29+
var eqIndex = trimmed.IndexOf('=');
30+
if (eqIndex <= 0)
31+
{
32+
continue;
33+
}
34+
35+
var key = trimmed[..eqIndex].Trim();
36+
var value = trimmed[(eqIndex + 1)..].Trim();
37+
38+
// Only set if not already set (env vars take precedence)
39+
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(key)))
40+
{
41+
Environment.SetEnvironmentVariable(key, value);
42+
}
43+
}
44+
45+
return;
46+
}
47+
48+
dir = Path.GetDirectoryName(dir);
49+
}
50+
}
51+
}

src/tests/Tiktoken.UnitTests/Tests.Integration.cs

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ public partial class Tests
1212
return Environment.GetEnvironmentVariable("OPENAI_API_KEY");
1313
}
1414

15+
private static async Task<int> GetServerTokenCount(HttpClient httpClient, string requestJson)
16+
{
17+
var response = await httpClient.PostAsync(
18+
"https://api.openai.com/v1/responses/input_tokens",
19+
new StringContent(requestJson, Encoding.UTF8, "application/json"));
20+
21+
response.EnsureSuccessStatusCode();
22+
var responseBody = await response.Content.ReadAsStringAsync();
23+
using var doc = JsonDocument.Parse(responseBody);
24+
return doc.RootElement.GetProperty("input_tokens").GetInt32();
25+
}
26+
27+
private static HttpClient CreateOpenAiClient(string apiKey)
28+
{
29+
var httpClient = new HttpClient();
30+
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
31+
return httpClient;
32+
}
33+
1534
[TestMethod]
1635
public async Task ValidateMessageTokensAgainstOpenAiApi()
1736
{
@@ -22,31 +41,20 @@ public async Task ValidateMessageTokensAgainstOpenAiApi()
2241
return;
2342
}
2443

25-
using var httpClient = new HttpClient();
26-
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
44+
using var httpClient = CreateOpenAiClient(apiKey);
2745

28-
// Test: simple text input
2946
var requestJson = JsonSerializer.Serialize(new
3047
{
3148
model = "gpt-4o-mini",
3249
input = "Tell me a joke about programming.",
3350
});
3451

35-
var response = await httpClient.PostAsync(
36-
"https://api.openai.com/v1/responses/input_tokens",
37-
new StringContent(requestJson, Encoding.UTF8, "application/json"));
52+
var serverTokens = await GetServerTokenCount(httpClient, requestJson);
3853

39-
response.EnsureSuccessStatusCode();
40-
var responseBody = await response.Content.ReadAsStringAsync();
41-
using var doc = JsonDocument.Parse(responseBody);
42-
var serverTokens = doc.RootElement.GetProperty("input_tokens").GetInt32();
43-
44-
// Local count for the same text
4554
var encoder = ModelToEncoder.For("gpt-4o-mini");
4655
var localTokens = encoder.CountTokens("Tell me a joke about programming.");
4756

4857
// The server count includes system prompt overhead, so server >= local
49-
// We just verify our local count is reasonable (within the server count)
5058
localTokens.Should().BeGreaterThan(0);
5159
serverTokens.Should().BeGreaterThanOrEqualTo(localTokens,
5260
$"Server returned {serverTokens} tokens, local counted {localTokens}");
@@ -62,8 +70,7 @@ public async Task ValidateMessageTokensWithToolsAgainstOpenAiApi()
6270
return;
6371
}
6472

65-
using var httpClient = new HttpClient();
66-
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
73+
using var httpClient = CreateOpenAiClient(apiKey);
6774

6875
var requestJson = """
6976
{
@@ -95,16 +102,8 @@ public async Task ValidateMessageTokensWithToolsAgainstOpenAiApi()
95102
}
96103
""";
97104

98-
var response = await httpClient.PostAsync(
99-
"https://api.openai.com/v1/responses/input_tokens",
100-
new StringContent(requestJson, Encoding.UTF8, "application/json"));
105+
var serverTokens = await GetServerTokenCount(httpClient, requestJson);
101106

102-
response.EnsureSuccessStatusCode();
103-
var responseBody = await response.Content.ReadAsStringAsync();
104-
using var doc = JsonDocument.Parse(responseBody);
105-
var serverTokens = doc.RootElement.GetProperty("input_tokens").GetInt32();
106-
107-
// Local count
108107
var encoder = ModelToEncoder.For("gpt-4o-mini");
109108
var messages = new List<ChatMessage>
110109
{
@@ -121,12 +120,95 @@ public async Task ValidateMessageTokensWithToolsAgainstOpenAiApi()
121120

122121
var localTokens = encoder.CountMessageTokens(messages, tools);
123122

124-
// Log both values for debugging
125123
Console.WriteLine($"Server tokens: {serverTokens}, Local tokens: {localTokens}, Diff: {serverTokens - localTokens}");
126124

127-
// Allow reasonable tolerance (±20%) since the formula is reverse-engineered
125+
// Allow ±20% tolerance since the formula is reverse-engineered
128126
var tolerance = Math.Max(serverTokens * 0.2, 5);
129127
localTokens.Should().BeCloseTo(serverTokens, (uint)tolerance,
130128
$"Local estimate ({localTokens}) should be close to server count ({serverTokens})");
131129
}
130+
131+
[TestMethod]
132+
public async Task ValidateMessagesOnlyAgainstOpenAiApi()
133+
{
134+
var apiKey = GetOpenAiApiKey();
135+
if (string.IsNullOrEmpty(apiKey))
136+
{
137+
Assert.Inconclusive("OPENAI_API_KEY not set — skipping integration test.");
138+
return;
139+
}
140+
141+
using var httpClient = CreateOpenAiClient(apiKey);
142+
143+
// Messages-only test (no tools) to validate message counting
144+
var requestJson = """
145+
{
146+
"model": "gpt-4o-mini",
147+
"input": [
148+
{"role": "developer", "content": "You are a helpful assistant."},
149+
{"role": "user", "content": "hello world"}
150+
]
151+
}
152+
""";
153+
154+
var serverTokens = await GetServerTokenCount(httpClient, requestJson);
155+
156+
var encoder = ModelToEncoder.For("gpt-4o-mini");
157+
var messages = new List<ChatMessage>
158+
{
159+
new("developer", "You are a helpful assistant."),
160+
new("user", "hello world"),
161+
};
162+
163+
var localTokens = encoder.CountMessageTokens(messages);
164+
165+
Console.WriteLine($"Messages-only: Server={serverTokens}, Local={localTokens}, Diff={serverTokens - localTokens}");
166+
167+
// Messages-only should be very close
168+
localTokens.Should().BeCloseTo(serverTokens, 3,
169+
$"Local ({localTokens}) should be very close to server ({serverTokens}) for messages-only");
170+
}
171+
172+
[TestMethod]
173+
public async Task ValidateToolsOnlyAgainstOpenAiApi()
174+
{
175+
var apiKey = GetOpenAiApiKey();
176+
if (string.IsNullOrEmpty(apiKey))
177+
{
178+
Assert.Inconclusive("OPENAI_API_KEY not set — skipping integration test.");
179+
return;
180+
}
181+
182+
using var httpClient = CreateOpenAiClient(apiKey);
183+
184+
// Test no-params tool
185+
var requestJson = """
186+
{
187+
"model": "gpt-4o-mini",
188+
"input": [{"role": "user", "content": "hi"}],
189+
"tools": [
190+
{
191+
"type": "function",
192+
"name": "get_time",
193+
"description": "Get the current time"
194+
}
195+
]
196+
}
197+
""";
198+
199+
var serverTokens = await GetServerTokenCount(httpClient, requestJson);
200+
201+
var encoder = ModelToEncoder.For("gpt-4o-mini");
202+
var messages = new List<ChatMessage> { new("user", "hi") };
203+
var tools = new List<ChatFunction> { new("get_time", "Get the current time") };
204+
205+
var localTokens = encoder.CountMessageTokens(messages, tools);
206+
var toolTokensOnly = encoder.CountToolTokens(tools);
207+
208+
Console.WriteLine($"No-params tool: Server={serverTokens}, Local={localTokens}, ToolTokens={toolTokensOnly}, Diff={serverTokens - localTokens}");
209+
210+
var tolerance = Math.Max(serverTokens * 0.2, 5);
211+
localTokens.Should().BeCloseTo(serverTokens, (uint)tolerance,
212+
$"Local ({localTokens}) should be close to server ({serverTokens})");
213+
}
132214
}

src/tests/Tiktoken.UnitTests/Tests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,4 +633,35 @@ public void CountToolTokensArrayType()
633633

634634
count.Should().BeGreaterThan(19); // funcInit + funcEnd + propInit + propKey + tokens
635635
}
636+
637+
[TestMethod]
638+
public void CountToolTokensAnyOf()
639+
{
640+
var encoder = ModelToEncoder.For("gpt-4o");
641+
var tools = new List<ChatFunction>
642+
{
643+
new("update_field", "Update a field value", new List<FunctionParameter>
644+
{
645+
new("value", "", "The new value", isRequired: true,
646+
anyOf: new[] { "string", "number", "boolean" }),
647+
}),
648+
};
649+
650+
var count = encoder.CountToolTokens(tools);
651+
652+
// Should tokenize the type as "string | number | boolean"
653+
count.Should().BeGreaterThan(19);
654+
655+
// Compare with a simple string type — anyOf should produce more tokens
656+
var simpleTools = new List<ChatFunction>
657+
{
658+
new("update_field", "Update a field value", new List<FunctionParameter>
659+
{
660+
new("value", "string", "The new value", isRequired: true),
661+
}),
662+
};
663+
664+
var simpleCount = encoder.CountToolTokens(simpleTools);
665+
count.Should().BeGreaterThan(simpleCount);
666+
}
636667
}

src/tests/Tiktoken.UnitTests/Tiktoken.UnitTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</ItemGroup>
1111

1212
<ItemGroup>
13-
<PackageReference Include="FluentAssertions" />
13+
<PackageReference Include="AwesomeAssertions" />
1414
<PackageReference Include="GitHubActionsTestLogger">
1515
<PrivateAssets>all</PrivateAssets>
1616
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

0 commit comments

Comments
 (0)