Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CompactifAI.Client/CompactifAI.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<!-- Package generation -->
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

<!-- AOT and trimming support -->
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
Expand Down
32 changes: 11 additions & 21 deletions src/CompactifAI.Client/CompactifAIClient.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using CompactifAI.Client.Models;
using CompactifAI.Client.Serialization;
using Microsoft.Extensions.Options;

namespace CompactifAI.Client;
Expand All @@ -13,7 +15,6 @@ public class CompactifAIClient : ICompactifAIClient
{
private readonly HttpClient _httpClient;
private readonly CompactifAIOptions _options;
private readonly JsonSerializerOptions _jsonOptions;

/// <summary>
/// Creates a new CompactifAI client.
Expand All @@ -24,12 +25,6 @@ public CompactifAIClient(HttpClient httpClient, IOptions<CompactifAIOptions> opt
{
_httpClient = httpClient;
_options = options.Value;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};

ConfigureHttpClient();
}

Expand All @@ -46,12 +41,6 @@ public CompactifAIClient(string apiKey, string? baseUrl = null)
BaseUrl = baseUrl ?? "https://api.compactif.ai/v1"
};
_httpClient = new HttpClient();
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};

ConfigureHttpClient();
}

Expand All @@ -76,10 +65,10 @@ public async Task<ChatCompletionResponse> CreateChatCompletionAsync(
var response = await _httpClient.PostAsJsonAsync(
"chat/completions",
request,
_jsonOptions,
CompactifAIJsonContext.Default.ChatCompletionRequest,
cancellationToken);

return await HandleResponseAsync<ChatCompletionResponse>(response, cancellationToken);
return await HandleResponseAsync(response, CompactifAIJsonContext.Default.ChatCompletionResponse, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -120,10 +109,10 @@ public async Task<CompletionResponse> CreateCompletionAsync(
var response = await _httpClient.PostAsJsonAsync(
"completions",
request,
_jsonOptions,
CompactifAIJsonContext.Default.CompletionRequest,
cancellationToken);

return await HandleResponseAsync<CompletionResponse>(response, cancellationToken);
return await HandleResponseAsync(response, CompactifAIJsonContext.Default.CompletionResponse, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -176,7 +165,7 @@ public async Task<TranscriptionResponse> TranscribeAsync(

var response = await _httpClient.PostAsync("audio/transcriptions", content, cancellationToken);

return await HandleResponseAsync<TranscriptionResponse>(response, cancellationToken);
return await HandleResponseAsync(response, CompactifAIJsonContext.Default.TranscriptionResponse, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -209,15 +198,15 @@ public async Task<ModelsResponse> ListModelsAsync(CancellationToken cancellation
{
var response = await _httpClient.GetAsync("models", cancellationToken);

return await HandleResponseAsync<ModelsResponse>(response, cancellationToken);
return await HandleResponseAsync(response, CompactifAIJsonContext.Default.ModelsResponse, cancellationToken);
}

/// <inheritdoc />
public async Task<ModelInfo> GetModelAsync(string modelId, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"models/{modelId}", cancellationToken);

return await HandleResponseAsync<ModelInfo>(response, cancellationToken);
return await HandleResponseAsync(response, CompactifAIJsonContext.Default.ModelInfo, cancellationToken);
}

#endregion
Expand All @@ -226,6 +215,7 @@ public async Task<ModelInfo> GetModelAsync(string modelId, CancellationToken can

private async Task<T> HandleResponseAsync<T>(
HttpResponseMessage response,
JsonTypeInfo<T> jsonTypeInfo,
CancellationToken cancellationToken)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
Expand All @@ -238,7 +228,7 @@ private async Task<T> HandleResponseAsync<T>(
responseBody);
}

var result = JsonSerializer.Deserialize<T>(responseBody, _jsonOptions);
var result = JsonSerializer.Deserialize(responseBody, jsonTypeInfo);

if (result is null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

Expand Down Expand Up @@ -49,6 +50,12 @@ public static IServiceCollection AddCompactifAI(
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration section to bind from.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// This method uses configuration binding which requires reflection and is not AOT-compatible.
/// For AOT scenarios, use the overload that accepts an Action&lt;CompactifAIOptions&gt;.
/// </remarks>
[RequiresUnreferencedCode("Configuration binding uses reflection. Use the Action<CompactifAIOptions> overload for AOT scenarios.")]
[RequiresDynamicCode("Configuration binding uses reflection. Use the Action<CompactifAIOptions> overload for AOT scenarios.")]
public static IServiceCollection AddCompactifAI(
this IServiceCollection services,
Microsoft.Extensions.Configuration.IConfiguration configuration)
Expand Down
3 changes: 2 additions & 1 deletion src/CompactifAI.Client/Models/ChatModels.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CompactifAI.Client.Models;
Expand Down Expand Up @@ -150,7 +151,7 @@ public class ToolFunction
/// </summary>
[JsonPropertyName("parameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Parameters { get; set; }
public JsonElement? Parameters { get; set; }
}

/// <summary>
Expand Down
45 changes: 45 additions & 0 deletions src/CompactifAI.Client/Serialization/CompactifAIJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using CompactifAI.Client.Models;

namespace CompactifAI.Client.Serialization;

/// <summary>
/// Source-generated JSON serialization context for CompactifAI models.
/// Provides high-performance, AOT-compatible serialization without reflection.
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
GenerationMode = JsonSourceGenerationMode.Default)]
// Chat models
[JsonSerializable(typeof(ChatCompletionRequest))]
[JsonSerializable(typeof(ChatCompletionResponse))]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(ChatChoice))]
[JsonSerializable(typeof(Tool))]
[JsonSerializable(typeof(ToolFunction))]
[JsonSerializable(typeof(Usage))]
// Completion models
[JsonSerializable(typeof(CompletionRequest))]
[JsonSerializable(typeof(CompletionResponse))]
[JsonSerializable(typeof(CompletionChoice))]
// Transcription models
[JsonSerializable(typeof(TranscriptionResponse))]
[JsonSerializable(typeof(TranscriptionSegment))]
// Model info
[JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(ModelCapabilities))]
// Supporting types
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(List<ChatChoice>))]
[JsonSerializable(typeof(List<Tool>))]
[JsonSerializable(typeof(List<CompletionChoice>))]
[JsonSerializable(typeof(List<TranscriptionSegment>))]
[JsonSerializable(typeof(List<ModelInfo>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(JsonElement))]
public partial class CompactifAIJsonContext : JsonSerializerContext
{
}
34 changes: 18 additions & 16 deletions tests/CompactifAI.Client.Tests/ModelsTests.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
using System.Text.Json;
using CompactifAI.Client.Models;
using CompactifAI.Client.Serialization;
using Xunit;

namespace CompactifAI.Client.Tests;

public class ModelsTests
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
// Use source-generated context for high-performance serialization
private static CompactifAIJsonContext JsonContext => CompactifAIJsonContext.Default;

#region ChatMessage Tests

Expand Down Expand Up @@ -56,7 +55,7 @@ public void ChatCompletionRequest_Serializes_WithRequiredFields()
}
};

var json = JsonSerializer.Serialize(request, _jsonOptions);
var json = JsonSerializer.Serialize(request, JsonContext.ChatCompletionRequest);

Assert.Contains("\"model\":\"test-model\"", json);
Assert.Contains("\"messages\":", json);
Expand All @@ -73,7 +72,7 @@ public void ChatCompletionRequest_Serializes_OptionalFieldsOmittedWhenNull()
Messages = new List<ChatMessage> { ChatMessage.User("Test") }
};

var json = JsonSerializer.Serialize(request, _jsonOptions);
var json = JsonSerializer.Serialize(request, JsonContext.ChatCompletionRequest);

Assert.DoesNotContain("temperature", json);
Assert.DoesNotContain("max_tokens", json);
Expand All @@ -93,7 +92,7 @@ public void ChatCompletionRequest_Serializes_WithAllOptionalFields()
FrequencyPenalty = 0.5
};

var json = JsonSerializer.Serialize(request, _jsonOptions);
var json = JsonSerializer.Serialize(request, JsonContext.ChatCompletionRequest);

Assert.Contains("\"temperature\":0.7", json);
Assert.Contains("\"max_tokens\":100", json);
Expand Down Expand Up @@ -132,7 +131,7 @@ public void ChatCompletionResponse_Deserializes_Correctly()
}
""";

var response = JsonSerializer.Deserialize<ChatCompletionResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.ChatCompletionResponse);

Assert.NotNull(response);
Assert.Equal("chatcmpl-123", response.Id);
Expand Down Expand Up @@ -161,7 +160,7 @@ public void CompletionRequest_Serializes_Correctly()
TopP = 0.95
};

var json = JsonSerializer.Serialize(request, _jsonOptions);
var json = JsonSerializer.Serialize(request, JsonContext.CompletionRequest);

Assert.Contains("\"model\":\"test-model\"", json);
Assert.Contains("\"prompt\":\"Once upon a time\"", json);
Expand Down Expand Up @@ -198,7 +197,7 @@ public void CompletionResponse_Deserializes_Correctly()
}
""";

var response = JsonSerializer.Deserialize<CompletionResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.CompletionResponse);

Assert.NotNull(response);
Assert.Equal("cmpl-123", response.Id);
Expand Down Expand Up @@ -230,7 +229,7 @@ public void TranscriptionResponse_Deserializes_Correctly()
}
""";

var response = JsonSerializer.Deserialize<TranscriptionResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.TranscriptionResponse);

Assert.NotNull(response);
Assert.Equal("transcribe", response.Task);
Expand Down Expand Up @@ -270,7 +269,7 @@ public void ModelsResponse_Deserializes_Correctly()
}
""";

var response = JsonSerializer.Deserialize<ModelsResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.ModelsResponse);

Assert.NotNull(response);
Assert.Equal("list", response.Object);
Expand Down Expand Up @@ -309,7 +308,7 @@ public void ModelsResponse_ParametersNumber_HandlesVariousFormats(string paramet
}
""";

var response = JsonSerializer.Deserialize<ModelsResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.ModelsResponse);

Assert.NotNull(response);
Assert.Single(response.Data);
Expand Down Expand Up @@ -338,7 +337,7 @@ public void ModelsResponse_Deserializes_WithMissingParametersNumber()
}
""";

var response = JsonSerializer.Deserialize<ModelsResponse>(json, _jsonOptions);
var response = JsonSerializer.Deserialize(json, JsonContext.ModelsResponse);

Assert.NotNull(response);
Assert.Single(response.Data);
Expand All @@ -352,18 +351,21 @@ public void ModelsResponse_Deserializes_WithMissingParametersNumber()
[Fact]
public void Tool_Serializes_Correctly()
{
// Create JsonElement from anonymous object for parameters
var parametersJson = JsonSerializer.SerializeToElement(new { type = "object", properties = new { location = new { type = "string" } } });

var tool = new Tool
{
Type = "function",
Function = new ToolFunction
{
Name = "get_weather",
Description = "Get the current weather",
Parameters = new { type = "object", properties = new { location = new { type = "string" } } }
Parameters = parametersJson
}
};

var json = JsonSerializer.Serialize(tool, _jsonOptions);
var json = JsonSerializer.Serialize(tool, JsonContext.Tool);

Assert.Contains("\"type\":\"function\"", json);
Assert.Contains("\"name\":\"get_weather\"", json);
Expand Down