From f8d86b08b2e897709d60b3449f4d54773ee486b2 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 16:48:56 -0500 Subject: [PATCH 01/35] Remove third-party OpenAI libraries from C# and Rust SDKs Replace Betalgo.Ranul.OpenAI (C#) and async-openai (Rust) with custom DTOs matching the OpenAI JSON wire format. C# SDK: - Add custom OpenAI types: ChatMessage, ChatCompletionResponse, ToolCallingTypes, AudioTypes, LiveAudioTranscriptionTypes - Add type-safe enums: ChatMessageRole, ToolType, FinishReason, ResponseErrorType with JsonStringEnumConverter for AOT serialization - Wire Stop and MaxCompletionTokens through ChatSettings - Simplify ToolChoiceConverter (remove HashSet, inline deserialization) - Remove ~60 redundant per-property JsonIgnore attributes (covered by JsonSourceGenerationOptions.DefaultIgnoreCondition) - Remove unused RichardSzalay.MockHttp test dependency - Fix Stop type from IList to string (matching Core contract) - Remove dead Seed property (SDK uses metadata[random_seed] path) Rust SDK: - Add custom chat_types module replacing async-openai types - Remove 3 dead types (ChatCompletionToolChoiceOption and related) - Add #[non_exhaustive] to public enums - Tighten module visibility to pub(crate) - Clean up Cargo.toml (move tokio-stream to dev-deps, remove unused serde build-dep) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/README.md | 4 +- ...soft.ai.foundry.local.openaiaudioclient.md | 2 +- ...osoft.ai.foundry.local.openaichatclient.md | 2 +- sdk/cs/src/Detail/JsonSerializationContext.cs | 27 +- sdk/cs/src/Microsoft.AI.Foundry.Local.csproj | 1 - sdk/cs/src/OpenAI/AudioClient.cs | 21 +- .../AudioTranscriptionRequestResponseTypes.cs | 32 +- sdk/cs/src/OpenAI/AudioTypes.cs | 46 +++ sdk/cs/src/OpenAI/ChatClient.cs | 26 +- .../ChatCompletionRequestResponseTypes.cs | 80 +++-- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 152 ++++++++++ sdk/cs/src/OpenAI/ChatMessage.cs | 110 +++++++ .../src/OpenAI/LiveAudioTranscriptionTypes.cs | 31 +- sdk/cs/src/OpenAI/ToolCallingExtensions.cs | 23 +- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 201 +++++++++++++ .../ChatCompletionsTests.cs | 60 ++-- .../LiveAudioTranscriptionTests.cs | 1 - .../Microsoft.AI.Foundry.Local.Tests.csproj | 1 - sdk/rust/Cargo.toml | 6 +- sdk/rust/src/lib.rs | 13 +- sdk/rust/src/openai/chat_client.rs | 2 +- sdk/rust/src/openai/chat_types.rs | 284 ++++++++++++++++++ sdk/rust/src/openai/mod.rs | 1 + 23 files changed, 986 insertions(+), 140 deletions(-) create mode 100644 sdk/cs/src/OpenAI/AudioTypes.cs create mode 100644 sdk/cs/src/OpenAI/ChatCompletionResponse.cs create mode 100644 sdk/cs/src/OpenAI/ChatMessage.cs create mode 100644 sdk/cs/src/OpenAI/ToolCallingTypes.cs create mode 100644 sdk/rust/src/openai/chat_types.rs diff --git a/sdk/cs/README.md b/sdk/cs/README.md index 3efdc242..b7b4c487 100644 --- a/sdk/cs/README.md +++ b/sdk/cs/README.md @@ -106,7 +106,7 @@ Catalog access no longer blocks on EP downloads. Call `DownloadAndRegisterEpsAsy using Microsoft.AI.Foundry.Local; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; // 1. Initialize the singleton manager await FoundryLocalManager.CreateAsync( @@ -273,7 +273,7 @@ audioClient.Settings.Temperature = 0.0f; For real-time microphone-to-text transcription, use `CreateLiveTranscriptionSession()`. Audio is pushed as raw PCM chunks and transcription results stream back as an `IAsyncEnumerable`. -The streaming result type (`LiveAudioTranscriptionResponse`) extends `ConversationItem` from the Betalgo OpenAI SDK's Realtime models, so it's compatible with the OpenAI Realtime API pattern. Access transcribed text via `result.Content[0].Text` or `result.Content[0].Transcript`. +The streaming result type (`LiveAudioTranscriptionResponse`) is compatible with the OpenAI Realtime API pattern and provides access to transcribed text via `result.Content[0].Text` or `result.Content[0].Transcript`. ```csharp var audioClient = await model.GetAudioClientAsync(); diff --git a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md index b1b60bd8..c9898f4c 100644 --- a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md +++ b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md @@ -3,7 +3,7 @@ Namespace: Microsoft.AI.Foundry.Local Audio Client that uses the OpenAI API. - Implemented using Betalgo.Ranul.OpenAI SDK types. + Implemented using custom OpenAI-compatible types. ```csharp public class OpenAIAudioClient diff --git a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md index 43e00f6d..f184dfb8 100644 --- a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md +++ b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md @@ -3,7 +3,7 @@ Namespace: Microsoft.AI.Foundry.Local Chat Client that uses the OpenAI API. - Implemented using Betalgo.Ranul.OpenAI SDK types. + Implemented using custom OpenAI-compatible types. ```csharp public class OpenAIChatClient diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 37cc81ac..e3037b04 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -10,23 +10,29 @@ namespace Microsoft.AI.Foundry.Local.Detail; using System.Text.Json; using System.Text.Json.Serialization; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; -using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; - using Microsoft.AI.Foundry.Local.OpenAI; [JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(CoreInteropRequest))] -[JsonSerializable(typeof(ChatCompletionCreateRequestExtended))] -[JsonSerializable(typeof(ChatCompletionCreateResponse))] -[JsonSerializable(typeof(AudioCreateTranscriptionRequest))] -[JsonSerializable(typeof(AudioCreateTranscriptionResponse))] +[JsonSerializable(typeof(ChatCompletionRequest))] +[JsonSerializable(typeof(ChatCompletionResponse))] +[JsonSerializable(typeof(ChatChoice))] +[JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(ChatMessageRole))] +[JsonSerializable(typeof(ToolType))] +[JsonSerializable(typeof(FinishReason))] +[JsonSerializable(typeof(CompletionUsage))] +[JsonSerializable(typeof(ResponseError))] +[JsonSerializable(typeof(ResponseErrorType))] +[JsonSerializable(typeof(AudioTranscriptionRequest))] +[JsonSerializable(typeof(AudioTranscriptionRequestExtended))] +[JsonSerializable(typeof(AudioTranscriptionResponse))] [JsonSerializable(typeof(string[]))] // list loaded or cached models [JsonSerializable(typeof(EpInfo[]))] [JsonSerializable(typeof(EpDownloadResult))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(ResponseFormat))] [JsonSerializable(typeof(ResponseFormatExtended))] [JsonSerializable(typeof(ToolChoice))] [JsonSerializable(typeof(ToolDefinition))] @@ -35,8 +41,9 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(PropertyDefinition))] [JsonSerializable(typeof(IList))] -// --- Audio streaming types (LiveAudioTranscriptionResponse inherits ConversationItem -// which has AOT-incompatible JsonConverters, so we only register the raw deserialization type) --- +[JsonSerializable(typeof(ToolCall))] +[JsonSerializable(typeof(FunctionCall))] +[JsonSerializable(typeof(JsonSchema))] [JsonSerializable(typeof(LiveAudioTranscriptionRaw))] [JsonSerializable(typeof(CoreErrorResponse))] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj index e8a7b755..4dc678e0 100644 --- a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj +++ b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj @@ -121,7 +121,6 @@ - diff --git a/sdk/cs/src/OpenAI/AudioClient.cs b/sdk/cs/src/OpenAI/AudioClient.cs index a8cbc1d7..383ba3cc 100644 --- a/sdk/cs/src/OpenAI/AudioClient.cs +++ b/sdk/cs/src/OpenAI/AudioClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -8,8 +8,6 @@ namespace Microsoft.AI.Foundry.Local; using System.Runtime.CompilerServices; using System.Threading.Channels; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.AI.Foundry.Local.OpenAI; @@ -17,7 +15,6 @@ namespace Microsoft.AI.Foundry.Local; /// /// Audio Client that uses the OpenAI API. -/// Implemented using Betalgo.Ranul.OpenAI SDK types. /// public class OpenAIAudioClient { @@ -54,7 +51,7 @@ public record AudioSettings /// /// Optional cancellation token. /// Transcription response. - public async Task TranscribeAudioAsync(string audioFilePath, + public async Task TranscribeAudioAsync(string audioFilePath, CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => TranscribeAudioImplAsync(audioFilePath, ct), @@ -71,7 +68,7 @@ public async Task TranscribeAudioAsync(string /// /// Cancellation token. /// An asynchronous enumerable of transcription responses. - public async IAsyncEnumerable TranscribeAudioStreamingAsync( + public async IAsyncEnumerable TranscribeAudioStreamingAsync( string audioFilePath, [EnumeratorCancellation] CancellationToken ct) { var enumerable = Utils.CallWithExceptionHandling( @@ -94,10 +91,10 @@ public LiveAudioTranscriptionSession CreateLiveTranscriptionSession() return new LiveAudioTranscriptionSession(_modelId); } - private async Task TranscribeAudioImplAsync(string audioFilePath, + private async Task TranscribeAudioImplAsync(string audioFilePath, CancellationToken? ct) { - var openaiRequest = AudioTranscriptionCreateRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest @@ -117,10 +114,10 @@ private async Task TranscribeAudioImplAsync(st return output; } - private async IAsyncEnumerable TranscribeAudioStreamingImplAsync( + private async IAsyncEnumerable TranscribeAudioStreamingImplAsync( string audioFilePath, [EnumeratorCancellation] CancellationToken ct) { - var openaiRequest = AudioTranscriptionCreateRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest { @@ -130,7 +127,7 @@ private async IAsyncEnumerable TranscribeAudio } }; - var channel = Channel.CreateUnbounded( + var channel = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleWriter = true, @@ -138,7 +135,7 @@ private async IAsyncEnumerable TranscribeAudio AllowSynchronousContinuations = true }); - // The callback will push AudioCreateTranscriptionResponse objects into the channel. + // The callback will push AudioTranscriptionResponse objects into the channel. // The channel reader will return the values to the user. // This setup prevents the user from blocking the thread generating the responses. _ = Task.Run(async () => diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index 4ba28336..d524750e 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -10,15 +10,12 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Text.Json; using System.Text.Json.Serialization; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; - using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.Extensions.Logging; -internal record AudioTranscriptionCreateRequestExtended : AudioCreateTranscriptionRequest +internal class AudioTranscriptionRequestExtended : AudioTranscriptionRequest { // Valid entries: // int language @@ -26,11 +23,11 @@ internal record AudioTranscriptionCreateRequestExtended : AudioCreateTranscripti [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } - internal static AudioTranscriptionCreateRequestExtended FromUserInput(string modelId, - string audioFilePath, - OpenAIAudioClient.AudioSettings settings) + internal static AudioTranscriptionRequestExtended FromUserInput(string modelId, + string audioFilePath, + OpenAIAudioClient.AudioSettings settings) { - var request = new AudioTranscriptionCreateRequestExtended + var request = new AudioTranscriptionRequestExtended { Model = modelId, FileName = audioFilePath, @@ -57,18 +54,19 @@ internal static AudioTranscriptionCreateRequestExtended FromUserInput(string mod request.Metadata = metadata; } - return request; } } + internal static class AudioTranscriptionRequestResponseExtensions { - internal static string ToJson(this AudioCreateTranscriptionRequest request) + internal static string ToJson(this AudioTranscriptionRequest request) { - return JsonSerializer.Serialize(request, JsonSerializationContext.Default.AudioCreateTranscriptionRequest); + return JsonSerializer.Serialize(request, JsonSerializationContext.Default.AudioTranscriptionRequest); } - internal static AudioCreateTranscriptionResponse ToAudioTranscription(this ICoreInterop.Response response, - ILogger logger) + + internal static AudioTranscriptionResponse ToAudioTranscription(this ICoreInterop.Response response, + ILogger logger) { if (response.Error != null) { @@ -79,14 +77,14 @@ internal static AudioCreateTranscriptionResponse ToAudioTranscription(this ICore return response.Data!.ToAudioTranscription(logger); } - internal static AudioCreateTranscriptionResponse ToAudioTranscription(this string responseData, ILogger logger) + internal static AudioTranscriptionResponse ToAudioTranscription(this string responseData, ILogger logger) { - var typeInfo = JsonSerializationContext.Default.AudioCreateTranscriptionResponse; + var typeInfo = JsonSerializationContext.Default.AudioTranscriptionResponse; var response = JsonSerializer.Deserialize(responseData, typeInfo); if (response == null) { - logger.LogError("Failed to deserialize AudioCreateTranscriptionResponse. Json={Data}", responseData); - throw new FoundryLocalException("Failed to deserialize AudioCreateTranscriptionResponse"); + logger.LogError("Failed to deserialize AudioTranscriptionResponse. Json={Data}", responseData); + throw new FoundryLocalException("Failed to deserialize AudioTranscriptionResponse"); } return response; diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs new file mode 100644 index 00000000..012b9f7a --- /dev/null +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -0,0 +1,46 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI; + +using System.Text.Json.Serialization; + +/// +/// Response from an audio transcription request. +/// +public class AudioTranscriptionResponse +{ + /// The transcribed text. + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// The task performed (e.g. "transcribe"). + [JsonPropertyName("task")] + public string? Task { get; set; } + + /// The language of the audio. + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// The duration of the audio in seconds. + [JsonPropertyName("duration")] + public float Duration { get; set; } +} + +/// +/// Internal request DTO for audio transcription. Uses PascalCase properties (no JsonPropertyName) +/// to match the convention that the SDK previously relied on for native core communication. +/// +internal class AudioTranscriptionRequest +{ + public string? Model { get; set; } + public string? FileName { get; set; } + public byte[]? File { get; set; } + public string? Language { get; set; } + public string? Prompt { get; set; } + public string? ResponseFormat { get; set; } + public float? Temperature { get; set; } +} diff --git a/sdk/cs/src/OpenAI/ChatClient.cs b/sdk/cs/src/OpenAI/ChatClient.cs index b9f889f2..688e369a 100644 --- a/sdk/cs/src/OpenAI/ChatClient.cs +++ b/sdk/cs/src/OpenAI/ChatClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -10,16 +10,12 @@ namespace Microsoft.AI.Foundry.Local; using System.Runtime.CompilerServices; using System.Threading.Channels; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; - using Microsoft.AI.Foundry.Local.Detail; using Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; /// /// Chat Client that uses the OpenAI API. -/// Implemented using Betalgo.Ranul.OpenAI SDK types. /// public class OpenAIChatClient { @@ -40,10 +36,12 @@ public record ChatSettings { public float? FrequencyPenalty { get; set; } public int? MaxTokens { get; set; } + public int? MaxCompletionTokens { get; set; } public int? N { get; set; } public float? Temperature { get; set; } public float? PresencePenalty { get; set; } public int? RandomSeed { get; set; } + public string? Stop { get; set; } internal bool? Stream { get; set; } // this is set internally based on the API used public int? TopK { get; set; } public float? TopP { get; set; } @@ -65,7 +63,7 @@ public record ChatSettings /// Chat messages. The system message is automatically added. /// Optional cancellation token. /// Chat completion response. - public Task CompleteChatAsync(IEnumerable messages, + public Task CompleteChatAsync(IEnumerable messages, CancellationToken? ct = null) { return CompleteChatAsync(messages: messages, tools: null, ct: ct); @@ -80,7 +78,7 @@ public Task CompleteChatAsync(IEnumerableOptional tool definitions to include in the request. /// Optional cancellation token. /// Chat completion response. - public async Task CompleteChatAsync(IEnumerable messages, + public async Task CompleteChatAsync(IEnumerable messages, IEnumerable? tools, CancellationToken? ct = null) { @@ -97,7 +95,7 @@ public async Task CompleteChatAsync(IEnumerableChat messages. The system message is automatically added. /// Cancellation token. /// Async enumerable of chat completion responses. - public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, + public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, CancellationToken ct) { return CompleteChatStreamingAsync(messages: messages, tools: null, ct: ct); @@ -112,7 +110,7 @@ public IAsyncEnumerable CompleteChatStreamingAsync /// Optional tool definitions to include in the request. /// Cancellation token. /// Async enumerable of chat completion responses. - public async IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, + public async IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, IEnumerable? tools, [EnumeratorCancellation] CancellationToken ct) { @@ -126,13 +124,13 @@ public async IAsyncEnumerable CompleteChatStreamin } } - private async Task CompleteChatImplAsync(IEnumerable messages, + private async Task CompleteChatImplAsync(IEnumerable messages, IEnumerable? tools, CancellationToken? ct) { Settings.Stream = false; - var chatRequest = ChatCompletionCreateRequestExtended.FromUserInput(_modelId, messages, tools, Settings); + var chatRequest = ChatCompletionRequest.FromUserInput(_modelId, messages, tools, Settings); var chatRequestJson = chatRequest.ToJson(); var request = new CoreInteropRequest { Params = new() { { "OpenAICreateRequest", chatRequestJson } } }; @@ -144,17 +142,17 @@ private async Task CompleteChatImplAsync(IEnumerab return chatCompletion; } - private async IAsyncEnumerable ChatStreamingImplAsync(IEnumerable messages, + private async IAsyncEnumerable ChatStreamingImplAsync(IEnumerable messages, IEnumerable? tools, [EnumeratorCancellation] CancellationToken ct) { Settings.Stream = true; - var chatRequest = ChatCompletionCreateRequestExtended.FromUserInput(_modelId, messages, tools, Settings); + var chatRequest = ChatCompletionRequest.FromUserInput(_modelId, messages, tools, Settings); var chatRequestJson = chatRequest.ToJson(); var request = new CoreInteropRequest { Params = new() { { "OpenAICreateRequest", chatRequestJson } } }; - var channel = Channel.CreateUnbounded( + var channel = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleWriter = true, diff --git a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs index cfd3e08c..033aa0b7 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs @@ -6,37 +6,70 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; +using System.Collections.Generic; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; - using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.Extensions.Logging; // https://platform.openai.com/docs/api-reference/chat/create -// Using the Betalgo ChatCompletionCreateRequest and extending with the `metadata` field for additional parameters -// which is part of the OpenAI spec but for some reason not part of the Betalgo request object. -internal class ChatCompletionCreateRequestExtended : ChatCompletionCreateRequest +internal class ChatCompletionRequest { - // Valid entries: - // int top_k - // int random_seed - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("messages")] + public List? Messages { get; set; } + + [JsonPropertyName("temperature")] + public float? Temperature { get; set; } + + [JsonPropertyName("max_tokens")] + public int? MaxTokens { get; set; } + + [JsonPropertyName("max_completion_tokens")] + public int? MaxCompletionTokens { get; set; } + + [JsonPropertyName("n")] + public int? N { get; set; } + + [JsonPropertyName("stream")] + public bool? Stream { get; set; } + + [JsonPropertyName("top_p")] + public float? TopP { get; set; } + + [JsonPropertyName("frequency_penalty")] + public float? FrequencyPenalty { get; set; } + + [JsonPropertyName("presence_penalty")] + public float? PresencePenalty { get; set; } + + [JsonPropertyName("stop")] + public string? Stop { get; set; } + + [JsonPropertyName("tools")] + public List? Tools { get; set; } + + [JsonPropertyName("tool_choice")] + public ToolChoice? ToolChoice { get; set; } [JsonPropertyName("response_format")] - public new ResponseFormatExtended? ResponseFormat { get; set; } + public ResponseFormatExtended? ResponseFormat { get; set; } - internal static ChatCompletionCreateRequestExtended FromUserInput(string modelId, - IEnumerable messages, - IEnumerable? tools, - OpenAIChatClient.ChatSettings settings) + // Extension: additional parameters passed via metadata + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + internal static ChatCompletionRequest FromUserInput(string modelId, + IEnumerable messages, + IEnumerable? tools, + OpenAIChatClient.ChatSettings settings) { - var request = new ChatCompletionCreateRequestExtended + var request = new ChatCompletionRequest { Model = modelId, Messages = messages.ToList(), @@ -44,9 +77,11 @@ internal static ChatCompletionCreateRequestExtended FromUserInput(string modelId // Apply our specific settings FrequencyPenalty = settings.FrequencyPenalty, MaxTokens = settings.MaxTokens, + MaxCompletionTokens = settings.MaxCompletionTokens, N = settings.N, Temperature = settings.Temperature, PresencePenalty = settings.PresencePenalty, + Stop = settings.Stop, Stream = settings.Stream, TopP = settings.TopP, // Apply tool calling and structured output settings @@ -71,19 +106,18 @@ internal static ChatCompletionCreateRequestExtended FromUserInput(string modelId request.Metadata = metadata; } - return request; } } internal static class ChatCompletionsRequestResponseExtensions { - internal static string ToJson(this ChatCompletionCreateRequestExtended request) + internal static string ToJson(this ChatCompletionRequest request) { - return JsonSerializer.Serialize(request, JsonSerializationContext.Default.ChatCompletionCreateRequestExtended); + return JsonSerializer.Serialize(request, JsonSerializationContext.Default.ChatCompletionRequest); } - internal static ChatCompletionCreateResponse ToChatCompletion(this ICoreInterop.Response response, ILogger logger) + internal static ChatCompletionResponse ToChatCompletion(this ICoreInterop.Response response, ILogger logger) { if (response.Error != null) { @@ -94,9 +128,9 @@ internal static ChatCompletionCreateResponse ToChatCompletion(this ICoreInterop. return response.Data!.ToChatCompletion(logger); } - internal static ChatCompletionCreateResponse ToChatCompletion(this string responseData, ILogger logger) + internal static ChatCompletionResponse ToChatCompletion(this string responseData, ILogger logger) { - var output = JsonSerializer.Deserialize(responseData, JsonSerializationContext.Default.ChatCompletionCreateResponse); + var output = JsonSerializer.Deserialize(responseData, JsonSerializationContext.Default.ChatCompletionResponse); if (output == null) { logger.LogError("Failed to deserialize chat completion response: {ResponseData}", responseData); diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs new file mode 100644 index 00000000..cf5539ce --- /dev/null +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -0,0 +1,152 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +/// +/// Reason the model stopped generating tokens. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FinishReason +{ + /// The model finished naturally or hit a stop sequence. + [JsonStringEnumMemberName("stop")] + Stop, + + /// The model hit the maximum token limit. + [JsonStringEnumMemberName("length")] + Length, + + /// The model is requesting tool calls. + [JsonStringEnumMemberName("tool_calls")] + ToolCalls, + + /// Content was filtered by safety policy. + [JsonStringEnumMemberName("content_filter")] + ContentFilter +} + +/// +/// Response from a chat completion request. +/// +public class ChatCompletionResponse +{ + /// A unique identifier for the completion. + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// The object type (e.g. "chat.completion"). + [JsonPropertyName("object")] + public string? ObjectType { get; set; } + + /// The Unix timestamp when the completion was created. + [JsonPropertyName("created")] + public long CreatedAtUnix { get; set; } + + /// The model used for the completion. + [JsonPropertyName("model")] + public string? Model { get; set; } + + /// The list of completion choices. + [JsonPropertyName("choices")] + public List? Choices { get; set; } + + /// Token usage statistics for the request. + [JsonPropertyName("usage")] + public CompletionUsage? Usage { get; set; } + + /// A fingerprint representing the backend configuration. + [JsonPropertyName("system_fingerprint")] + public string? SystemFingerprint { get; set; } + + /// Error information, if the request failed. + [JsonPropertyName("error")] + public ResponseError? Error { get; set; } +} + +/// +/// A single completion choice in a chat completion response. +/// +public class ChatChoice +{ + /// The index of this choice. + [JsonPropertyName("index")] + public int Index { get; set; } + + /// The chat message generated by the model (non-streaming). + [JsonPropertyName("message")] + public ChatMessage? Message { get; set; } + + /// The delta content for streaming responses. + [JsonPropertyName("delta")] + public ChatMessage? Delta { get; set; } + + /// The reason the model stopped generating. + [JsonPropertyName("finish_reason")] + public FinishReason? FinishReason { get; set; } +} + +/// +/// Token usage statistics for a completion request. +/// +public class CompletionUsage +{ + /// Number of tokens in the prompt. + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + /// Number of tokens in the completion. + [JsonPropertyName("completion_tokens")] + public int? CompletionTokens { get; set; } + + /// Total number of tokens used. + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} + +/// +/// The type of error returned by the API. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ResponseErrorType +{ + /// The request was invalid (bad parameters, missing fields, etc.). + [JsonStringEnumMemberName("invalid_request_error")] + InvalidRequestError, + + /// An internal server error occurred. + [JsonStringEnumMemberName("server_error")] + ServerError, + + /// A general error. + [JsonStringEnumMemberName("error")] + Error +} + +/// +/// Error information returned by the API. +/// +public class ResponseError +{ + /// The error code. + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// A human-readable error message. + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// The error type. + [JsonPropertyName("type")] + public ResponseErrorType? Type { get; set; } + + /// The parameter that caused the error. + [JsonPropertyName("param")] + public string? Param { get; set; } +} diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs new file mode 100644 index 00000000..7e8b6edf --- /dev/null +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -0,0 +1,110 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +/// +/// Role of a chat message author. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChatMessageRole +{ + /// System instruction message. + [JsonStringEnumMemberName("system")] + System, + + /// User input message. + [JsonStringEnumMemberName("user")] + User, + + /// Assistant response message. + [JsonStringEnumMemberName("assistant")] + Assistant, + + /// Tool result message. + [JsonStringEnumMemberName("tool")] + Tool +} + +/// +/// Type of tool call or tool definition. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ToolType +{ + /// A function tool. + [JsonStringEnumMemberName("function")] + Function +} + +/// +/// Represents a chat message in a conversation. +/// +public class ChatMessage +{ + /// The role of the message author. + [JsonPropertyName("role")] + public ChatMessageRole Role { get; set; } + + /// The text content of the message. + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// Optional name of the author of the message. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// Tool call ID this message is responding to. + [JsonPropertyName("tool_call_id")] + public string? ToolCallId { get; set; } + + /// Tool calls generated by the model. + [JsonPropertyName("tool_calls")] + public IList? ToolCalls { get; set; } + + /// Deprecated function call generated by the model. + [JsonPropertyName("function_call")] + public FunctionCall? FunctionCall { get; set; } +} + +/// +/// Represents a tool call generated by the model. +/// +public class ToolCall +{ + /// The index of this tool call in the list. + [JsonPropertyName("index")] + public int Index { get; set; } + + /// The unique ID of the tool call. + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// The type of tool call. + [JsonPropertyName("type")] + public ToolType Type { get; set; } + + /// The function that the model called. + [JsonPropertyName("function")] + public FunctionCall? FunctionCall { get; set; } +} + +/// +/// Represents a function call with name and arguments. +/// +public class FunctionCall +{ + /// The name of the function to call. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// The arguments to pass to the function, as a JSON string. + [JsonPropertyName("arguments")] + public string? Arguments { get; set; } +} diff --git a/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs b/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs index a0e98542..591d2a20 100644 --- a/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs +++ b/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs @@ -1,19 +1,34 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; -using Betalgo.Ranul.OpenAI.ObjectModels.RealtimeModels; using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; +/// +/// A content part within a transcription result, following the OpenAI Realtime +/// ConversationItem pattern. Access transcribed text via or +/// . +/// +public class TranscriptionContentPart +{ + /// The transcribed text. + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// The transcript (same as Text for transcription results). + [JsonPropertyName("transcript")] + public string? Transcript { get; set; } +} + /// /// Transcription result for real-time audio streaming sessions. -/// Extends the OpenAI Realtime API's so that +/// Follows the OpenAI Realtime API ConversationItem pattern so that /// customers access text via result.Content[0].Text or -/// result.Content[0].Transcript, ensuring forward compatibility -/// when the transport layer moves to WebSocket. +/// result.Content[0].Transcript. /// -public class LiveAudioTranscriptionResponse : ConversationItem +public class LiveAudioTranscriptionResponse { /// /// Whether this is a final or partial (interim) result. @@ -32,6 +47,10 @@ public class LiveAudioTranscriptionResponse : ConversationItem [JsonPropertyName("end_time")] public double? EndTime { get; init; } + /// Content parts. Access text via Content[0].Text or Content[0].Transcript. + [JsonPropertyName("content")] + public List? Content { get; set; } + internal static LiveAudioTranscriptionResponse FromJson(string json) { var raw = JsonSerializer.Deserialize(json, @@ -45,7 +64,7 @@ internal static LiveAudioTranscriptionResponse FromJson(string json) EndTime = raw.EndTime, Content = [ - new ContentPart + new TranscriptionContentPart { Text = raw.Text, Transcript = raw.Text diff --git a/sdk/cs/src/OpenAI/ToolCallingExtensions.cs b/sdk/cs/src/OpenAI/ToolCallingExtensions.cs index fb3a72bf..ea017dc3 100644 --- a/sdk/cs/src/OpenAI/ToolCallingExtensions.cs +++ b/sdk/cs/src/OpenAI/ToolCallingExtensions.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -6,18 +6,23 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; - using System.Text.Json.Serialization; -// Extend response format beyond the OpenAI spec for LARK grammars +/// +/// Extended response format that adds LARK grammar support beyond the OpenAI spec. +/// +/// +/// Supported formats: +/// +/// {"type": "text"} +/// {"type": "json_object"} +/// {"type": "json_schema", "json_schema": ...} +/// {"type": "lark_grammar", "lark_grammar": ...} +/// +/// public class ResponseFormatExtended : ResponseFormat { - // Ex: - // 1. {"type": "text"} - // 2. {"type": "json_object"} - // 3. {"type": "json_schema", "json_schema": } - // 4. {"type": "lark_grammar", "lark_grammar": } + /// LARK grammar string when type is "lark_grammar". [JsonPropertyName("lark_grammar")] public string? LarkGrammar { get; set; } } diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs new file mode 100644 index 00000000..28b9c768 --- /dev/null +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -0,0 +1,201 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Definition of a tool the model may call. +/// +public class ToolDefinition +{ + /// The type of tool. + [JsonPropertyName("type")] + public ToolType Type { get; set; } + + /// The function definition. + [JsonPropertyName("function")] + public FunctionDefinition? Function { get; set; } +} + +/// +/// Definition of a function the model may call. +/// +public class FunctionDefinition +{ + /// The name of the function. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// A description of what the function does. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// The parameters the function accepts, described as a JSON Schema object. + [JsonPropertyName("parameters")] + public PropertyDefinition? Parameters { get; set; } + + /// Whether to enable strict schema adherence. + [JsonPropertyName("strict")] + public bool? Strict { get; set; } +} + +/// +/// JSON Schema property definition used to describe function parameters and structured outputs. +/// +public class PropertyDefinition +{ + /// The data type (e.g. "object", "string", "integer", "array"). + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// A description of the property. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Allowed values for enum types. + [JsonPropertyName("enum")] + public IList? Enum { get; set; } + + /// Nested properties for object types. + [JsonPropertyName("properties")] + public IDictionary? Properties { get; set; } + + /// Required property names for object types. + [JsonPropertyName("required")] + public IList? Required { get; set; } + + /// Schema for array item types. + [JsonPropertyName("items")] + public PropertyDefinition? Items { get; set; } + + /// Whether additional properties are allowed. + [JsonPropertyName("additionalProperties")] + public bool? AdditionalProperties { get; set; } +} + +/// +/// Response format specification for chat completions. +/// +public class ResponseFormat +{ + /// The format type (e.g. "text", "json_object", "json_schema"). + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// JSON Schema specification when type is "json_schema". + [JsonPropertyName("json_schema")] + public JsonSchema? JsonSchema { get; set; } +} + +/// +/// JSON Schema definition for structured output. +/// +public class JsonSchema +{ + /// The name of the schema. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// Whether to enable strict schema adherence. + [JsonPropertyName("strict")] + public bool? Strict { get; set; } + + /// The JSON Schema definition. + [JsonPropertyName("schema")] + public PropertyDefinition? Schema { get; set; } +} + +/// +/// Controls which tool the model should use. +/// Use static properties , , or +/// for standard choices. +/// +[JsonConverter(typeof(ToolChoiceConverter))] +public class ToolChoice +{ + /// The tool choice type. + public string? Type { get; set; } + + /// Specifies a particular function to call. + public FunctionTool? Function { get; set; } + + /// The model will not call any tool. + public static ToolChoice None => new() { Type = "none" }; + + /// The model can choose whether to call a tool. + public static ToolChoice Auto => new() { Type = "auto" }; + + /// The model must call one or more tools. + public static ToolChoice Required => new() { Type = "required" }; + + /// + /// Specifies a specific function tool to call. + /// + public class FunctionTool + { + /// The name of the function to call. + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} + +/// +/// Custom JSON converter for that serializes +/// simple choices ("none", "auto", "required") as plain strings +/// and specific function choices as objects. +/// +internal class ToolChoiceConverter : JsonConverter +{ + public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + return new ToolChoice { Type = reader.GetString() }; + + if (reader.TokenType != JsonTokenType.StartObject) + return null; + + var choice = new ToolChoice(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) continue; + var prop = reader.GetString(); + reader.Read(); + switch (prop) + { + case "type": + choice.Type = reader.GetString(); + break; + case "function": + choice.Function = JsonSerializer.Deserialize(ref reader, options); + break; + default: + reader.Skip(); + break; + } + } + return choice; + } + + public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializerOptions options) + { + if (value.Function == null) + { + writer.WriteStringValue(value.Type); + return; + } + + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WritePropertyName("function"); + JsonSerializer.Serialize(writer, value.Function, options); + writer.WriteEndObject(); + } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 2624f98a..869d70c3 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -9,9 +9,7 @@ namespace Microsoft.AI.Foundry.Local.Tests; using System.Text; using System.Threading.Tasks; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; -using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; +using Microsoft.AI.Foundry.Local.OpenAI; internal sealed class ChatCompletionsTests { @@ -45,7 +43,7 @@ public async Task DirectChat_NoStreaming_Succeeds() List messages = [ // System prompt is setup by GenAI - new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var response = await chatClient.CompleteChatAsync(messages).ConfigureAwait(false); @@ -54,16 +52,16 @@ public async Task DirectChat_NoStreaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); await Assert.That(message.Content).Contains("42"); Console.WriteLine($"Response: {message.Content}"); - messages.Add(new ChatMessage { Role = "assistant", Content = message.Content }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = message.Content }); messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = "Is the answer a real number?" }); @@ -84,7 +82,7 @@ public async Task DirectChat_Streaming_Succeeds() List messages = [ - new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var updates = chatClient.CompleteChatStreamingAsync(messages, CancellationToken.None).ConfigureAwait(false); @@ -96,7 +94,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -105,10 +103,10 @@ public async Task DirectChat_Streaming_Succeeds() Console.WriteLine(fullResponse); await Assert.That(fullResponse).Contains("42"); - messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = "Add 25 to the previous answer. Think hard to be sure of the answer." }); @@ -120,7 +118,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -143,14 +141,14 @@ public async Task DirectTool_NoStreaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -177,7 +175,7 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response).IsNotNull(); await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); await Assert.That(response.Choices.Count).IsEqualTo(1); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo("tool_calls"); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(response.Choices[0].Message).IsNotNull(); await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); @@ -188,8 +186,8 @@ public async Task DirectTool_NoStreaming_Succeeds() var expectedResponse = "" + toolCall + ""; await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(response.Choices[0].Message.ToolCalls![0].Type).IsEqualTo(ToolType.Function); + await Assert.That(response.Choices[0].Message.ToolCalls![0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); @@ -197,10 +195,10 @@ public async Task DirectTool_NoStreaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolCallResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = "tool", Content = toolCallResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolCallResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt @@ -227,14 +225,14 @@ public async Task DirectTool_Streaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -259,7 +257,7 @@ public async Task DirectTool_Streaming_Succeeds() // Check that each response chunk contains the expected information StringBuilder responseMessage = new(); var numTokens = 0; - ChatCompletionCreateResponse? toolCallResponse = null; + ChatCompletionResponse? toolCallResponse = null; await foreach (var response in updates) { await Assert.That(response).IsNotNull(); @@ -273,7 +271,7 @@ public async Task DirectTool_Streaming_Succeeds() responseMessage.Append(content); numTokens += 1; } - if (response.Choices[0].FinishReason == "tool_calls") + if (response.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponse = response; } @@ -289,11 +287,11 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(fullResponse).IsNotNull(); await Assert.That(fullResponse).IsEqualTo(expectedResponse); await Assert.That(toolCallResponse?.Choices.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo("tool_calls"); + await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls![0].Type).IsEqualTo(ToolType.Function); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls![0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); @@ -301,10 +299,10 @@ public async Task DirectTool_Streaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = "tool", Content = toolResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt diff --git a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs index 2bc39d68..35a99acd 100644 --- a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs @@ -6,7 +6,6 @@ namespace Microsoft.AI.Foundry.Local.Tests; -using System.Text.Json; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.AI.Foundry.Local.OpenAI; diff --git a/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj b/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj index fe0dfcd2..3d0c44fb 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj +++ b/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj @@ -47,7 +47,6 @@ - diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 2a6292b7..d45b8d3f 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -20,17 +20,17 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } -tokio-stream = "0.1" futures-core = "0.3" reqwest = { version = "0.12", features = ["json"] } urlencoding = "2" -async-openai = { version = "0.33", default-features = false, features = ["chat-completion-types"] } + +[dev-dependencies] +tokio-stream = "0.1" [build-dependencies] ureq = "3" zip = "2" serde_json = "1" -serde = { version = "1", features = ["derive"] } [[example]] name = "chat_completion" diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 872a875c..c8c1bb94 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -9,7 +9,7 @@ mod foundry_local_manager; mod types; pub(crate) mod detail; -pub mod openai; +pub(crate) mod openai; pub use self::catalog::Catalog; pub use self::configuration::{FoundryLocalConfig, LogLevel, Logger}; @@ -22,11 +22,10 @@ pub use self::types::{ }; // Re-export OpenAI request types so callers can construct typed messages. -pub use async_openai::types::chat::{ - ChatCompletionNamedToolChoice, ChatCompletionRequestAssistantMessage, - ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, - ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage, - ChatCompletionToolChoiceOption, ChatCompletionTools, FunctionObject, +pub use crate::openai::chat_types::{ + ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage, + ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage, + ChatCompletionRequestUserMessage, ChatCompletionTools, FunctionObject, }; // Re-export OpenAI response types for convenience. @@ -34,7 +33,7 @@ pub use crate::openai::{ AudioTranscriptionResponse, AudioTranscriptionStream, ChatCompletionStream, TranscriptionSegment, TranscriptionWord, }; -pub use async_openai::types::chat::{ +pub use crate::openai::chat_types::{ ChatChoice, ChatChoiceStream, ChatCompletionMessageToolCall, ChatCompletionMessageToolCallChunk, ChatCompletionMessageToolCalls, ChatCompletionResponseMessage, ChatCompletionStreamResponseDelta, CompletionUsage, diff --git a/sdk/rust/src/openai/chat_client.rs b/sdk/rust/src/openai/chat_client.rs index 6597de82..81a0d39e 100644 --- a/sdk/rust/src/openai/chat_client.rs +++ b/sdk/rust/src/openai/chat_client.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::sync::Arc; -use async_openai::types::chat::{ +use super::chat_types::{ ChatCompletionRequestMessage, ChatCompletionTools, CreateChatCompletionResponse, CreateChatCompletionStreamResponse, }; diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs new file mode 100644 index 00000000..a4e444c8 --- /dev/null +++ b/sdk/rust/src/openai/chat_types.rs @@ -0,0 +1,284 @@ +//! OpenAI-compatible chat completion types. + +use serde::{Deserialize, Serialize}; + +// ─── Request types ─────────────────────────────────────────────────────────── + +/// A chat completion request message, internally tagged by the `role` field. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(tag = "role")] +pub enum ChatCompletionRequestMessage { + #[serde(rename = "system")] + System(ChatCompletionRequestSystemMessage), + #[serde(rename = "user")] + User(ChatCompletionRequestUserMessage), + #[serde(rename = "assistant")] + Assistant(ChatCompletionRequestAssistantMessage), + #[serde(rename = "tool")] + Tool(ChatCompletionRequestToolMessage), +} + +/// A system message in a chat completion request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionRequestSystemMessage { + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl From<&str> for ChatCompletionRequestSystemMessage { + fn from(s: &str) -> Self { + Self { + content: s.to_owned(), + name: None, + } + } +} + +impl From for ChatCompletionRequestSystemMessage { + fn from(s: String) -> Self { + Self { + content: s, + name: None, + } + } +} + +impl From for ChatCompletionRequestMessage { + fn from(msg: ChatCompletionRequestSystemMessage) -> Self { + Self::System(msg) + } +} + +/// A user message in a chat completion request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionRequestUserMessage { + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl From<&str> for ChatCompletionRequestUserMessage { + fn from(s: &str) -> Self { + Self { + content: s.to_owned(), + name: None, + } + } +} + +impl From for ChatCompletionRequestUserMessage { + fn from(s: String) -> Self { + Self { + content: s, + name: None, + } + } +} + +impl From for ChatCompletionRequestMessage { + fn from(msg: ChatCompletionRequestUserMessage) -> Self { + Self::User(msg) + } +} + +/// An assistant message in a chat completion request. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatCompletionRequestAssistantMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refusal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_call: Option, +} + +impl From for ChatCompletionRequestMessage { + fn from(msg: ChatCompletionRequestAssistantMessage) -> Self { + Self::Assistant(msg) + } +} + +/// A tool result message in a chat completion request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionRequestToolMessage { + pub content: String, + pub tool_call_id: String, +} + +impl From for ChatCompletionRequestMessage { + fn from(msg: ChatCompletionRequestToolMessage) -> Self { + Self::Tool(msg) + } +} + +/// A tool definition for a chat completion request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionTools { + #[serde(rename = "type")] + pub r#type: String, + pub function: FunctionObject, +} + +/// Description of a function that the model can call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionObject { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strict: Option, +} + +// ─── Response types ────────────────────────────────────────────────────────── + +/// Response object for a non-streaming chat completion. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateChatCompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_fingerprint: Option, +} + +/// A single choice within a non-streaming chat completion response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatChoice { + pub index: u32, + pub message: ChatCompletionResponseMessage, + #[serde(default)] + pub finish_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logprobs: Option, +} + +/// The assistant's message inside a [`ChatChoice`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionResponseMessage { + #[serde(default)] + pub role: Option, + #[serde(default)] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_call: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refusal: Option, +} + +/// Token usage statistics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +/// Reason the model stopped generating tokens. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(rename_all = "snake_case")] +pub enum FinishReason { + Stop, + Length, + ToolCalls, + ContentFilter, + FunctionCall, +} + +/// A tool call within a response message, tagged by `type`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(tag = "type")] +pub enum ChatCompletionMessageToolCalls { + #[serde(rename = "function")] + Function(ChatCompletionMessageToolCall), +} + +/// A single function tool call (id + function payload). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionMessageToolCall { + pub id: String, + pub function: FunctionCall, +} + +/// A resolved function call with name and JSON-encoded arguments. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +// ─── Streaming response types ──────────────────────────────────────────────── + +/// Response object for a streaming chat completion chunk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateChatCompletionStreamResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_fingerprint: Option, +} + +/// A single choice within a streaming response chunk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatChoiceStream { + pub index: u32, + pub delta: ChatCompletionStreamResponseDelta, + #[serde(default)] + pub finish_reason: Option, +} + +/// The delta payload inside a streaming [`ChatChoiceStream`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionStreamResponseDelta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_call: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refusal: Option, +} + +/// A partial tool call chunk received during streaming. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionMessageToolCallChunk { + pub index: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")] + pub r#type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +/// A partial function call received during streaming. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FunctionCallStream { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option, +} diff --git a/sdk/rust/src/openai/mod.rs b/sdk/rust/src/openai/mod.rs index c3d4a645..714ae50f 100644 --- a/sdk/rust/src/openai/mod.rs +++ b/sdk/rust/src/openai/mod.rs @@ -1,4 +1,5 @@ mod audio_client; +pub(crate) mod chat_types; mod chat_client; mod json_stream; From 92331ac91580f8b139b747f9e4a8e0f7f0c7a2ed Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 17:17:01 -0500 Subject: [PATCH 02/35] Update C# samples and docs to use new SDK types Replace all Betalgo references in samples with SDK-native types: - Remove Betalgo.Ranul.OpenAI package references from 4 .csproj files and Directory.Packages.props - Replace string-based Role with ChatMessageRole enum - Replace string-based Type with ToolType.Function enum - Replace string-based FinishReason with FinishReason enum - Replace ChatCompletionCreateResponse with ChatCompletionResponse - Update API docs to remove Betalgo inheritance references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/cs/Directory.Packages.props | 1 - samples/cs/model-management-example/Program.cs | 4 ++-- samples/cs/native-chat-completions/Program.cs | 4 ++-- .../tool-calling-foundry-local-sdk/Program.cs | 18 ++++++++---------- samples/cs/tutorial-chat-assistant/Program.cs | 8 ++++---- .../TutorialChatAssistant.csproj | 1 - .../cs/tutorial-document-summarizer/Program.cs | 6 +++--- .../TutorialDocumentSummarizer.csproj | 1 - samples/cs/tutorial-tool-calling/Program.cs | 18 ++++++++---------- .../TutorialToolCalling.csproj | 1 - samples/cs/tutorial-voice-to-text/Program.cs | 6 +++--- .../TutorialVoiceToText.csproj | 1 - 12 files changed, 30 insertions(+), 39 deletions(-) diff --git a/samples/cs/Directory.Packages.props b/samples/cs/Directory.Packages.props index e5ba306b..428cbbf8 100644 --- a/samples/cs/Directory.Packages.props +++ b/samples/cs/Directory.Packages.props @@ -7,7 +7,6 @@ - diff --git a/samples/cs/model-management-example/Program.cs b/samples/cs/model-management-example/Program.cs index a34d2737..f22d5211 100644 --- a/samples/cs/model-management-example/Program.cs +++ b/samples/cs/model-management-example/Program.cs @@ -1,5 +1,5 @@ using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; using System.Diagnostics; CancellationToken ct = new CancellationToken(); @@ -112,7 +112,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = "user", Content = "Why is the sky blue?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } }; // You can adjust settings on the chat client diff --git a/samples/cs/native-chat-completions/Program.cs b/samples/cs/native-chat-completions/Program.cs index 033786b1..9da668db 100644 --- a/samples/cs/native-chat-completions/Program.cs +++ b/samples/cs/native-chat-completions/Program.cs @@ -1,7 +1,7 @@ // // using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; // // @@ -90,7 +90,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = "user", Content = "Why is the sky blue?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } }; // Get a streaming chat completion response diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index 8ac96369..e654fc2a 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -1,9 +1,7 @@ // // using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; -using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; +using Microsoft.AI.Foundry.Local.OpenAI; using System.Text.Json; // @@ -65,8 +63,8 @@ await model.DownloadAsync(progress => // Prepare messages List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; @@ -76,7 +74,7 @@ await model.DownloadAsync(progress => [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -99,7 +97,7 @@ await model.DownloadAsync(progress => // // Get a streaming chat completion response -var toolCallResponses = new List(); +var toolCallResponses = new List(); Console.WriteLine("Chat completion response:"); var streamingResponse = chatClient.CompleteChatStreamingAsync(messages, tools, ct); await foreach (var chunk in streamingResponse) @@ -108,7 +106,7 @@ await model.DownloadAsync(progress => Console.Write(content); Console.Out.Flush(); - if (chunk.Choices[0].FinishReason == "tool_calls") + if (chunk.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponses.Add(chunk); } @@ -132,7 +130,7 @@ await model.DownloadAsync(progress => var response = new ChatMessage { - Role = "tool", + Role = ChatMessageRole.Tool, Content = result.ToString(), }; messages.Add(response); @@ -142,7 +140,7 @@ await model.DownloadAsync(progress => // Prompt the model to continue the conversation after the tool call -messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); +messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call diff --git a/samples/cs/tutorial-chat-assistant/Program.cs b/samples/cs/tutorial-chat-assistant/Program.cs index 10e9a63b..68c47d7d 100644 --- a/samples/cs/tutorial-chat-assistant/Program.cs +++ b/samples/cs/tutorial-chat-assistant/Program.cs @@ -1,7 +1,7 @@ // // using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; // @@ -48,7 +48,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a helpful, friendly assistant. Keep your responses " + "concise and conversational. If you don't know something, say so." } @@ -70,7 +70,7 @@ await model.DownloadAsync(progress => } // Add the user's message to conversation history - messages.Add(new ChatMessage { Role = "user", Content = userInput }); + messages.Add(new ChatMessage { Role = ChatMessageRole.User, Content = userInput }); // // Stream the response token by token @@ -91,7 +91,7 @@ await model.DownloadAsync(progress => // // Add the complete response to conversation history - messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); } // diff --git a/samples/cs/tutorial-chat-assistant/TutorialChatAssistant.csproj b/samples/cs/tutorial-chat-assistant/TutorialChatAssistant.csproj index a3533047..e48c209d 100644 --- a/samples/cs/tutorial-chat-assistant/TutorialChatAssistant.csproj +++ b/samples/cs/tutorial-chat-assistant/TutorialChatAssistant.csproj @@ -42,7 +42,6 @@ - diff --git a/samples/cs/tutorial-document-summarizer/Program.cs b/samples/cs/tutorial-document-summarizer/Program.cs index bc5546f6..14d0e7a1 100644 --- a/samples/cs/tutorial-document-summarizer/Program.cs +++ b/samples/cs/tutorial-document-summarizer/Program.cs @@ -1,7 +1,7 @@ // // using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; // @@ -75,8 +75,8 @@ async Task SummarizeFileAsync( var fileContent = await File.ReadAllTextAsync(filePath, token); var messages = new List { - new ChatMessage { Role = "system", Content = prompt }, - new ChatMessage { Role = "user", Content = fileContent } + new ChatMessage { Role = ChatMessageRole.System, Content = prompt }, + new ChatMessage { Role = ChatMessageRole.User, Content = fileContent } }; var response = await client.CompleteChatAsync(messages, token); diff --git a/samples/cs/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj b/samples/cs/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj index a3533047..e48c209d 100644 --- a/samples/cs/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj +++ b/samples/cs/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj @@ -42,7 +42,6 @@ - diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index 74f137db..ea59e679 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -2,9 +2,7 @@ // using System.Text.Json; using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; -using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; -using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; +using Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; // @@ -16,7 +14,7 @@ [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "get_weather", @@ -35,7 +33,7 @@ }, new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "calculate", @@ -142,7 +140,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a helpful assistant with access to tools. " + "Use them when needed to answer questions accurately." } @@ -165,7 +163,7 @@ await model.DownloadAsync(progress => messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = userInput }); @@ -193,7 +191,7 @@ await model.DownloadAsync(progress => ); messages.Add(new ChatMessage { - Role = "tool", + Role = ChatMessageRole.Tool, ToolCallId = toolCall.Id, Content = result }); @@ -205,7 +203,7 @@ await model.DownloadAsync(progress => var answer = finalResponse.Choices[0].Message.Content ?? ""; messages.Add(new ChatMessage { - Role = "assistant", + Role = ChatMessageRole.Assistant, Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); @@ -215,7 +213,7 @@ await model.DownloadAsync(progress => var answer = choice.Content ?? ""; messages.Add(new ChatMessage { - Role = "assistant", + Role = ChatMessageRole.Assistant, Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); diff --git a/samples/cs/tutorial-tool-calling/TutorialToolCalling.csproj b/samples/cs/tutorial-tool-calling/TutorialToolCalling.csproj index a3533047..e48c209d 100644 --- a/samples/cs/tutorial-tool-calling/TutorialToolCalling.csproj +++ b/samples/cs/tutorial-tool-calling/TutorialToolCalling.csproj @@ -42,7 +42,6 @@ - diff --git a/samples/cs/tutorial-voice-to-text/Program.cs b/samples/cs/tutorial-voice-to-text/Program.cs index 976b44e4..84882fa6 100644 --- a/samples/cs/tutorial-voice-to-text/Program.cs +++ b/samples/cs/tutorial-voice-to-text/Program.cs @@ -1,7 +1,7 @@ // // using Microsoft.AI.Foundry.Local; -using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; using System.Text; // @@ -81,14 +81,14 @@ await chatModel.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a note-taking assistant. Summarize " + "the following transcription into organized, " + "concise notes with bullet points." }, new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = transcriptionText.ToString() } }; diff --git a/samples/cs/tutorial-voice-to-text/TutorialVoiceToText.csproj b/samples/cs/tutorial-voice-to-text/TutorialVoiceToText.csproj index a3533047..e48c209d 100644 --- a/samples/cs/tutorial-voice-to-text/TutorialVoiceToText.csproj +++ b/samples/cs/tutorial-voice-to-text/TutorialVoiceToText.csproj @@ -42,7 +42,6 @@ - From 2b5106c7a8d476e656abf4dbd64ce896914649ee Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 17:47:37 -0500 Subject: [PATCH 03/35] Revert enums to strings for Role, Type, and FinishReason Align with JS, Python, and Rust SDKs which all use plain strings (or string literal unions) for these fields. Removes ChatMessageRole, ToolType, FinishReason, and ResponseErrorType enums. Using strings is simpler for users and consistent across all SDK languages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cs/model-management-example/Program.cs | 2 +- samples/cs/native-chat-completions/Program.cs | 4 +- .../tool-calling-foundry-local-sdk/Program.cs | 14 ++--- .../Program.cs | 4 +- samples/cs/tutorial-chat-assistant/Program.cs | 6 +-- .../tutorial-document-summarizer/Program.cs | 4 +- samples/cs/tutorial-tool-calling/Program.cs | 14 ++--- samples/cs/tutorial-voice-to-text/Program.cs | 4 +- sdk/cs/src/Detail/JsonSerializationContext.cs | 4 -- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 50 ++---------------- sdk/cs/src/OpenAI/ChatMessage.cs | 42 ++------------- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 4 +- .../ChatCompletionsTests.cs | 52 +++++++++---------- 13 files changed, 62 insertions(+), 142 deletions(-) diff --git a/samples/cs/model-management-example/Program.cs b/samples/cs/model-management-example/Program.cs index f22d5211..f06d2f8f 100644 --- a/samples/cs/model-management-example/Program.cs +++ b/samples/cs/model-management-example/Program.cs @@ -112,7 +112,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } + new ChatMessage { Role = "user", Content = "Why is the sky blue?" } }; // You can adjust settings on the chat client diff --git a/samples/cs/native-chat-completions/Program.cs b/samples/cs/native-chat-completions/Program.cs index 9da668db..0a98c84d 100644 --- a/samples/cs/native-chat-completions/Program.cs +++ b/samples/cs/native-chat-completions/Program.cs @@ -1,4 +1,4 @@ -// +// // using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.OpenAI; @@ -90,7 +90,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } + new ChatMessage { Role = "user", Content = "Why is the sky blue?" } }; // Get a streaming chat completion response diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index e654fc2a..92bd874b 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -1,4 +1,4 @@ -// +// // using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.OpenAI; @@ -63,8 +63,8 @@ await model.DownloadAsync(progress => // Prepare messages List messages = [ - new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } ]; @@ -74,7 +74,7 @@ await model.DownloadAsync(progress => [ new ToolDefinition { - Type = ToolType.Function, + Type = "function", Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -106,7 +106,7 @@ await model.DownloadAsync(progress => Console.Write(content); Console.Out.Flush(); - if (chunk.Choices[0].FinishReason == FinishReason.ToolCalls) + if (chunk.Choices[0].FinishReason == "tool_calls") { toolCallResponses.Add(chunk); } @@ -130,7 +130,7 @@ await model.DownloadAsync(progress => var response = new ChatMessage { - Role = ChatMessageRole.Tool, + Role = "tool", Content = result.ToString(), }; messages.Add(response); @@ -140,7 +140,7 @@ await model.DownloadAsync(progress => // Prompt the model to continue the conversation after the tool call -messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); +messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call diff --git a/samples/cs/tool-calling-foundry-local-web-server/Program.cs b/samples/cs/tool-calling-foundry-local-web-server/Program.cs index 48ee6c6f..ece64e2f 100644 --- a/samples/cs/tool-calling-foundry-local-web-server/Program.cs +++ b/samples/cs/tool-calling-foundry-local-web-server/Program.cs @@ -1,4 +1,4 @@ -// +// using Microsoft.AI.Foundry.Local; using OpenAI; using OpenAI.Chat; @@ -118,7 +118,7 @@ await model.DownloadAsync(progress => Console.Write(completionUpdate.ContentUpdate[0].Text); } - if (completionUpdate.FinishReason == ChatFinishReason.ToolCalls) + if (completionUpdate.FinishReason == Chat"tool_calls") { foreach (var toolCall in completionUpdate.ToolCallUpdates) { diff --git a/samples/cs/tutorial-chat-assistant/Program.cs b/samples/cs/tutorial-chat-assistant/Program.cs index 68c47d7d..52eb3e3b 100644 --- a/samples/cs/tutorial-chat-assistant/Program.cs +++ b/samples/cs/tutorial-chat-assistant/Program.cs @@ -48,7 +48,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = ChatMessageRole.System, + Role = "system", Content = "You are a helpful, friendly assistant. Keep your responses " + "concise and conversational. If you don't know something, say so." } @@ -70,7 +70,7 @@ await model.DownloadAsync(progress => } // Add the user's message to conversation history - messages.Add(new ChatMessage { Role = ChatMessageRole.User, Content = userInput }); + messages.Add(new ChatMessage { Role = "user", Content = userInput }); // // Stream the response token by token @@ -91,7 +91,7 @@ await model.DownloadAsync(progress => // // Add the complete response to conversation history - messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); + messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); } // diff --git a/samples/cs/tutorial-document-summarizer/Program.cs b/samples/cs/tutorial-document-summarizer/Program.cs index 14d0e7a1..11972827 100644 --- a/samples/cs/tutorial-document-summarizer/Program.cs +++ b/samples/cs/tutorial-document-summarizer/Program.cs @@ -75,8 +75,8 @@ async Task SummarizeFileAsync( var fileContent = await File.ReadAllTextAsync(filePath, token); var messages = new List { - new ChatMessage { Role = ChatMessageRole.System, Content = prompt }, - new ChatMessage { Role = ChatMessageRole.User, Content = fileContent } + new ChatMessage { Role = "system", Content = prompt }, + new ChatMessage { Role = "user", Content = fileContent } }; var response = await client.CompleteChatAsync(messages, token); diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index ea59e679..c6b33425 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -14,7 +14,7 @@ [ new ToolDefinition { - Type = ToolType.Function, + Type = "function", Function = new FunctionDefinition() { Name = "get_weather", @@ -33,7 +33,7 @@ }, new ToolDefinition { - Type = ToolType.Function, + Type = "function", Function = new FunctionDefinition() { Name = "calculate", @@ -140,7 +140,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = ChatMessageRole.System, + Role = "system", Content = "You are a helpful assistant with access to tools. " + "Use them when needed to answer questions accurately." } @@ -163,7 +163,7 @@ await model.DownloadAsync(progress => messages.Add(new ChatMessage { - Role = ChatMessageRole.User, + Role = "user", Content = userInput }); @@ -191,7 +191,7 @@ await model.DownloadAsync(progress => ); messages.Add(new ChatMessage { - Role = ChatMessageRole.Tool, + Role = "tool", ToolCallId = toolCall.Id, Content = result }); @@ -203,7 +203,7 @@ await model.DownloadAsync(progress => var answer = finalResponse.Choices[0].Message.Content ?? ""; messages.Add(new ChatMessage { - Role = ChatMessageRole.Assistant, + Role = "assistant", Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); @@ -213,7 +213,7 @@ await model.DownloadAsync(progress => var answer = choice.Content ?? ""; messages.Add(new ChatMessage { - Role = ChatMessageRole.Assistant, + Role = "assistant", Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); diff --git a/samples/cs/tutorial-voice-to-text/Program.cs b/samples/cs/tutorial-voice-to-text/Program.cs index 84882fa6..4ed5a2ea 100644 --- a/samples/cs/tutorial-voice-to-text/Program.cs +++ b/samples/cs/tutorial-voice-to-text/Program.cs @@ -81,14 +81,14 @@ await chatModel.DownloadAsync(progress => { new ChatMessage { - Role = ChatMessageRole.System, + Role = "system", Content = "You are a note-taking assistant. Summarize " + "the following transcription into organized, " + "concise notes with bullet points." }, new ChatMessage { - Role = ChatMessageRole.User, + Role = "user", Content = transcriptionText.ToString() } }; diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index e3037b04..0ba74eb8 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -19,12 +19,8 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ChatCompletionResponse))] [JsonSerializable(typeof(ChatChoice))] [JsonSerializable(typeof(ChatMessage))] -[JsonSerializable(typeof(ChatMessageRole))] -[JsonSerializable(typeof(ToolType))] -[JsonSerializable(typeof(FinishReason))] [JsonSerializable(typeof(CompletionUsage))] [JsonSerializable(typeof(ResponseError))] -[JsonSerializable(typeof(ResponseErrorType))] [JsonSerializable(typeof(AudioTranscriptionRequest))] [JsonSerializable(typeof(AudioTranscriptionRequestExtended))] [JsonSerializable(typeof(AudioTranscriptionResponse))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index cf5539ce..caafd343 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -9,29 +9,6 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; using System.Text.Json.Serialization; -/// -/// Reason the model stopped generating tokens. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum FinishReason -{ - /// The model finished naturally or hit a stop sequence. - [JsonStringEnumMemberName("stop")] - Stop, - - /// The model hit the maximum token limit. - [JsonStringEnumMemberName("length")] - Length, - - /// The model is requesting tool calls. - [JsonStringEnumMemberName("tool_calls")] - ToolCalls, - - /// Content was filtered by safety policy. - [JsonStringEnumMemberName("content_filter")] - ContentFilter -} - /// /// Response from a chat completion request. /// @@ -87,9 +64,9 @@ public class ChatChoice [JsonPropertyName("delta")] public ChatMessage? Delta { get; set; } - /// The reason the model stopped generating. + /// The reason the model stopped generating (e.g. "stop", "length", "tool_calls", "content_filter"). [JsonPropertyName("finish_reason")] - public FinishReason? FinishReason { get; set; } + public string? FinishReason { get; set; } } /// @@ -110,25 +87,6 @@ public class CompletionUsage public int TotalTokens { get; set; } } -/// -/// The type of error returned by the API. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ResponseErrorType -{ - /// The request was invalid (bad parameters, missing fields, etc.). - [JsonStringEnumMemberName("invalid_request_error")] - InvalidRequestError, - - /// An internal server error occurred. - [JsonStringEnumMemberName("server_error")] - ServerError, - - /// A general error. - [JsonStringEnumMemberName("error")] - Error -} - /// /// Error information returned by the API. /// @@ -142,9 +100,9 @@ public class ResponseError [JsonPropertyName("message")] public string? Message { get; set; } - /// The error type. + /// The error type (e.g. "invalid_request_error", "server_error"). [JsonPropertyName("type")] - public ResponseErrorType? Type { get; set; } + public string? Type { get; set; } /// The parameter that caused the error. [JsonPropertyName("param")] diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 7e8b6edf..48465c49 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -9,48 +9,14 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; using System.Text.Json.Serialization; -/// -/// Role of a chat message author. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ChatMessageRole -{ - /// System instruction message. - [JsonStringEnumMemberName("system")] - System, - - /// User input message. - [JsonStringEnumMemberName("user")] - User, - - /// Assistant response message. - [JsonStringEnumMemberName("assistant")] - Assistant, - - /// Tool result message. - [JsonStringEnumMemberName("tool")] - Tool -} - -/// -/// Type of tool call or tool definition. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ToolType -{ - /// A function tool. - [JsonStringEnumMemberName("function")] - Function -} - /// /// Represents a chat message in a conversation. /// public class ChatMessage { - /// The role of the message author. + /// The role of the message author (e.g. "system", "user", "assistant", "tool"). [JsonPropertyName("role")] - public ChatMessageRole Role { get; set; } + public string? Role { get; set; } /// The text content of the message. [JsonPropertyName("content")] @@ -86,9 +52,9 @@ public class ToolCall [JsonPropertyName("id")] public string? Id { get; set; } - /// The type of tool call. + /// The type of tool call (e.g. "function"). [JsonPropertyName("type")] - public ToolType Type { get; set; } + public string? Type { get; set; } /// The function that the model called. [JsonPropertyName("function")] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 28b9c768..54f3c2d4 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -16,9 +16,9 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// public class ToolDefinition { - /// The type of tool. + /// The type of tool (e.g. "function"). [JsonPropertyName("type")] - public ToolType Type { get; set; } + public string? Type { get; set; } /// The function definition. [JsonPropertyName("function")] diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 869d70c3..0a754efc 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -43,7 +43,7 @@ public async Task DirectChat_NoStreaming_Succeeds() List messages = [ // System prompt is setup by GenAI - new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var response = await chatClient.CompleteChatAsync(messages).ConfigureAwait(false); @@ -52,16 +52,16 @@ public async Task DirectChat_NoStreaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); + await Assert.That(message.Role).IsEqualTo("assistant"); await Assert.That(message.Content).IsNotNull(); await Assert.That(message.Content).Contains("42"); Console.WriteLine($"Response: {message.Content}"); - messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = message.Content }); + messages.Add(new ChatMessage { Role = "assistant", Content = message.Content }); messages.Add(new ChatMessage { - Role = ChatMessageRole.User, + Role = "user", Content = "Is the answer a real number?" }); @@ -82,7 +82,7 @@ public async Task DirectChat_Streaming_Succeeds() List messages = [ - new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var updates = chatClient.CompleteChatStreamingAsync(messages, CancellationToken.None).ConfigureAwait(false); @@ -94,7 +94,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); + await Assert.That(message.Role).IsEqualTo("assistant"); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -103,10 +103,10 @@ public async Task DirectChat_Streaming_Succeeds() Console.WriteLine(fullResponse); await Assert.That(fullResponse).Contains("42"); - messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); + messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); messages.Add(new ChatMessage { - Role = ChatMessageRole.User, + Role = "user", Content = "Add 25 to the previous answer. Think hard to be sure of the answer." }); @@ -118,7 +118,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); + await Assert.That(message.Role).IsEqualTo("assistant"); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -141,14 +141,14 @@ public async Task DirectTool_NoStreaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = ToolType.Function, + Type = "function", Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -175,7 +175,7 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response).IsNotNull(); await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); await Assert.That(response.Choices.Count).IsEqualTo(1); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo("tool_calls"); await Assert.That(response.Choices[0].Message).IsNotNull(); await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); @@ -186,8 +186,8 @@ public async Task DirectTool_NoStreaming_Succeeds() var expectedResponse = "" + toolCall + ""; await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); - await Assert.That(response.Choices[0].Message.ToolCalls![0].Type).IsEqualTo(ToolType.Function); - await Assert.That(response.Choices[0].Message.ToolCalls![0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); @@ -195,10 +195,10 @@ public async Task DirectTool_NoStreaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolCallResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolCallResponse }); + messages.Add(new ChatMessage { Role = "tool", Content = toolCallResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt @@ -225,14 +225,14 @@ public async Task DirectTool_Streaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = ToolType.Function, + Type = "function", Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -271,7 +271,7 @@ public async Task DirectTool_Streaming_Succeeds() responseMessage.Append(content); numTokens += 1; } - if (response.Choices[0].FinishReason == FinishReason.ToolCalls) + if (response.Choices[0].FinishReason == "tool_calls") { toolCallResponse = response; } @@ -287,11 +287,11 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(fullResponse).IsNotNull(); await Assert.That(fullResponse).IsEqualTo(expectedResponse); await Assert.That(toolCallResponse?.Choices.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); + await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo("tool_calls"); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls![0].Type).IsEqualTo(ToolType.Function); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls![0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); @@ -299,10 +299,10 @@ public async Task DirectTool_Streaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolResponse }); + messages.Add(new ChatMessage { Role = "tool", Content = toolResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt From 94282ada246286c60e580bf34e2abb9c1ca92ed5 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 18:57:54 -0500 Subject: [PATCH 04/35] Fix CI failures and address Copilot review comments C# SDK: - Fix AOT/trimming errors in ToolChoiceConverter: use source-generated JsonSerializationContext instead of generic JsonSerializer overloads - Register ToolChoice.FunctionTool in JsonSerializationContext - Fix AudioTranscriptionRequestResponseTypes.ToJson() to serialize AudioTranscriptionRequestExtended with correct type info so metadata is not dropped (Copilot review comment) Rust SDK: - Restore pub visibility for openai and chat_types modules (samples and external consumers depend on these paths) - Move tokio-stream back to regular dependencies (dev-deps are not available during cargo doc, breaking rustdoc links) Samples: - Fix corrupted FinishReason check in tool-calling-foundry-local-web-server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/cs/tool-calling-foundry-local-web-server/Program.cs | 2 +- sdk/cs/src/Detail/JsonSerializationContext.cs | 1 + sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs | 6 ++++++ sdk/cs/src/OpenAI/ToolCallingTypes.cs | 6 ++++-- sdk/rust/Cargo.toml | 4 +--- sdk/rust/src/lib.rs | 4 ++-- sdk/rust/src/openai/mod.rs | 2 +- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/samples/cs/tool-calling-foundry-local-web-server/Program.cs b/samples/cs/tool-calling-foundry-local-web-server/Program.cs index ece64e2f..666d9212 100644 --- a/samples/cs/tool-calling-foundry-local-web-server/Program.cs +++ b/samples/cs/tool-calling-foundry-local-web-server/Program.cs @@ -118,7 +118,7 @@ await model.DownloadAsync(progress => Console.Write(completionUpdate.ContentUpdate[0].Text); } - if (completionUpdate.FinishReason == Chat"tool_calls") + if (completionUpdate.FinishReason == ChatFinishReason.ToolCalls) { foreach (var toolCall in completionUpdate.ToolCallUpdates) { diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 0ba74eb8..47da7112 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -31,6 +31,7 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ResponseFormat))] [JsonSerializable(typeof(ResponseFormatExtended))] [JsonSerializable(typeof(ToolChoice))] +[JsonSerializable(typeof(ToolChoice.FunctionTool))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(FunctionDefinition))] diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index d524750e..9217e5c2 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -62,6 +62,12 @@ internal static class AudioTranscriptionRequestResponseExtensions { internal static string ToJson(this AudioTranscriptionRequest request) { + if (request is AudioTranscriptionRequestExtended extendedRequest) + { + return JsonSerializer.Serialize(extendedRequest, + JsonSerializationContext.Default.AudioTranscriptionRequestExtended); + } + return JsonSerializer.Serialize(request, JsonSerializationContext.Default.AudioTranscriptionRequest); } diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 54f3c2d4..07a6d58e 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -11,6 +11,8 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AI.Foundry.Local.Detail; + /// /// Definition of a tool the model may call. /// @@ -174,7 +176,7 @@ internal class ToolChoiceConverter : JsonConverter choice.Type = reader.GetString(); break; case "function": - choice.Function = JsonSerializer.Deserialize(ref reader, options); + choice.Function = JsonSerializer.Deserialize(ref reader, JsonSerializationContext.Default.FunctionTool); break; default: reader.Skip(); @@ -195,7 +197,7 @@ public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializ writer.WriteStartObject(); writer.WriteString("type", value.Type); writer.WritePropertyName("function"); - JsonSerializer.Serialize(writer, value.Function, options); + JsonSerializer.Serialize(writer, value.Function, JsonSerializationContext.Default.FunctionTool); writer.WriteEndObject(); } } diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index d45b8d3f..2f168bf8 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -20,13 +20,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +tokio-stream = "0.1" futures-core = "0.3" reqwest = { version = "0.12", features = ["json"] } urlencoding = "2" -[dev-dependencies] -tokio-stream = "0.1" - [build-dependencies] ureq = "3" zip = "2" diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index c8c1bb94..6fa74da4 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -8,8 +8,8 @@ mod error; mod foundry_local_manager; mod types; -pub(crate) mod detail; -pub(crate) mod openai; +pub mod detail; +pub mod openai; pub use self::catalog::Catalog; pub use self::configuration::{FoundryLocalConfig, LogLevel, Logger}; diff --git a/sdk/rust/src/openai/mod.rs b/sdk/rust/src/openai/mod.rs index 714ae50f..0cfe5a1d 100644 --- a/sdk/rust/src/openai/mod.rs +++ b/sdk/rust/src/openai/mod.rs @@ -1,5 +1,5 @@ mod audio_client; -pub(crate) mod chat_types; +pub mod chat_types; mod chat_client; mod json_stream; From 20976e940fb0d8ac4d542fadecaebfb64e642b66 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 19:35:26 -0500 Subject: [PATCH 05/35] Add C# enums matching official .NET OpenAI SDK patterns; add Invalid to Python DeviceType - Re-add ChatMessageRole, ToolType, FinishReason enums to C# SDK - Use JsonStringEnumConverter with JsonStringEnumMemberName for AOT compat - Register enums in JsonSerializationContext - Update all tests and samples to use enum values - Add Invalid member to Python DeviceType for cross-SDK consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cs/model-management-example/Program.cs | 2 +- samples/cs/native-chat-completions/Program.cs | 2 +- .../tool-calling-foundry-local-sdk/Program.cs | 12 ++--- samples/cs/tutorial-chat-assistant/Program.cs | 6 +-- .../tutorial-document-summarizer/Program.cs | 4 +- samples/cs/tutorial-tool-calling/Program.cs | 14 +++--- samples/cs/tutorial-voice-to-text/Program.cs | 4 +- sdk/cs/src/Detail/JsonSerializationContext.cs | 3 ++ sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 27 ++++++++++- sdk/cs/src/OpenAI/ChatMessage.cs | 42 ++++++++++++++-- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 4 +- .../ChatCompletionsTests.cs | 48 +++++++++---------- sdk/python/src/detail/model_data_types.py | 1 + 13 files changed, 115 insertions(+), 54 deletions(-) diff --git a/samples/cs/model-management-example/Program.cs b/samples/cs/model-management-example/Program.cs index f06d2f8f..f22d5211 100644 --- a/samples/cs/model-management-example/Program.cs +++ b/samples/cs/model-management-example/Program.cs @@ -112,7 +112,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = "user", Content = "Why is the sky blue?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } }; // You can adjust settings on the chat client diff --git a/samples/cs/native-chat-completions/Program.cs b/samples/cs/native-chat-completions/Program.cs index 0a98c84d..8369ef96 100644 --- a/samples/cs/native-chat-completions/Program.cs +++ b/samples/cs/native-chat-completions/Program.cs @@ -90,7 +90,7 @@ await model.DownloadAsync(progress => // Create a chat message List messages = new() { - new ChatMessage { Role = "user", Content = "Why is the sky blue?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "Why is the sky blue?" } }; // Get a streaming chat completion response diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index 92bd874b..f912913a 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -63,8 +63,8 @@ await model.DownloadAsync(progress => // Prepare messages List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; @@ -74,7 +74,7 @@ await model.DownloadAsync(progress => [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -106,7 +106,7 @@ await model.DownloadAsync(progress => Console.Write(content); Console.Out.Flush(); - if (chunk.Choices[0].FinishReason == "tool_calls") + if (chunk.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponses.Add(chunk); } @@ -130,7 +130,7 @@ await model.DownloadAsync(progress => var response = new ChatMessage { - Role = "tool", + Role = ChatMessageRole.Tool, Content = result.ToString(), }; messages.Add(response); @@ -140,7 +140,7 @@ await model.DownloadAsync(progress => // Prompt the model to continue the conversation after the tool call -messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); +messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call diff --git a/samples/cs/tutorial-chat-assistant/Program.cs b/samples/cs/tutorial-chat-assistant/Program.cs index 52eb3e3b..68c47d7d 100644 --- a/samples/cs/tutorial-chat-assistant/Program.cs +++ b/samples/cs/tutorial-chat-assistant/Program.cs @@ -48,7 +48,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a helpful, friendly assistant. Keep your responses " + "concise and conversational. If you don't know something, say so." } @@ -70,7 +70,7 @@ await model.DownloadAsync(progress => } // Add the user's message to conversation history - messages.Add(new ChatMessage { Role = "user", Content = userInput }); + messages.Add(new ChatMessage { Role = ChatMessageRole.User, Content = userInput }); // // Stream the response token by token @@ -91,7 +91,7 @@ await model.DownloadAsync(progress => // // Add the complete response to conversation history - messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); } // diff --git a/samples/cs/tutorial-document-summarizer/Program.cs b/samples/cs/tutorial-document-summarizer/Program.cs index 11972827..14d0e7a1 100644 --- a/samples/cs/tutorial-document-summarizer/Program.cs +++ b/samples/cs/tutorial-document-summarizer/Program.cs @@ -75,8 +75,8 @@ async Task SummarizeFileAsync( var fileContent = await File.ReadAllTextAsync(filePath, token); var messages = new List { - new ChatMessage { Role = "system", Content = prompt }, - new ChatMessage { Role = "user", Content = fileContent } + new ChatMessage { Role = ChatMessageRole.System, Content = prompt }, + new ChatMessage { Role = ChatMessageRole.User, Content = fileContent } }; var response = await client.CompleteChatAsync(messages, token); diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index c6b33425..ea59e679 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -14,7 +14,7 @@ [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "get_weather", @@ -33,7 +33,7 @@ }, new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "calculate", @@ -140,7 +140,7 @@ await model.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a helpful assistant with access to tools. " + "Use them when needed to answer questions accurately." } @@ -163,7 +163,7 @@ await model.DownloadAsync(progress => messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = userInput }); @@ -191,7 +191,7 @@ await model.DownloadAsync(progress => ); messages.Add(new ChatMessage { - Role = "tool", + Role = ChatMessageRole.Tool, ToolCallId = toolCall.Id, Content = result }); @@ -203,7 +203,7 @@ await model.DownloadAsync(progress => var answer = finalResponse.Choices[0].Message.Content ?? ""; messages.Add(new ChatMessage { - Role = "assistant", + Role = ChatMessageRole.Assistant, Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); @@ -213,7 +213,7 @@ await model.DownloadAsync(progress => var answer = choice.Content ?? ""; messages.Add(new ChatMessage { - Role = "assistant", + Role = ChatMessageRole.Assistant, Content = answer }); Console.WriteLine($"Assistant: {answer}\n"); diff --git a/samples/cs/tutorial-voice-to-text/Program.cs b/samples/cs/tutorial-voice-to-text/Program.cs index 4ed5a2ea..84882fa6 100644 --- a/samples/cs/tutorial-voice-to-text/Program.cs +++ b/samples/cs/tutorial-voice-to-text/Program.cs @@ -81,14 +81,14 @@ await chatModel.DownloadAsync(progress => { new ChatMessage { - Role = "system", + Role = ChatMessageRole.System, Content = "You are a note-taking assistant. Summarize " + "the following transcription into organized, " + "concise notes with bullet points." }, new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = transcriptionText.ToString() } }; diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 47da7112..74e13a87 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -19,6 +19,9 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ChatCompletionResponse))] [JsonSerializable(typeof(ChatChoice))] [JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(ChatMessageRole))] +[JsonSerializable(typeof(ToolType))] +[JsonSerializable(typeof(FinishReason))] [JsonSerializable(typeof(CompletionUsage))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index caafd343..34614220 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -9,6 +9,29 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; using System.Text.Json.Serialization; +/// +/// Reason the model stopped generating tokens. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FinishReason +{ + /// The model finished naturally or hit a stop sequence. + [JsonStringEnumMemberName("stop")] + Stop, + + /// The model hit the maximum token limit. + [JsonStringEnumMemberName("length")] + Length, + + /// The model is requesting tool calls. + [JsonStringEnumMemberName("tool_calls")] + ToolCalls, + + /// Content was filtered by safety policy. + [JsonStringEnumMemberName("content_filter")] + ContentFilter +} + /// /// Response from a chat completion request. /// @@ -64,9 +87,9 @@ public class ChatChoice [JsonPropertyName("delta")] public ChatMessage? Delta { get; set; } - /// The reason the model stopped generating (e.g. "stop", "length", "tool_calls", "content_filter"). + /// The reason the model stopped generating. [JsonPropertyName("finish_reason")] - public string? FinishReason { get; set; } + public FinishReason? FinishReason { get; set; } } /// diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 48465c49..50f47526 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -9,14 +9,48 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; using System.Text.Json.Serialization; +/// +/// Role of a chat message author. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChatMessageRole +{ + /// System instruction message. + [JsonStringEnumMemberName("system")] + System, + + /// User input message. + [JsonStringEnumMemberName("user")] + User, + + /// Assistant response message. + [JsonStringEnumMemberName("assistant")] + Assistant, + + /// Tool result message. + [JsonStringEnumMemberName("tool")] + Tool +} + +/// +/// Type of tool call or tool definition. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ToolType +{ + /// A function tool. + [JsonStringEnumMemberName("function")] + Function +} + /// /// Represents a chat message in a conversation. /// public class ChatMessage { - /// The role of the message author (e.g. "system", "user", "assistant", "tool"). + /// The role of the message author. [JsonPropertyName("role")] - public string? Role { get; set; } + public ChatMessageRole Role { get; set; } /// The text content of the message. [JsonPropertyName("content")] @@ -52,9 +86,9 @@ public class ToolCall [JsonPropertyName("id")] public string? Id { get; set; } - /// The type of tool call (e.g. "function"). + /// The type of tool call. [JsonPropertyName("type")] - public string? Type { get; set; } + public ToolType? Type { get; set; } /// The function that the model called. [JsonPropertyName("function")] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 07a6d58e..8c3749ae 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -18,9 +18,9 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// public class ToolDefinition { - /// The type of tool (e.g. "function"). + /// The type of tool. [JsonPropertyName("type")] - public string? Type { get; set; } + public ToolType? Type { get; set; } /// The function definition. [JsonPropertyName("function")] diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 0a754efc..f98f1601 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -43,7 +43,7 @@ public async Task DirectChat_NoStreaming_Succeeds() List messages = [ // System prompt is setup by GenAI - new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var response = await chatClient.CompleteChatAsync(messages).ConfigureAwait(false); @@ -52,16 +52,16 @@ public async Task DirectChat_NoStreaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); await Assert.That(message.Content).Contains("42"); Console.WriteLine($"Response: {message.Content}"); - messages.Add(new ChatMessage { Role = "assistant", Content = message.Content }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = message.Content }); messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = "Is the answer a real number?" }); @@ -82,7 +82,7 @@ public async Task DirectChat_Streaming_Succeeds() List messages = [ - new ChatMessage { Role = "user", Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.User, Content = "You are a calculator. Be precise. What is the answer to 7 multiplied by 6?" } ]; var updates = chatClient.CompleteChatStreamingAsync(messages, CancellationToken.None).ConfigureAwait(false); @@ -94,7 +94,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -103,10 +103,10 @@ public async Task DirectChat_Streaming_Succeeds() Console.WriteLine(fullResponse); await Assert.That(fullResponse).Contains("42"); - messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Assistant, Content = fullResponse }); messages.Add(new ChatMessage { - Role = "user", + Role = ChatMessageRole.User, Content = "Add 25 to the previous answer. Think hard to be sure of the answer." }); @@ -118,7 +118,7 @@ public async Task DirectChat_Streaming_Succeeds() await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); var message = response.Choices[0].Message; await Assert.That(message).IsNotNull(); - await Assert.That(message.Role).IsEqualTo("assistant"); + await Assert.That(message.Role).IsEqualTo(ChatMessageRole.Assistant); await Assert.That(message.Content).IsNotNull(); responseMessage.Append(message.Content); } @@ -141,14 +141,14 @@ public async Task DirectTool_NoStreaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -175,7 +175,7 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response).IsNotNull(); await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); await Assert.That(response.Choices.Count).IsEqualTo(1); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo("tool_calls"); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(response.Choices[0].Message).IsNotNull(); await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); @@ -186,7 +186,7 @@ public async Task DirectTool_NoStreaming_Succeeds() var expectedResponse = "" + toolCall + ""; await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; @@ -195,10 +195,10 @@ public async Task DirectTool_NoStreaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolCallResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = "tool", Content = toolCallResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolCallResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt @@ -225,14 +225,14 @@ public async Task DirectTool_Streaming_Succeeds() // Prepare messages and tools List messages = [ - new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, - new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } + new ChatMessage { Role = ChatMessageRole.System, Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = ChatMessageRole.User, Content = "What is the answer to 7 multiplied by 6?" } ]; List tools = [ new ToolDefinition { - Type = "function", + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -271,7 +271,7 @@ public async Task DirectTool_Streaming_Succeeds() responseMessage.Append(content); numTokens += 1; } - if (response.Choices[0].FinishReason == "tool_calls") + if (response.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponse = response; } @@ -287,10 +287,10 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(fullResponse).IsNotNull(); await Assert.That(fullResponse).IsEqualTo(expectedResponse); await Assert.That(toolCallResponse?.Choices.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo("tool_calls"); + await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo("function"); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; @@ -299,10 +299,10 @@ public async Task DirectTool_Streaming_Succeeds() // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolResponse = "7 x 6 = 42."; - messages.Add(new ChatMessage { Role = "tool", Content = toolResponse }); + messages.Add(new ChatMessage { Role = ChatMessageRole.Tool, Content = toolResponse }); // Prompt the model to continue the conversation after the tool call - messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); + messages.Add(new ChatMessage { Role = ChatMessageRole.System, Content = "Respond only with the answer generated by the tool." }); // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt diff --git a/sdk/python/src/detail/model_data_types.py b/sdk/python/src/detail/model_data_types.py index 46525dc7..95132f9e 100644 --- a/sdk/python/src/detail/model_data_types.py +++ b/sdk/python/src/detail/model_data_types.py @@ -12,6 +12,7 @@ class DeviceType(StrEnum): """Device types supported by model variants.""" + Invalid = "Invalid" CPU = "CPU" GPU = "GPU" NPU = "NPU" From 1c42d9e03b1155c331024cf7ef16ab2c11d2d405 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 19:47:04 -0500 Subject: [PATCH 06/35] Apply code formatting across C# SDK and Rust SDK - Run dotnet format on C# SDK (19 files reformatted per .editorconfig) - Run cargo fmt on Rust SDK (import ordering per .rustfmt.toml) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Catalog.cs | 16 +-- sdk/cs/src/Detail/AsyncLock.cs | 1 + sdk/cs/src/Detail/CoreInterop.cs | 18 ++-- sdk/cs/src/Detail/CoreInteropRequest.cs | 3 +- sdk/cs/src/Detail/ICoreInterop.cs | 10 +- sdk/cs/src/Detail/IModelLoadManager.cs | 1 + sdk/cs/src/Detail/Model.cs | 2 +- sdk/cs/src/EpInfo.cs | 2 +- sdk/cs/src/FoundryLocalException.cs | 1 + sdk/cs/src/FoundryLocalManager.cs | 98 +++++++++---------- sdk/cs/src/FoundryModelInfo.cs | 2 +- sdk/cs/src/GlobalSuppressions.cs | 9 +- sdk/cs/src/ICatalog.cs | 1 + sdk/cs/src/OpenAI/AudioClient.cs | 2 +- sdk/cs/src/OpenAI/AudioTypes.cs | 2 +- sdk/cs/src/OpenAI/ChatClient.cs | 4 +- .../ChatCompletionRequestResponseTypes.cs | 3 + sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 2 +- sdk/cs/src/OpenAI/ChatMessage.cs | 2 +- .../OpenAI/LiveAudioTranscriptionClient.cs | 7 +- .../src/OpenAI/LiveAudioTranscriptionTypes.cs | 9 +- sdk/cs/src/OpenAI/ToolCallingExtensions.cs | 2 +- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 12 ++- sdk/cs/src/Utils.cs | 8 +- .../test/FoundryLocal.Tests/CatalogTests.cs | 1 + .../ChatCompletionsTests.cs | 2 +- sdk/cs/test/FoundryLocal.Tests/EndToEnd.cs | 1 + .../FoundryLocalManagerTest.cs | 8 +- .../LiveAudioTranscriptionTests.cs | 33 +++---- .../FoundryLocal.Tests/SkipInCIAttribute.cs | 4 +- .../TestAssemblySetupCleanup.cs | 1 + sdk/cs/test/FoundryLocal.Tests/Utils.cs | 16 +-- sdk/rust/src/lib.rs | 8 +- sdk/rust/src/openai/mod.rs | 2 +- 34 files changed, 153 insertions(+), 140 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index f33dcaff..21ca82bd 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local; + using System; using System.Collections.Generic; using System.Text.Json; @@ -15,8 +16,8 @@ namespace Microsoft.AI.Foundry.Local; internal sealed class Catalog : ICatalog, IDisposable { - private readonly Dictionary _modelAliasToModel = new(); - private readonly Dictionary _modelIdToModelVariant = new(); + private readonly Dictionary _modelAliasToModel = []; + private readonly Dictionary _modelIdToModelVariant = []; private DateTime _lastFetch; private readonly IModelLoadManager _modelLoadManager; @@ -163,14 +164,7 @@ private async Task GetLatestVersionImplAsync(IModel modelOrModelVariant, else { // Try to use the concrete Model instance if this is our SDK type. - model = modelOrModelVariant as Model; - - // If this is a different IModel implementation (e.g., a test stub), - // fall back to resolving the Model via alias. - if (model == null) - { - model = await GetModelImplAsync(modelOrModelVariant.Alias, ct); - } + model = modelOrModelVariant as Model ?? await GetModelImplAsync(modelOrModelVariant.Alias, ct); } if (model == null) @@ -180,7 +174,7 @@ private async Task GetLatestVersionImplAsync(IModel modelOrModelVariant, } // variants are sorted by version, so the first one matching the name is the latest version for that variant. - var latest = model!.Variants.FirstOrDefault(v => v.Info.Name == modelOrModelVariant.Info.Name) ?? + var latest = model.Variants.FirstOrDefault(v => v.Info.Name == modelOrModelVariant.Info.Name) ?? // should not be possible given we internally manage all the state involved throw new FoundryLocalException($"Internal error. Mismatch between model (alias:{model.Alias}) and " + $"model variant (alias:{modelOrModelVariant.Alias}).", _logger); diff --git a/sdk/cs/src/Detail/AsyncLock.cs b/sdk/cs/src/Detail/AsyncLock.cs index 921d7f98..c8b174d1 100644 --- a/sdk/cs/src/Detail/AsyncLock.cs +++ b/sdk/cs/src/Detail/AsyncLock.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Detail; + using System; using System.Threading.Tasks; diff --git a/sdk/cs/src/Detail/CoreInterop.cs b/sdk/cs/src/Detail/CoreInterop.cs index a7a43447..06729b95 100644 --- a/sdk/cs/src/Detail/CoreInterop.cs +++ b/sdk/cs/src/Detail/CoreInterop.cs @@ -240,9 +240,9 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, { try { - byte[] commandBytes = System.Text.Encoding.UTF8.GetBytes(commandName); + var commandBytes = System.Text.Encoding.UTF8.GetBytes(commandName); // Allocate unmanaged memory for the command bytes - IntPtr commandPtr = Marshal.AllocHGlobal(commandBytes.Length); + var commandPtr = Marshal.AllocHGlobal(commandBytes.Length); Marshal.Copy(commandBytes, 0, commandPtr, commandBytes.Length); byte[]? inputBytes = null; @@ -305,7 +305,7 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, // Marshal response. Will have either Data or Error populated. Not both. if (response.Data != IntPtr.Zero && response.DataLength > 0) { - byte[] managedResponse = new byte[response.DataLength]; + var managedResponse = new byte[response.DataLength]; Marshal.Copy(response.Data, managedResponse, 0, response.DataLength); result.Data = System.Text.Encoding.UTF8.GetString(managedResponse); _logger.LogDebug($"Command: {commandName} succeeded."); @@ -374,14 +374,14 @@ private Response MarshalResponse(ResponseBuffer response) if (response.Data != IntPtr.Zero && response.DataLength > 0) { - byte[] managedResponse = new byte[response.DataLength]; + var managedResponse = new byte[response.DataLength]; Marshal.Copy(response.Data, managedResponse, 0, response.DataLength); result.Data = System.Text.Encoding.UTF8.GetString(managedResponse); } if (response.Error != IntPtr.Zero && response.ErrorLength > 0) { - result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength)!; + result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength); } Marshal.FreeHGlobal(response.Data); @@ -405,13 +405,13 @@ public Response PushAudioData(CoreInteropRequest request, ReadOnlyMemory a try { var commandInputJson = request.ToJson(); - byte[] commandBytes = System.Text.Encoding.UTF8.GetBytes("audio_stream_push"); - byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(commandInputJson); + var commandBytes = System.Text.Encoding.UTF8.GetBytes("audio_stream_push"); + var inputBytes = System.Text.Encoding.UTF8.GetBytes(commandInputJson); - IntPtr commandPtr = Marshal.AllocHGlobal(commandBytes.Length); + var commandPtr = Marshal.AllocHGlobal(commandBytes.Length); Marshal.Copy(commandBytes, 0, commandPtr, commandBytes.Length); - IntPtr inputPtr = Marshal.AllocHGlobal(inputBytes.Length); + var inputPtr = Marshal.AllocHGlobal(inputBytes.Length); Marshal.Copy(inputBytes, 0, inputPtr, inputBytes.Length); // Pin the managed audio data so GC won't move it during the native call diff --git a/sdk/cs/src/Detail/CoreInteropRequest.cs b/sdk/cs/src/Detail/CoreInteropRequest.cs index 50365ad0..2936c2fd 100644 --- a/sdk/cs/src/Detail/CoreInteropRequest.cs +++ b/sdk/cs/src/Detail/CoreInteropRequest.cs @@ -5,12 +5,13 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Detail; + using System.Collections.Generic; using System.Text.Json; public class CoreInteropRequest { - public Dictionary Params { get; set; } = new(); + public Dictionary Params { get; set; } = []; } internal static class RequestExtensions diff --git a/sdk/cs/src/Detail/ICoreInterop.cs b/sdk/cs/src/Detail/ICoreInterop.cs index b493dfb7..489ed8b9 100644 --- a/sdk/cs/src/Detail/ICoreInterop.cs +++ b/sdk/cs/src/Detail/ICoreInterop.cs @@ -19,10 +19,10 @@ internal record Response internal string? Error; } - public delegate void CallbackFn(string callbackData); + delegate void CallbackFn(string callbackData); [StructLayout(LayoutKind.Sequential)] - protected unsafe struct RequestBuffer + protected struct RequestBuffer { public nint Command; public int CommandLength; @@ -31,7 +31,7 @@ protected unsafe struct RequestBuffer } [StructLayout(LayoutKind.Sequential)] - protected unsafe struct ResponseBuffer + protected struct ResponseBuffer { public nint Data; public int DataLength; @@ -41,7 +41,7 @@ protected unsafe struct ResponseBuffer // native callback function signature [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - protected unsafe delegate void NativeCallbackFn(nint data, int length, nint userData); + protected delegate void NativeCallbackFn(nint data, int length, nint userData); Response ExecuteCommand(string commandName, CoreInteropRequest? commandInput = null); Response ExecuteCommandWithCallback(string commandName, CoreInteropRequest? commandInput, CallbackFn callback); @@ -55,7 +55,7 @@ Task ExecuteCommandWithCallbackAsync(string commandName, CoreInteropRe // --- Audio streaming session support --- [StructLayout(LayoutKind.Sequential)] - protected unsafe struct StreamingRequestBuffer + protected struct StreamingRequestBuffer { public nint Command; public int CommandLength; diff --git a/sdk/cs/src/Detail/IModelLoadManager.cs b/sdk/cs/src/Detail/IModelLoadManager.cs index a96c6697..cd11f1f3 100644 --- a/sdk/cs/src/Detail/IModelLoadManager.cs +++ b/sdk/cs/src/Detail/IModelLoadManager.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Detail; + using System.Threading.Tasks; /// diff --git a/sdk/cs/src/Detail/Model.cs b/sdk/cs/src/Detail/Model.cs index c4d96057..e72dd608 100644 --- a/sdk/cs/src/Detail/Model.cs +++ b/sdk/cs/src/Detail/Model.cs @@ -14,7 +14,7 @@ public class Model : IModel private readonly List _variants; public IReadOnlyList Variants => _variants; - internal IModel SelectedVariant { get; set; } = default!; + internal IModel SelectedVariant { get; set; } public string Alias { get; init; } public string Id => SelectedVariant.Id; diff --git a/sdk/cs/src/EpInfo.cs b/sdk/cs/src/EpInfo.cs index d170ac0e..db853c69 100644 --- a/sdk/cs/src/EpInfo.cs +++ b/sdk/cs/src/EpInfo.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/FoundryLocalException.cs b/sdk/cs/src/FoundryLocalException.cs index d6e606c9..553c7ee8 100644 --- a/sdk/cs/src/FoundryLocalException.cs +++ b/sdk/cs/src/FoundryLocalException.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local; + using System; using System.Diagnostics; diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 10b51285..599ca8e9 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -19,18 +19,15 @@ public class FoundryLocalManager : IDisposable internal static readonly string AssemblyVersion = typeof(FoundryLocalManager).Assembly.GetName().Version?.ToString() ?? "unknown"; - - private readonly Configuration _config; - private CoreInterop _coreInterop = default!; - private Catalog _catalog = default!; - private ModelLoadManager _modelManager = default!; + private CoreInterop _coreInterop; + private Catalog _catalog; + private ModelLoadManager _modelManager; private readonly AsyncLock _lock = new(); private bool _disposed; - private readonly ILogger _logger; - internal Configuration Configuration => _config; - internal ILogger Logger => _logger; - internal ICoreInterop CoreInterop => _coreInterop!; // always valid once the instance is created + internal Configuration Configuration { get; } + internal ILogger Logger { get; } + internal ICoreInterop CoreInterop => _coreInterop; // always valid once the instance is created public static bool IsInitialized => instance != null; public static FoundryLocalManager Instance => instance ?? @@ -104,7 +101,7 @@ public static async Task CreateAsync(Configuration configuration, ILogger logger public async Task GetCatalogAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => GetCatalogImplAsync(ct), - "Error getting Catalog.", _logger).ConfigureAwait(false); + "Error getting Catalog.", Logger).ConfigureAwait(false); } /// @@ -120,7 +117,7 @@ public async Task GetCatalogAsync(CancellationToken? ct = null) public async Task StartWebServiceAsync(CancellationToken? ct = null) { await Utils.CallWithExceptionHandling(() => StartWebServiceImplAsync(ct), - "Error starting web service.", _logger).ConfigureAwait(false); + "Error starting web service.", Logger).ConfigureAwait(false); } /// @@ -131,7 +128,7 @@ await Utils.CallWithExceptionHandling(() => StartWebServiceImplAsync(ct), public async Task StopWebServiceAsync(CancellationToken? ct = null) { await Utils.CallWithExceptionHandling(() => StopWebServiceImplAsync(ct), - "Error stopping web service.", _logger).ConfigureAwait(false); + "Error stopping web service.", Logger).ConfigureAwait(false); } /// @@ -142,7 +139,7 @@ await Utils.CallWithExceptionHandling(() => StopWebServiceImplAsync(ct), public EpInfo[] DiscoverEps() { return Utils.CallWithExceptionHandling(DiscoverEpsImpl, - "Error discovering execution providers.", _logger); + "Error discovering execution providers.", Logger); } /// @@ -157,7 +154,7 @@ public EpInfo[] DiscoverEps() public async Task DownloadAndRegisterEpsAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => DownloadAndRegisterEpsImplAsync(null, null, ct), - "Error downloading execution providers.", _logger) + "Error downloading execution providers.", Logger) .ConfigureAwait(false); } @@ -177,7 +174,7 @@ public async Task DownloadAndRegisterEpsAsync(IEnumerable DownloadAndRegisterEpsImplAsync(names, null, ct), - "Error downloading execution providers.", _logger) + "Error downloading execution providers.", Logger) .ConfigureAwait(false); } @@ -197,7 +194,7 @@ public async Task DownloadAndRegisterEpsAsync(Action DownloadAndRegisterEpsImplAsync(null, progressCallback, ct), - "Error downloading execution providers.", _logger) + "Error downloading execution providers.", Logger) .ConfigureAwait(false); } @@ -221,50 +218,50 @@ public async Task DownloadAndRegisterEpsAsync(IEnumerable DownloadAndRegisterEpsImplAsync(names, progressCallback, ct), - "Error downloading execution providers.", _logger) + "Error downloading execution providers.", Logger) .ConfigureAwait(false); } private FoundryLocalManager(Configuration configuration, ILogger logger) { - _config = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _logger = logger; + Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + Logger = logger; } private async Task InitializeAsync(CancellationToken? ct = null) { - _config.Validate(); - _coreInterop = new CoreInterop(_config, _logger); + Configuration.Validate(); + _coreInterop = new CoreInterop(Configuration, Logger); #pragma warning disable IDISP003 // Dispose previous before re-assigning. Always null when this is called. - _modelManager = new ModelLoadManager(_config.Web?.ExternalUrl, _coreInterop, _logger); + _modelManager = new ModelLoadManager(Configuration.Web?.ExternalUrl, _coreInterop, Logger); #pragma warning restore IDISP003 - if (_config.ModelCacheDir != null) + if (Configuration.ModelCacheDir != null) { CoreInteropRequest? input = null; - var result = await _coreInterop!.ExecuteCommandAsync("get_cache_directory", input, ct) + var result = await _coreInterop.ExecuteCommandAsync("get_cache_directory", input, ct) .ConfigureAwait(false); if (result.Error != null) { throw new FoundryLocalException($"Error getting current model cache directory: {result.Error}", - _logger); + Logger); } - var curCacheDir = result.Data!; - if (curCacheDir != _config.ModelCacheDir) + var curCacheDir = result.Data; + if (curCacheDir != Configuration.ModelCacheDir) { var request = new CoreInteropRequest { - Params = new Dictionary { { "Directory", _config.ModelCacheDir } } + Params = new Dictionary { { "Directory", Configuration.ModelCacheDir } } }; - result = await _coreInterop!.ExecuteCommandAsync("set_cache_directory", request, ct) + result = await _coreInterop.ExecuteCommandAsync("set_cache_directory", request, ct) .ConfigureAwait(false); if (result.Error != null) { throw new FoundryLocalException( - $"Error setting model cache directory to '{_config.ModelCacheDir}': {result.Error}", _logger); + $"Error setting model cache directory to '{Configuration.ModelCacheDir}': {result.Error}", Logger); } } } @@ -274,20 +271,20 @@ private async Task InitializeAsync(CancellationToken? ct = null) private EpInfo[] DiscoverEpsImpl() { - var result = _coreInterop!.ExecuteCommand("discover_eps"); + var result = _coreInterop.ExecuteCommand("discover_eps"); if (result.Error != null) { - throw new FoundryLocalException($"Error discovering execution providers: {result.Error}", _logger); + throw new FoundryLocalException($"Error discovering execution providers: {result.Error}", Logger); } var data = result.Data; if (string.IsNullOrWhiteSpace(data)) { - return Array.Empty(); + return []; } return JsonSerializer.Deserialize(data, JsonSerializationContext.Default.EpInfoArray) - ?? Array.Empty(); + ?? []; } private async Task GetCatalogImplAsync(CancellationToken? ct = null) @@ -296,10 +293,7 @@ private async Task GetCatalogImplAsync(CancellationToken? ct = null) if (_catalog == null) { using var disposable = await _lock.LockAsync().ConfigureAwait(false); - if (_catalog == null) - { - _catalog = await Catalog.CreateAsync(_modelManager!, _coreInterop!, _logger, ct).ConfigureAwait(false); - } + _catalog ??= await Catalog.CreateAsync(_modelManager, _coreInterop, Logger, ct).ConfigureAwait(false); } return _catalog; @@ -307,9 +301,9 @@ private async Task GetCatalogImplAsync(CancellationToken? ct = null) private async Task StartWebServiceImplAsync(CancellationToken? ct = null) { - if (_config?.Web?.Urls == null) + if (Configuration?.Web?.Urls == null) { - throw new FoundryLocalException("Web service configuration was not provided.", _logger); + throw new FoundryLocalException("Web service configuration was not provided.", Logger); } using var disposable = await asyncLock.LockAsync().ConfigureAwait(false); @@ -318,14 +312,14 @@ private async Task StartWebServiceImplAsync(CancellationToken? ct = null) var result = await _coreInterop!.ExecuteCommandAsync("start_service", input, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error starting web service: {result.Error}", _logger); + throw new FoundryLocalException($"Error starting web service: {result.Error}", Logger); } var typeInfo = JsonSerializationContext.Default.StringArray; var boundUrls = JsonSerializer.Deserialize(result.Data!, typeInfo); if (boundUrls == null || boundUrls.Length == 0) { - throw new FoundryLocalException("Failed to get bound URLs from web service start response.", _logger); + throw new FoundryLocalException("Failed to get bound URLs from web service start response.", Logger); } Urls = boundUrls; @@ -333,18 +327,18 @@ private async Task StartWebServiceImplAsync(CancellationToken? ct = null) private async Task StopWebServiceImplAsync(CancellationToken? ct = null) { - if (_config?.Web?.Urls == null) + if (Configuration?.Web?.Urls == null) { - throw new FoundryLocalException("Web service configuration was not provided.", _logger); + throw new FoundryLocalException("Web service configuration was not provided.", Logger); } using var disposable = await asyncLock.LockAsync().ConfigureAwait(false); CoreInteropRequest? input = null; - var result = await _coreInterop!.ExecuteCommandAsync("stop_service", input, ct).ConfigureAwait(false); + var result = await _coreInterop.ExecuteCommandAsync("stop_service", input, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error stopping web service: {result.Error}", _logger); + throw new FoundryLocalException($"Error stopping web service: {result.Error}", Logger); } // Should we clear these even if there's an error response? @@ -391,25 +385,25 @@ private async Task DownloadAndRegisterEpsImplAsync(IEnumerable } }); - result = await _coreInterop!.ExecuteCommandWithCallbackAsync("download_and_register_eps", input, + result = await _coreInterop.ExecuteCommandWithCallbackAsync("download_and_register_eps", input, callback, ct).ConfigureAwait(false); } else { - result = await _coreInterop!.ExecuteCommandAsync("download_and_register_eps", input, ct).ConfigureAwait(false); + result = await _coreInterop.ExecuteCommandAsync("download_and_register_eps", input, ct).ConfigureAwait(false); } if (result.Error != null) { - throw new FoundryLocalException($"Error downloading execution providers: {result.Error}", _logger); + throw new FoundryLocalException($"Error downloading execution providers: {result.Error}", Logger); } EpDownloadResult epResult; if (!string.IsNullOrEmpty(result.Data)) { - epResult = JsonSerializer.Deserialize(result.Data!, JsonSerializationContext.Default.EpDownloadResult) - ?? throw new FoundryLocalException("Failed to deserialize EP download result.", _logger); + epResult = JsonSerializer.Deserialize(result.Data, JsonSerializationContext.Default.EpDownloadResult) + ?? throw new FoundryLocalException("Failed to deserialize EP download result.", Logger); } else { @@ -441,7 +435,7 @@ protected virtual void Dispose(bool disposing) } catch (Exception ex) { - _logger.LogWarning(ex, "Error stopping web service during Dispose."); + Logger.LogWarning(ex, "Error stopping web service during Dispose."); } } diff --git a/sdk/cs/src/FoundryModelInfo.cs b/sdk/cs/src/FoundryModelInfo.cs index 2d1327cc..463c63cd 100644 --- a/sdk/cs/src/FoundryModelInfo.cs +++ b/sdk/cs/src/FoundryModelInfo.cs @@ -35,7 +35,7 @@ public record PromptTemplate public record Runtime { [JsonPropertyName("deviceType")] - public DeviceType DeviceType { get; init; } = default!; + public DeviceType DeviceType { get; init; } = default; // there are many different possible values; keep it open‑ended [JsonPropertyName("executionProvider")] diff --git a/sdk/cs/src/GlobalSuppressions.cs b/sdk/cs/src/GlobalSuppressions.cs index 42d57544..78909bb7 100644 --- a/sdk/cs/src/GlobalSuppressions.cs +++ b/sdk/cs/src/GlobalSuppressions.cs @@ -1,7 +1,8 @@ -// This file is used by Code Analysis to maintain SuppressMessage -// attributes that are applied to this project. -// Project-level suppressions either have no target or are given -// a specific target and scoped to a namespace, type, member, etc. +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- using System.Diagnostics.CodeAnalysis; diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs index 4dca8e7d..8848d9ce 100644 --- a/sdk/cs/src/ICatalog.cs +++ b/sdk/cs/src/ICatalog.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local; + using System.Collections.Generic; public interface ICatalog diff --git a/sdk/cs/src/OpenAI/AudioClient.cs b/sdk/cs/src/OpenAI/AudioClient.cs index 383ba3cc..f1ee267f 100644 --- a/sdk/cs/src/OpenAI/AudioClient.cs +++ b/sdk/cs/src/OpenAI/AudioClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs index 012b9f7a..6fb14ff9 100644 --- a/sdk/cs/src/OpenAI/AudioTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/OpenAI/ChatClient.cs b/sdk/cs/src/OpenAI/ChatClient.cs index 688e369a..5e6463e7 100644 --- a/sdk/cs/src/OpenAI/ChatClient.cs +++ b/sdk/cs/src/OpenAI/ChatClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -228,4 +228,4 @@ private async IAsyncEnumerable ChatStreamingImplAsync(IE yield return item; } } -} \ No newline at end of file +} diff --git a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs index 033aa0b7..1307ec26 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs @@ -61,6 +61,9 @@ internal class ChatCompletionRequest public ResponseFormatExtended? ResponseFormat { get; set; } // Extension: additional parameters passed via metadata + // Valid entries: + // int top_k + // int random_seed [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 34614220..373aa2c8 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 50f47526..4a0f8084 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/OpenAI/LiveAudioTranscriptionClient.cs b/sdk/cs/src/OpenAI/LiveAudioTranscriptionClient.cs index 6da4d076..1d44e45c 100644 --- a/sdk/cs/src/OpenAI/LiveAudioTranscriptionClient.cs +++ b/sdk/cs/src/OpenAI/LiveAudioTranscriptionClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -6,9 +6,10 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; -using System.Runtime.CompilerServices; using System.Globalization; +using System.Runtime.CompilerServices; using System.Threading.Channels; + using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; using Microsoft.Extensions.Logging; @@ -382,4 +383,4 @@ public async ValueTask DisposeAsync() _lock.Dispose(); } } -} \ No newline at end of file +} diff --git a/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs b/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs index 591d2a20..872216bc 100644 --- a/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs +++ b/sdk/cs/src/OpenAI/LiveAudioTranscriptionTypes.cs @@ -1,8 +1,15 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; + using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; @@ -121,4 +128,4 @@ internal record CoreErrorResponse return null; // unstructured error — treat as permanent } } -} \ No newline at end of file +} diff --git a/sdk/cs/src/OpenAI/ToolCallingExtensions.cs b/sdk/cs/src/OpenAI/ToolCallingExtensions.cs index ea017dc3..f683b71e 100644 --- a/sdk/cs/src/OpenAI/ToolCallingExtensions.cs +++ b/sdk/cs/src/OpenAI/ToolCallingExtensions.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 8c3749ae..487d9bd0 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -159,15 +159,23 @@ internal class ToolChoiceConverter : JsonConverter public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) + { return new ToolChoice { Type = reader.GetString() }; + } if (reader.TokenType != JsonTokenType.StartObject) + { return null; + } var choice = new ToolChoice(); while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { - if (reader.TokenType != JsonTokenType.PropertyName) continue; + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + var prop = reader.GetString(); reader.Read(); switch (prop) diff --git a/sdk/cs/src/Utils.cs b/sdk/cs/src/Utils.cs index 8300a967..8e2103b6 100644 --- a/sdk/cs/src/Utils.cs +++ b/sdk/cs/src/Utils.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local; + using System.Text.Json; using System.Threading.Tasks; @@ -23,12 +24,7 @@ internal static async Task GetCachedModelIdsAsync(ICoreInterop coreInt } var typeInfo = JsonSerializationContext.Default.StringArray; - var cachedModelIds = JsonSerializer.Deserialize(result.Data!, typeInfo); - if (cachedModelIds == null) - { - throw new FoundryLocalException($"Failed to deserialized cached model names. Json:'{result.Data!}'"); - } - + var cachedModelIds = JsonSerializer.Deserialize(result.Data!, typeInfo) ?? throw new FoundryLocalException($"Failed to deserialized cached model names. Json:'{result.Data!}'"); return cachedModelIds; } diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs index d270ac15..68267b03 100644 --- a/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/CatalogTests.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Tests; + using System.Collections.Generic; using System.Linq; using System.Text.Json; diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index f98f1601..eb77740a 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // diff --git a/sdk/cs/test/FoundryLocal.Tests/EndToEnd.cs b/sdk/cs/test/FoundryLocal.Tests/EndToEnd.cs index 56c70769..3e5bacdc 100644 --- a/sdk/cs/test/FoundryLocal.Tests/EndToEnd.cs +++ b/sdk/cs/test/FoundryLocal.Tests/EndToEnd.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Tests; + using System; using System.Threading.Tasks; diff --git a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs index cd7e7793..76e1562b 100644 --- a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs +++ b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs @@ -57,12 +57,12 @@ public async Task Manager_GetCatalog_Succeeds() [Test] public async Task Catalog_ListCachedLoadUnload_Succeeds() { - List logSink = new(); + List logSink = []; var logger = Utils.CreateCapturingLoggerMock(logSink); using var loadManager = new ModelLoadManager(null, Utils.CoreInterop, logger.Object); - List intercepts = new() - { + List intercepts = + [ new Utils.InteropCommandInterceptInfo { CommandName = "initialize", @@ -70,7 +70,7 @@ public async Task Catalog_ListCachedLoadUnload_Succeeds() ResponseData = "Success", ResponseError = null } - }; + ]; var coreInterop = Utils.CreateCoreInteropWithIntercept(Utils.CoreInterop, intercepts); using var catalog = await Catalog.CreateAsync(loadManager, coreInterop.Object, logger.Object); await Assert.That(catalog).IsNotNull(); diff --git a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs index 35a99acd..c1801977 100644 --- a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -6,7 +6,6 @@ namespace Microsoft.AI.Foundry.Local.Tests; -using Microsoft.AI.Foundry.Local.Detail; using Microsoft.AI.Foundry.Local.OpenAI; internal sealed class LiveAudioTranscriptionTests @@ -16,7 +15,7 @@ internal sealed class LiveAudioTranscriptionTests [Test] public async Task FromJson_ParsesTextAndIsFinal() { - var json = """{"is_final":true,"text":"hello world","start_time":null,"end_time":null}"""; + var json = /*lang=json,strict*/ """{"is_final":true,"text":"hello world","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -30,7 +29,7 @@ public async Task FromJson_ParsesTextAndIsFinal() [Test] public async Task FromJson_MapsTimingFields() { - var json = """{"is_final":false,"text":"partial","start_time":1.5,"end_time":3.0}"""; + var json = /*lang=json,strict*/ """{"is_final":false,"text":"partial","start_time":1.5,"end_time":3.0}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -43,7 +42,7 @@ public async Task FromJson_MapsTimingFields() [Test] public async Task FromJson_EmptyText_ParsesSuccessfully() { - var json = """{"is_final":true,"text":"","start_time":null,"end_time":null}"""; + var json = /*lang=json,strict*/ """{"is_final":true,"text":"","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -54,7 +53,7 @@ public async Task FromJson_EmptyText_ParsesSuccessfully() [Test] public async Task FromJson_OnlyStartTime_SetsStartTime() { - var json = """{"is_final":true,"text":"word","start_time":2.0,"end_time":null}"""; + var json = /*lang=json,strict*/ """{"is_final":true,"text":"word","start_time":2.0,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -74,7 +73,7 @@ public async Task FromJson_InvalidJson_Throws() [Test] public async Task FromJson_ContentHasTextAndTranscript() { - var json = """{"is_final":true,"text":"test","start_time":null,"end_time":null}"""; + var json = /*lang=json,strict*/ """{"is_final":true,"text":"test","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -101,7 +100,7 @@ public async Task Options_DefaultValues() [Test] public async Task CoreErrorResponse_TryParse_ValidJson() { - var json = """{"code":"ASR_SESSION_NOT_FOUND","message":"Session not found","isTransient":false}"""; + var json = /*lang=json,strict*/ """{"code":"ASR_SESSION_NOT_FOUND","message":"Session not found","isTransient":false}"""; var error = CoreErrorResponse.TryParse(json); @@ -121,7 +120,7 @@ public async Task CoreErrorResponse_TryParse_InvalidJson_ReturnsNull() [Test] public async Task CoreErrorResponse_TryParse_TransientError() { - var json = """{"code":"BUSY","message":"Model busy","isTransient":true}"""; + var json = /*lang=json,strict*/ """{"code":"BUSY","message":"Model busy","isTransient":true}"""; var error = CoreErrorResponse.TryParse(json); @@ -223,22 +222,22 @@ public async Task LiveStreaming_E2E_WithSyntheticPCM_ReturnsValidResponse() const int sampleRate = 16000; const int durationSeconds = 2; const double frequency = 440.0; - int totalSamples = sampleRate * durationSeconds; + var totalSamples = sampleRate * durationSeconds; var pcmBytes = new byte[totalSamples * 2]; // 16-bit = 2 bytes per sample - for (int i = 0; i < totalSamples; i++) + for (var i = 0; i < totalSamples; i++) { - double t = (double)i / sampleRate; - short sample = (short)(short.MaxValue * 0.5 * Math.Sin(2 * Math.PI * frequency * t)); + var t = (double)i / sampleRate; + var sample = (short)(short.MaxValue * 0.5 * Math.Sin(2 * Math.PI * frequency * t)); pcmBytes[i * 2] = (byte)(sample & 0xFF); - pcmBytes[i * 2 + 1] = (byte)((sample >> 8) & 0xFF); + pcmBytes[(i * 2) + 1] = (byte)((sample >> 8) & 0xFF); } // Push audio in chunks (100ms each, matching typical mic callback size) - int chunkSize = sampleRate / 10 * 2; // 100ms of 16-bit audio - for (int offset = 0; offset < pcmBytes.Length; offset += chunkSize) + var chunkSize = sampleRate / 10 * 2; // 100ms of 16-bit audio + for (var offset = 0; offset < pcmBytes.Length; offset += chunkSize) { - int len = Math.Min(chunkSize, pcmBytes.Length - offset); + var len = Math.Min(chunkSize, pcmBytes.Length - offset); await session.AppendAsync(new ReadOnlyMemory(pcmBytes, offset, len)); } diff --git a/sdk/cs/test/FoundryLocal.Tests/SkipInCIAttribute.cs b/sdk/cs/test/FoundryLocal.Tests/SkipInCIAttribute.cs index c4d17e5b..69db2f85 100644 --- a/sdk/cs/test/FoundryLocal.Tests/SkipInCIAttribute.cs +++ b/sdk/cs/test/FoundryLocal.Tests/SkipInCIAttribute.cs @@ -6,10 +6,10 @@ namespace Microsoft.AI.Foundry.Local.Tests; -using TUnit.Core; - using System.Threading.Tasks; +using TUnit.Core; + public class SkipInCIAttribute() : SkipAttribute("This test is only supported locally. Skipped on CIs.") { public override Task ShouldSkip(TestRegisteredContext context) diff --git a/sdk/cs/test/FoundryLocal.Tests/TestAssemblySetupCleanup.cs b/sdk/cs/test/FoundryLocal.Tests/TestAssemblySetupCleanup.cs index 2136a8eb..4720b76e 100644 --- a/sdk/cs/test/FoundryLocal.Tests/TestAssemblySetupCleanup.cs +++ b/sdk/cs/test/FoundryLocal.Tests/TestAssemblySetupCleanup.cs @@ -5,6 +5,7 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Tests; + using System.Threading.Tasks; internal static class TestAssemblySetupCleanup diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index 9611d0d4..8080ad60 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -21,7 +21,7 @@ namespace Microsoft.AI.Foundry.Local.Tests; internal static class Utils { - internal struct TestCatalogInfo + internal readonly struct TestCatalogInfo { internal readonly List TestCatalog { get; } internal readonly string ModelListJson { get; } @@ -29,7 +29,7 @@ internal struct TestCatalogInfo internal TestCatalogInfo(bool includeCuda) { - TestCatalog = Utils.BuildTestCatalog(includeCuda); + TestCatalog = BuildTestCatalog(includeCuda); ModelListJson = JsonSerializer.Serialize(TestCatalog, JsonSerializationContext.Default.ListModelInfo); } } @@ -96,7 +96,7 @@ public static void AssemblyInit(AssemblyHookContext _) FoundryLocalManager.CreateAsync(config, logger).GetAwaiter().GetResult(); // standalone instance for testing individual components that skips the 'initialize' command - CoreInterop = new CoreInterop(logger); + CoreInterop = new CoreInterop(logger); } internal static ICoreInterop CoreInterop { get; private set; } = default!; @@ -234,7 +234,7 @@ private static List BuildTestCatalog(bool includeCuda = true) PromptTemplate = common.PromptTemplate, Publisher = common.Publisher, Task = common.Task, FileSizeMb = common.FileSizeMb - 10, // smaller so default chosen in test that sorts on this - ModelSettings = common.ModelSettings, + ModelSettings = common.ModelSettings, SupportsToolCalling = common.SupportsToolCalling, License = common.License, LicenseDescription = common.LicenseDescription, @@ -358,8 +358,8 @@ private static List BuildTestCatalog(bool includeCuda = true) }); } - list.AddRange(new[] - { + list.AddRange( + [ new ModelInfo { Id = "model-3-generic-gpu:1", @@ -403,7 +403,7 @@ private static List BuildTestCatalog(bool includeCuda = true) MaxOutputTokens = common.MaxOutputTokens, MinFLVersion = common.MinFLVersion } - }); + ]); // model-4 generic-gpu (nullable prompt) list.Add(new ModelInfo @@ -444,7 +444,9 @@ private static string GetRepoRoot() while (dir != null) { if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { return dir.FullName; + } dir = dir.Parent; } diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 6fa74da4..7272f665 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -29,10 +29,6 @@ pub use crate::openai::chat_types::{ }; // Re-export OpenAI response types for convenience. -pub use crate::openai::{ - AudioTranscriptionResponse, AudioTranscriptionStream, ChatCompletionStream, - TranscriptionSegment, TranscriptionWord, -}; pub use crate::openai::chat_types::{ ChatChoice, ChatChoiceStream, ChatCompletionMessageToolCall, ChatCompletionMessageToolCallChunk, ChatCompletionMessageToolCalls, @@ -40,3 +36,7 @@ pub use crate::openai::chat_types::{ CreateChatCompletionResponse, CreateChatCompletionStreamResponse, FinishReason, FunctionCall, FunctionCallStream, }; +pub use crate::openai::{ + AudioTranscriptionResponse, AudioTranscriptionStream, ChatCompletionStream, + TranscriptionSegment, TranscriptionWord, +}; diff --git a/sdk/rust/src/openai/mod.rs b/sdk/rust/src/openai/mod.rs index 0cfe5a1d..f3ad0931 100644 --- a/sdk/rust/src/openai/mod.rs +++ b/sdk/rust/src/openai/mod.rs @@ -1,6 +1,6 @@ mod audio_client; -pub mod chat_types; mod chat_client; +pub mod chat_types; mod json_stream; pub use self::audio_client::{ From 2589fb0735a3ad2a8cfc02534c2d9729e8061afc Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:02:29 -0500 Subject: [PATCH 07/35] Update docs and merge AudioTranscriptionRequest classes - Fix README.md to use ChatMessageRole enum in example - Fix IModel.cs doc comments: OpenAI.ChatClient -> OpenAIChatClient - Fix IModel API docs: remove stale OpenAI.* return type references - Fix Rust GENERATE-DOCS.md and api.md: remove async-openai references - Merge AudioTranscriptionRequestExtended into AudioTranscriptionRequest - Move Metadata property into base class - Eliminate runtime type-check in ToJson() - Remove AudioTranscriptionRequestExtended from JsonSerializationContext Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../api/microsoft.ai.foundry.local.imodel.md | 4 +-- sdk/cs/src/Detail/JsonSerializationContext.cs | 1 - sdk/cs/src/IModel.cs | 4 +-- sdk/cs/src/OpenAI/AudioClient.cs | 4 +-- .../AudioTranscriptionRequestResponseTypes.cs | 27 ++++--------------- sdk/cs/src/OpenAI/AudioTypes.cs | 3 +++ sdk/rust/GENERATE-DOCS.md | 4 +-- sdk/rust/docs/api.md | 2 +- 9 files changed, 18 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 07bc9b4d..f0a83778 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ The Foundry Local SDK makes it easy to integrate local AI models into your appli var chatClient = await model.GetChatClientAsync(); var messages = new List { - new() { Role = "user", Content = "What is the golden ratio?" } + new() { Role = ChatMessageRole.User, Content = "What is the golden ratio?" } }; await foreach (var chunk in chatClient.CompleteChatStreamingAsync(messages)) diff --git a/sdk/cs/docs/api/microsoft.ai.foundry.local.imodel.md b/sdk/cs/docs/api/microsoft.ai.foundry.local.imodel.md index 861386a8..e57c8391 100644 --- a/sdk/cs/docs/api/microsoft.ai.foundry.local.imodel.md +++ b/sdk/cs/docs/api/microsoft.ai.foundry.local.imodel.md @@ -188,7 +188,7 @@ Optional cancellation token. #### Returns [Task<OpenAIChatClient>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
-OpenAI.ChatClient +OpenAIChatClient ### **GetAudioClientAsync(Nullable<CancellationToken>)** @@ -206,7 +206,7 @@ Optional cancellation token. #### Returns [Task<OpenAIAudioClient>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
-OpenAI.AudioClient +OpenAIAudioClient ### **SelectVariant(IModel)** diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 74e13a87..ae29256e 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -25,7 +25,6 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(CompletionUsage))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] -[JsonSerializable(typeof(AudioTranscriptionRequestExtended))] [JsonSerializable(typeof(AudioTranscriptionResponse))] [JsonSerializable(typeof(string[]))] // list loaded or cached models [JsonSerializable(typeof(EpInfo[]))] diff --git a/sdk/cs/src/IModel.cs b/sdk/cs/src/IModel.cs index a27f3a3d..afe8ff1c 100644 --- a/sdk/cs/src/IModel.cs +++ b/sdk/cs/src/IModel.cs @@ -60,14 +60,14 @@ Task DownloadAsync(Action? downloadProgress = null, /// Get an OpenAI API based ChatClient ///
/// Optional cancellation token. - /// OpenAI.ChatClient + /// OpenAIChatClient Task GetChatClientAsync(CancellationToken? ct = null); /// /// Get an OpenAI API based AudioClient /// /// Optional cancellation token. - /// OpenAI.AudioClient + /// OpenAIAudioClient Task GetAudioClientAsync(CancellationToken? ct = null); /// diff --git a/sdk/cs/src/OpenAI/AudioClient.cs b/sdk/cs/src/OpenAI/AudioClient.cs index f1ee267f..1052ca97 100644 --- a/sdk/cs/src/OpenAI/AudioClient.cs +++ b/sdk/cs/src/OpenAI/AudioClient.cs @@ -94,7 +94,7 @@ public LiveAudioTranscriptionSession CreateLiveTranscriptionSession() private async Task TranscribeAudioImplAsync(string audioFilePath, CancellationToken? ct) { - var openaiRequest = AudioTranscriptionRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionRequestResponseExtensions.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest @@ -117,7 +117,7 @@ private async Task TranscribeAudioImplAsync(string a private async IAsyncEnumerable TranscribeAudioStreamingImplAsync( string audioFilePath, [EnumeratorCancellation] CancellationToken ct) { - var openaiRequest = AudioTranscriptionRequestExtended.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionRequestResponseExtensions.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest { diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index 9217e5c2..d2d71801 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -15,24 +15,16 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; -internal class AudioTranscriptionRequestExtended : AudioTranscriptionRequest +internal static class AudioTranscriptionRequestResponseExtensions { - // Valid entries: - // int language - // int temperature - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - internal static AudioTranscriptionRequestExtended FromUserInput(string modelId, - string audioFilePath, - OpenAIAudioClient.AudioSettings settings) + internal static AudioTranscriptionRequest FromUserInput(string modelId, + string audioFilePath, + OpenAIAudioClient.AudioSettings settings) { - var request = new AudioTranscriptionRequestExtended + var request = new AudioTranscriptionRequest { Model = modelId, FileName = audioFilePath, - - // apply our specific settings Language = settings.Language, Temperature = settings.Temperature }; @@ -56,18 +48,9 @@ internal static AudioTranscriptionRequestExtended FromUserInput(string modelId, return request; } -} -internal static class AudioTranscriptionRequestResponseExtensions -{ internal static string ToJson(this AudioTranscriptionRequest request) { - if (request is AudioTranscriptionRequestExtended extendedRequest) - { - return JsonSerializer.Serialize(extendedRequest, - JsonSerializationContext.Default.AudioTranscriptionRequestExtended); - } - return JsonSerializer.Serialize(request, JsonSerializationContext.Default.AudioTranscriptionRequest); } diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs index 6fb14ff9..8906b20a 100644 --- a/sdk/cs/src/OpenAI/AudioTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -43,4 +43,7 @@ internal class AudioTranscriptionRequest public string? Prompt { get; set; } public string? ResponseFormat { get; set; } public float? Temperature { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } } diff --git a/sdk/rust/GENERATE-DOCS.md b/sdk/rust/GENERATE-DOCS.md index f02b5d99..1be5d6a1 100644 --- a/sdk/rust/GENERATE-DOCS.md +++ b/sdk/rust/GENERATE-DOCS.md @@ -26,8 +26,8 @@ The SDK re-exports all public types from the crate root. Key modules: | `ModelVariant` | Single variant — download, load, unload | | `ChatClient` | OpenAI-compatible chat completions (sync + streaming) | | `AudioClient` | OpenAI-compatible audio transcription (sync + streaming) | -| `CreateChatCompletionResponse` | Typed chat completion response (from `async-openai`) | -| `CreateChatCompletionStreamResponse` | Typed streaming chat chunk (from `async-openai`) | +| `CreateChatCompletionResponse` | Typed chat completion response | +| `CreateChatCompletionStreamResponse` | Typed streaming chat chunk | | `AudioTranscriptionResponse` | Typed audio transcription response | | `FoundryLocalError` | Error enum with variants for all failure modes | diff --git a/sdk/rust/docs/api.md b/sdk/rust/docs/api.md index 278402fb..00bf934f 100644 --- a/sdk/rust/docs/api.md +++ b/sdk/rust/docs/api.md @@ -517,7 +517,7 @@ Implements: `Display`, `Error`, `From`, `From ## Re-exported OpenAI Types -The following types from `async_openai` are re-exported at the crate root for convenience: +The following OpenAI-compatible types are re-exported at the crate root for convenience: **Request types:** - `ChatCompletionRequestMessage` From 9cfa4285b30c7e125bca384014028910e033a83d Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:10:04 -0500 Subject: [PATCH 08/35] Fix stale API docs for ChatClient, AudioClient, and LiveAudioTranscription - ChatClient docs: ChatCompletionCreateResponse -> ChatCompletionResponse - AudioClient docs: AudioCreateTranscriptionResponse -> AudioTranscriptionResponse - LiveAudioTranscriptionResponse docs: remove stale properties (Id, Type, Status, Role, CallId, Name, Arguments, Output), add Content with TranscriptionContentPart, update property accessors to init Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...crosoft.ai.foundry.local.openaiaudioclient.md | 8 ++++---- ...icrosoft.ai.foundry.local.openaichatclient.md | 16 ++++++++-------- .../AudioTranscriptionRequestResponseTypes.cs | 1 - 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md index c9898f4c..77b6bc4c 100644 --- a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md +++ b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaiaudioclient.md @@ -33,7 +33,7 @@ public AudioSettings Settings { get; } Transcribe audio from a file. ```csharp -public Task TranscribeAudioAsync(string audioFilePath, Nullable ct) +public Task TranscribeAudioAsync(string audioFilePath, Nullable ct) ``` #### Parameters @@ -47,7 +47,7 @@ Optional cancellation token. #### Returns -[Task<AudioCreateTranscriptionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
+[Task<AudioTranscriptionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
Transcription response. ### **TranscribeAudioStreamingAsync(String, CancellationToken)** @@ -55,7 +55,7 @@ Transcription response. Transcribe audio from a file with streamed output. ```csharp -public IAsyncEnumerable TranscribeAudioStreamingAsync(string audioFilePath, CancellationToken ct) +public IAsyncEnumerable TranscribeAudioStreamingAsync(string audioFilePath, CancellationToken ct) ``` #### Parameters @@ -69,7 +69,7 @@ Cancellation token. #### Returns -[IAsyncEnumerable<AudioCreateTranscriptionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
+[IAsyncEnumerable<AudioTranscriptionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
An asynchronous enumerable of transcription responses. ### **CreateLiveTranscriptionSession()** diff --git a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md index f184dfb8..f0d78442 100644 --- a/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md +++ b/sdk/cs/docs/api/microsoft.ai.foundry.local.openaichatclient.md @@ -35,7 +35,7 @@ Execute a chat completion request. To continue a conversation, add the ChatMessage from the previous response and new prompt to the messages. ```csharp -public Task CompleteChatAsync(IEnumerable messages, Nullable ct) +public Task CompleteChatAsync(IEnumerable messages, Nullable ct) ``` #### Parameters @@ -48,7 +48,7 @@ Optional cancellation token. #### Returns -[Task<ChatCompletionCreateResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
+[Task<ChatCompletionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
Chat completion response. ### **CompleteChatAsync(IEnumerable<ChatMessage>, IEnumerable<ToolDefinition>, Nullable<CancellationToken>)** @@ -58,7 +58,7 @@ Execute a chat completion request. To continue a conversation, add the ChatMessage from the previous response and new prompt to the messages. ```csharp -public Task CompleteChatAsync(IEnumerable messages, IEnumerable tools, Nullable ct) +public Task CompleteChatAsync(IEnumerable messages, IEnumerable tools, Nullable ct) ``` #### Parameters @@ -74,7 +74,7 @@ Optional cancellation token. #### Returns -[Task<ChatCompletionCreateResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
+[Task<ChatCompletionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1)
Chat completion response. ### **CompleteChatStreamingAsync(IEnumerable<ChatMessage>, CancellationToken)** @@ -84,7 +84,7 @@ Execute a chat completion request with streamed output. To continue a conversation, add the ChatMessage from the previous response and new prompt to the messages. ```csharp -public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, CancellationToken ct) +public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, CancellationToken ct) ``` #### Parameters @@ -97,7 +97,7 @@ Cancellation token. #### Returns -[IAsyncEnumerable<ChatCompletionCreateResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
+[IAsyncEnumerable<ChatCompletionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
Async enumerable of chat completion responses. ### **CompleteChatStreamingAsync(IEnumerable<ChatMessage>, IEnumerable<ToolDefinition>, CancellationToken)** @@ -107,7 +107,7 @@ Execute a chat completion request with streamed output. To continue a conversation, add the ChatMessage from the previous response and new prompt to the messages. ```csharp -public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, IEnumerable tools, CancellationToken ct) +public IAsyncEnumerable CompleteChatStreamingAsync(IEnumerable messages, IEnumerable tools, CancellationToken ct) ``` #### Parameters @@ -123,5 +123,5 @@ Cancellation token. #### Returns -[IAsyncEnumerable<ChatCompletionCreateResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
+[IAsyncEnumerable<ChatCompletionResponse>](https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.iasyncenumerable-1)
Async enumerable of chat completion responses. diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index d2d71801..f3a0eee2 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -8,7 +8,6 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Globalization; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.AI.Foundry.Local; using Microsoft.AI.Foundry.Local.Detail; From dbc5583f0053be6b07a8d2601cc7217cfa99971c Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:20:10 -0500 Subject: [PATCH 09/35] Revert unrelated formatting changes to non-SDK files Keep formatting only in files modified by this branch. Revert dotnet format changes to CoreInterop, FoundryLocalManager, Utils, and other files that were not part of the third-party library removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Catalog.cs | 13 ++- sdk/cs/src/Detail/CoreInterop.cs | 18 ++-- sdk/cs/src/Detail/CoreInteropRequest.cs | 3 +- sdk/cs/src/Detail/ICoreInterop.cs | 10 +- sdk/cs/src/Detail/Model.cs | 2 +- sdk/cs/src/FoundryLocalManager.cs | 98 ++++++++++--------- sdk/cs/src/FoundryModelInfo.cs | 2 +- sdk/cs/src/GlobalSuppressions.cs | 9 +- sdk/cs/src/Utils.cs | 8 +- .../FoundryLocalManagerTest.cs | 8 +- .../LiveAudioTranscriptionTests.cs | 34 ++++--- sdk/cs/test/FoundryLocal.Tests/Utils.cs | 6 +- 12 files changed, 114 insertions(+), 97 deletions(-) diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs index 21ca82bd..0f939c0b 100644 --- a/sdk/cs/src/Catalog.cs +++ b/sdk/cs/src/Catalog.cs @@ -16,8 +16,8 @@ namespace Microsoft.AI.Foundry.Local; internal sealed class Catalog : ICatalog, IDisposable { - private readonly Dictionary _modelAliasToModel = []; - private readonly Dictionary _modelIdToModelVariant = []; + private readonly Dictionary _modelAliasToModel = new(); + private readonly Dictionary _modelIdToModelVariant = new(); private DateTime _lastFetch; private readonly IModelLoadManager _modelLoadManager; @@ -164,7 +164,14 @@ private async Task GetLatestVersionImplAsync(IModel modelOrModelVariant, else { // Try to use the concrete Model instance if this is our SDK type. - model = modelOrModelVariant as Model ?? await GetModelImplAsync(modelOrModelVariant.Alias, ct); + model = modelOrModelVariant as Model; + + // If this is a different IModel implementation (e.g., a test stub), + // fall back to resolving the Model via alias. + if (model == null) + { + model = await GetModelImplAsync(modelOrModelVariant.Alias, ct); + } } if (model == null) diff --git a/sdk/cs/src/Detail/CoreInterop.cs b/sdk/cs/src/Detail/CoreInterop.cs index 06729b95..a7a43447 100644 --- a/sdk/cs/src/Detail/CoreInterop.cs +++ b/sdk/cs/src/Detail/CoreInterop.cs @@ -240,9 +240,9 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, { try { - var commandBytes = System.Text.Encoding.UTF8.GetBytes(commandName); + byte[] commandBytes = System.Text.Encoding.UTF8.GetBytes(commandName); // Allocate unmanaged memory for the command bytes - var commandPtr = Marshal.AllocHGlobal(commandBytes.Length); + IntPtr commandPtr = Marshal.AllocHGlobal(commandBytes.Length); Marshal.Copy(commandBytes, 0, commandPtr, commandBytes.Length); byte[]? inputBytes = null; @@ -305,7 +305,7 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput, // Marshal response. Will have either Data or Error populated. Not both. if (response.Data != IntPtr.Zero && response.DataLength > 0) { - var managedResponse = new byte[response.DataLength]; + byte[] managedResponse = new byte[response.DataLength]; Marshal.Copy(response.Data, managedResponse, 0, response.DataLength); result.Data = System.Text.Encoding.UTF8.GetString(managedResponse); _logger.LogDebug($"Command: {commandName} succeeded."); @@ -374,14 +374,14 @@ private Response MarshalResponse(ResponseBuffer response) if (response.Data != IntPtr.Zero && response.DataLength > 0) { - var managedResponse = new byte[response.DataLength]; + byte[] managedResponse = new byte[response.DataLength]; Marshal.Copy(response.Data, managedResponse, 0, response.DataLength); result.Data = System.Text.Encoding.UTF8.GetString(managedResponse); } if (response.Error != IntPtr.Zero && response.ErrorLength > 0) { - result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength); + result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength)!; } Marshal.FreeHGlobal(response.Data); @@ -405,13 +405,13 @@ public Response PushAudioData(CoreInteropRequest request, ReadOnlyMemory a try { var commandInputJson = request.ToJson(); - var commandBytes = System.Text.Encoding.UTF8.GetBytes("audio_stream_push"); - var inputBytes = System.Text.Encoding.UTF8.GetBytes(commandInputJson); + byte[] commandBytes = System.Text.Encoding.UTF8.GetBytes("audio_stream_push"); + byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(commandInputJson); - var commandPtr = Marshal.AllocHGlobal(commandBytes.Length); + IntPtr commandPtr = Marshal.AllocHGlobal(commandBytes.Length); Marshal.Copy(commandBytes, 0, commandPtr, commandBytes.Length); - var inputPtr = Marshal.AllocHGlobal(inputBytes.Length); + IntPtr inputPtr = Marshal.AllocHGlobal(inputBytes.Length); Marshal.Copy(inputBytes, 0, inputPtr, inputBytes.Length); // Pin the managed audio data so GC won't move it during the native call diff --git a/sdk/cs/src/Detail/CoreInteropRequest.cs b/sdk/cs/src/Detail/CoreInteropRequest.cs index 2936c2fd..50365ad0 100644 --- a/sdk/cs/src/Detail/CoreInteropRequest.cs +++ b/sdk/cs/src/Detail/CoreInteropRequest.cs @@ -5,13 +5,12 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local.Detail; - using System.Collections.Generic; using System.Text.Json; public class CoreInteropRequest { - public Dictionary Params { get; set; } = []; + public Dictionary Params { get; set; } = new(); } internal static class RequestExtensions diff --git a/sdk/cs/src/Detail/ICoreInterop.cs b/sdk/cs/src/Detail/ICoreInterop.cs index 489ed8b9..b493dfb7 100644 --- a/sdk/cs/src/Detail/ICoreInterop.cs +++ b/sdk/cs/src/Detail/ICoreInterop.cs @@ -19,10 +19,10 @@ internal record Response internal string? Error; } - delegate void CallbackFn(string callbackData); + public delegate void CallbackFn(string callbackData); [StructLayout(LayoutKind.Sequential)] - protected struct RequestBuffer + protected unsafe struct RequestBuffer { public nint Command; public int CommandLength; @@ -31,7 +31,7 @@ protected struct RequestBuffer } [StructLayout(LayoutKind.Sequential)] - protected struct ResponseBuffer + protected unsafe struct ResponseBuffer { public nint Data; public int DataLength; @@ -41,7 +41,7 @@ protected struct ResponseBuffer // native callback function signature [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - protected delegate void NativeCallbackFn(nint data, int length, nint userData); + protected unsafe delegate void NativeCallbackFn(nint data, int length, nint userData); Response ExecuteCommand(string commandName, CoreInteropRequest? commandInput = null); Response ExecuteCommandWithCallback(string commandName, CoreInteropRequest? commandInput, CallbackFn callback); @@ -55,7 +55,7 @@ Task ExecuteCommandWithCallbackAsync(string commandName, CoreInteropRe // --- Audio streaming session support --- [StructLayout(LayoutKind.Sequential)] - protected struct StreamingRequestBuffer + protected unsafe struct StreamingRequestBuffer { public nint Command; public int CommandLength; diff --git a/sdk/cs/src/Detail/Model.cs b/sdk/cs/src/Detail/Model.cs index e72dd608..c4d96057 100644 --- a/sdk/cs/src/Detail/Model.cs +++ b/sdk/cs/src/Detail/Model.cs @@ -14,7 +14,7 @@ public class Model : IModel private readonly List _variants; public IReadOnlyList Variants => _variants; - internal IModel SelectedVariant { get; set; } + internal IModel SelectedVariant { get; set; } = default!; public string Alias { get; init; } public string Id => SelectedVariant.Id; diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 599ca8e9..10b51285 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -19,15 +19,18 @@ public class FoundryLocalManager : IDisposable internal static readonly string AssemblyVersion = typeof(FoundryLocalManager).Assembly.GetName().Version?.ToString() ?? "unknown"; - private CoreInterop _coreInterop; - private Catalog _catalog; - private ModelLoadManager _modelManager; + + private readonly Configuration _config; + private CoreInterop _coreInterop = default!; + private Catalog _catalog = default!; + private ModelLoadManager _modelManager = default!; private readonly AsyncLock _lock = new(); private bool _disposed; + private readonly ILogger _logger; - internal Configuration Configuration { get; } - internal ILogger Logger { get; } - internal ICoreInterop CoreInterop => _coreInterop; // always valid once the instance is created + internal Configuration Configuration => _config; + internal ILogger Logger => _logger; + internal ICoreInterop CoreInterop => _coreInterop!; // always valid once the instance is created public static bool IsInitialized => instance != null; public static FoundryLocalManager Instance => instance ?? @@ -101,7 +104,7 @@ public static async Task CreateAsync(Configuration configuration, ILogger logger public async Task GetCatalogAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => GetCatalogImplAsync(ct), - "Error getting Catalog.", Logger).ConfigureAwait(false); + "Error getting Catalog.", _logger).ConfigureAwait(false); } /// @@ -117,7 +120,7 @@ public async Task GetCatalogAsync(CancellationToken? ct = null) public async Task StartWebServiceAsync(CancellationToken? ct = null) { await Utils.CallWithExceptionHandling(() => StartWebServiceImplAsync(ct), - "Error starting web service.", Logger).ConfigureAwait(false); + "Error starting web service.", _logger).ConfigureAwait(false); } /// @@ -128,7 +131,7 @@ await Utils.CallWithExceptionHandling(() => StartWebServiceImplAsync(ct), public async Task StopWebServiceAsync(CancellationToken? ct = null) { await Utils.CallWithExceptionHandling(() => StopWebServiceImplAsync(ct), - "Error stopping web service.", Logger).ConfigureAwait(false); + "Error stopping web service.", _logger).ConfigureAwait(false); } /// @@ -139,7 +142,7 @@ await Utils.CallWithExceptionHandling(() => StopWebServiceImplAsync(ct), public EpInfo[] DiscoverEps() { return Utils.CallWithExceptionHandling(DiscoverEpsImpl, - "Error discovering execution providers.", Logger); + "Error discovering execution providers.", _logger); } /// @@ -154,7 +157,7 @@ public EpInfo[] DiscoverEps() public async Task DownloadAndRegisterEpsAsync(CancellationToken? ct = null) { return await Utils.CallWithExceptionHandling(() => DownloadAndRegisterEpsImplAsync(null, null, ct), - "Error downloading execution providers.", Logger) + "Error downloading execution providers.", _logger) .ConfigureAwait(false); } @@ -174,7 +177,7 @@ public async Task DownloadAndRegisterEpsAsync(IEnumerable DownloadAndRegisterEpsImplAsync(names, null, ct), - "Error downloading execution providers.", Logger) + "Error downloading execution providers.", _logger) .ConfigureAwait(false); } @@ -194,7 +197,7 @@ public async Task DownloadAndRegisterEpsAsync(Action DownloadAndRegisterEpsImplAsync(null, progressCallback, ct), - "Error downloading execution providers.", Logger) + "Error downloading execution providers.", _logger) .ConfigureAwait(false); } @@ -218,50 +221,50 @@ public async Task DownloadAndRegisterEpsAsync(IEnumerable DownloadAndRegisterEpsImplAsync(names, progressCallback, ct), - "Error downloading execution providers.", Logger) + "Error downloading execution providers.", _logger) .ConfigureAwait(false); } private FoundryLocalManager(Configuration configuration, ILogger logger) { - Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - Logger = logger; + _config = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger; } private async Task InitializeAsync(CancellationToken? ct = null) { - Configuration.Validate(); - _coreInterop = new CoreInterop(Configuration, Logger); + _config.Validate(); + _coreInterop = new CoreInterop(_config, _logger); #pragma warning disable IDISP003 // Dispose previous before re-assigning. Always null when this is called. - _modelManager = new ModelLoadManager(Configuration.Web?.ExternalUrl, _coreInterop, Logger); + _modelManager = new ModelLoadManager(_config.Web?.ExternalUrl, _coreInterop, _logger); #pragma warning restore IDISP003 - if (Configuration.ModelCacheDir != null) + if (_config.ModelCacheDir != null) { CoreInteropRequest? input = null; - var result = await _coreInterop.ExecuteCommandAsync("get_cache_directory", input, ct) + var result = await _coreInterop!.ExecuteCommandAsync("get_cache_directory", input, ct) .ConfigureAwait(false); if (result.Error != null) { throw new FoundryLocalException($"Error getting current model cache directory: {result.Error}", - Logger); + _logger); } - var curCacheDir = result.Data; - if (curCacheDir != Configuration.ModelCacheDir) + var curCacheDir = result.Data!; + if (curCacheDir != _config.ModelCacheDir) { var request = new CoreInteropRequest { - Params = new Dictionary { { "Directory", Configuration.ModelCacheDir } } + Params = new Dictionary { { "Directory", _config.ModelCacheDir } } }; - result = await _coreInterop.ExecuteCommandAsync("set_cache_directory", request, ct) + result = await _coreInterop!.ExecuteCommandAsync("set_cache_directory", request, ct) .ConfigureAwait(false); if (result.Error != null) { throw new FoundryLocalException( - $"Error setting model cache directory to '{Configuration.ModelCacheDir}': {result.Error}", Logger); + $"Error setting model cache directory to '{_config.ModelCacheDir}': {result.Error}", _logger); } } } @@ -271,20 +274,20 @@ private async Task InitializeAsync(CancellationToken? ct = null) private EpInfo[] DiscoverEpsImpl() { - var result = _coreInterop.ExecuteCommand("discover_eps"); + var result = _coreInterop!.ExecuteCommand("discover_eps"); if (result.Error != null) { - throw new FoundryLocalException($"Error discovering execution providers: {result.Error}", Logger); + throw new FoundryLocalException($"Error discovering execution providers: {result.Error}", _logger); } var data = result.Data; if (string.IsNullOrWhiteSpace(data)) { - return []; + return Array.Empty(); } return JsonSerializer.Deserialize(data, JsonSerializationContext.Default.EpInfoArray) - ?? []; + ?? Array.Empty(); } private async Task GetCatalogImplAsync(CancellationToken? ct = null) @@ -293,7 +296,10 @@ private async Task GetCatalogImplAsync(CancellationToken? ct = null) if (_catalog == null) { using var disposable = await _lock.LockAsync().ConfigureAwait(false); - _catalog ??= await Catalog.CreateAsync(_modelManager, _coreInterop, Logger, ct).ConfigureAwait(false); + if (_catalog == null) + { + _catalog = await Catalog.CreateAsync(_modelManager!, _coreInterop!, _logger, ct).ConfigureAwait(false); + } } return _catalog; @@ -301,9 +307,9 @@ private async Task GetCatalogImplAsync(CancellationToken? ct = null) private async Task StartWebServiceImplAsync(CancellationToken? ct = null) { - if (Configuration?.Web?.Urls == null) + if (_config?.Web?.Urls == null) { - throw new FoundryLocalException("Web service configuration was not provided.", Logger); + throw new FoundryLocalException("Web service configuration was not provided.", _logger); } using var disposable = await asyncLock.LockAsync().ConfigureAwait(false); @@ -312,14 +318,14 @@ private async Task StartWebServiceImplAsync(CancellationToken? ct = null) var result = await _coreInterop!.ExecuteCommandAsync("start_service", input, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error starting web service: {result.Error}", Logger); + throw new FoundryLocalException($"Error starting web service: {result.Error}", _logger); } var typeInfo = JsonSerializationContext.Default.StringArray; var boundUrls = JsonSerializer.Deserialize(result.Data!, typeInfo); if (boundUrls == null || boundUrls.Length == 0) { - throw new FoundryLocalException("Failed to get bound URLs from web service start response.", Logger); + throw new FoundryLocalException("Failed to get bound URLs from web service start response.", _logger); } Urls = boundUrls; @@ -327,18 +333,18 @@ private async Task StartWebServiceImplAsync(CancellationToken? ct = null) private async Task StopWebServiceImplAsync(CancellationToken? ct = null) { - if (Configuration?.Web?.Urls == null) + if (_config?.Web?.Urls == null) { - throw new FoundryLocalException("Web service configuration was not provided.", Logger); + throw new FoundryLocalException("Web service configuration was not provided.", _logger); } using var disposable = await asyncLock.LockAsync().ConfigureAwait(false); CoreInteropRequest? input = null; - var result = await _coreInterop.ExecuteCommandAsync("stop_service", input, ct).ConfigureAwait(false); + var result = await _coreInterop!.ExecuteCommandAsync("stop_service", input, ct).ConfigureAwait(false); if (result.Error != null) { - throw new FoundryLocalException($"Error stopping web service: {result.Error}", Logger); + throw new FoundryLocalException($"Error stopping web service: {result.Error}", _logger); } // Should we clear these even if there's an error response? @@ -385,25 +391,25 @@ private async Task DownloadAndRegisterEpsImplAsync(IEnumerable } }); - result = await _coreInterop.ExecuteCommandWithCallbackAsync("download_and_register_eps", input, + result = await _coreInterop!.ExecuteCommandWithCallbackAsync("download_and_register_eps", input, callback, ct).ConfigureAwait(false); } else { - result = await _coreInterop.ExecuteCommandAsync("download_and_register_eps", input, ct).ConfigureAwait(false); + result = await _coreInterop!.ExecuteCommandAsync("download_and_register_eps", input, ct).ConfigureAwait(false); } if (result.Error != null) { - throw new FoundryLocalException($"Error downloading execution providers: {result.Error}", Logger); + throw new FoundryLocalException($"Error downloading execution providers: {result.Error}", _logger); } EpDownloadResult epResult; if (!string.IsNullOrEmpty(result.Data)) { - epResult = JsonSerializer.Deserialize(result.Data, JsonSerializationContext.Default.EpDownloadResult) - ?? throw new FoundryLocalException("Failed to deserialize EP download result.", Logger); + epResult = JsonSerializer.Deserialize(result.Data!, JsonSerializationContext.Default.EpDownloadResult) + ?? throw new FoundryLocalException("Failed to deserialize EP download result.", _logger); } else { @@ -435,7 +441,7 @@ protected virtual void Dispose(bool disposing) } catch (Exception ex) { - Logger.LogWarning(ex, "Error stopping web service during Dispose."); + _logger.LogWarning(ex, "Error stopping web service during Dispose."); } } diff --git a/sdk/cs/src/FoundryModelInfo.cs b/sdk/cs/src/FoundryModelInfo.cs index 463c63cd..2d1327cc 100644 --- a/sdk/cs/src/FoundryModelInfo.cs +++ b/sdk/cs/src/FoundryModelInfo.cs @@ -35,7 +35,7 @@ public record PromptTemplate public record Runtime { [JsonPropertyName("deviceType")] - public DeviceType DeviceType { get; init; } = default; + public DeviceType DeviceType { get; init; } = default!; // there are many different possible values; keep it open‑ended [JsonPropertyName("executionProvider")] diff --git a/sdk/cs/src/GlobalSuppressions.cs b/sdk/cs/src/GlobalSuppressions.cs index 78909bb7..42d57544 100644 --- a/sdk/cs/src/GlobalSuppressions.cs +++ b/sdk/cs/src/GlobalSuppressions.cs @@ -1,8 +1,7 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) Microsoft. All rights reserved. -// -// -------------------------------------------------------------------------------------------------------------------- +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. using System.Diagnostics.CodeAnalysis; diff --git a/sdk/cs/src/Utils.cs b/sdk/cs/src/Utils.cs index 8e2103b6..8300a967 100644 --- a/sdk/cs/src/Utils.cs +++ b/sdk/cs/src/Utils.cs @@ -5,7 +5,6 @@ // -------------------------------------------------------------------------------------------------------------------- namespace Microsoft.AI.Foundry.Local; - using System.Text.Json; using System.Threading.Tasks; @@ -24,7 +23,12 @@ internal static async Task GetCachedModelIdsAsync(ICoreInterop coreInt } var typeInfo = JsonSerializationContext.Default.StringArray; - var cachedModelIds = JsonSerializer.Deserialize(result.Data!, typeInfo) ?? throw new FoundryLocalException($"Failed to deserialized cached model names. Json:'{result.Data!}'"); + var cachedModelIds = JsonSerializer.Deserialize(result.Data!, typeInfo); + if (cachedModelIds == null) + { + throw new FoundryLocalException($"Failed to deserialized cached model names. Json:'{result.Data!}'"); + } + return cachedModelIds; } diff --git a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs index 76e1562b..cd7e7793 100644 --- a/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs +++ b/sdk/cs/test/FoundryLocal.Tests/FoundryLocalManagerTest.cs @@ -57,12 +57,12 @@ public async Task Manager_GetCatalog_Succeeds() [Test] public async Task Catalog_ListCachedLoadUnload_Succeeds() { - List logSink = []; + List logSink = new(); var logger = Utils.CreateCapturingLoggerMock(logSink); using var loadManager = new ModelLoadManager(null, Utils.CoreInterop, logger.Object); - List intercepts = - [ + List intercepts = new() + { new Utils.InteropCommandInterceptInfo { CommandName = "initialize", @@ -70,7 +70,7 @@ public async Task Catalog_ListCachedLoadUnload_Succeeds() ResponseData = "Success", ResponseError = null } - ]; + }; var coreInterop = Utils.CreateCoreInteropWithIntercept(Utils.CoreInterop, intercepts); using var catalog = await Catalog.CreateAsync(loadManager, coreInterop.Object, logger.Object); await Assert.That(catalog).IsNotNull(); diff --git a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs index c1801977..2bc39d68 100644 --- a/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/LiveAudioTranscriptionTests.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -6,6 +6,8 @@ namespace Microsoft.AI.Foundry.Local.Tests; +using System.Text.Json; +using Microsoft.AI.Foundry.Local.Detail; using Microsoft.AI.Foundry.Local.OpenAI; internal sealed class LiveAudioTranscriptionTests @@ -15,7 +17,7 @@ internal sealed class LiveAudioTranscriptionTests [Test] public async Task FromJson_ParsesTextAndIsFinal() { - var json = /*lang=json,strict*/ """{"is_final":true,"text":"hello world","start_time":null,"end_time":null}"""; + var json = """{"is_final":true,"text":"hello world","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -29,7 +31,7 @@ public async Task FromJson_ParsesTextAndIsFinal() [Test] public async Task FromJson_MapsTimingFields() { - var json = /*lang=json,strict*/ """{"is_final":false,"text":"partial","start_time":1.5,"end_time":3.0}"""; + var json = """{"is_final":false,"text":"partial","start_time":1.5,"end_time":3.0}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -42,7 +44,7 @@ public async Task FromJson_MapsTimingFields() [Test] public async Task FromJson_EmptyText_ParsesSuccessfully() { - var json = /*lang=json,strict*/ """{"is_final":true,"text":"","start_time":null,"end_time":null}"""; + var json = """{"is_final":true,"text":"","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -53,7 +55,7 @@ public async Task FromJson_EmptyText_ParsesSuccessfully() [Test] public async Task FromJson_OnlyStartTime_SetsStartTime() { - var json = /*lang=json,strict*/ """{"is_final":true,"text":"word","start_time":2.0,"end_time":null}"""; + var json = """{"is_final":true,"text":"word","start_time":2.0,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -73,7 +75,7 @@ public async Task FromJson_InvalidJson_Throws() [Test] public async Task FromJson_ContentHasTextAndTranscript() { - var json = /*lang=json,strict*/ """{"is_final":true,"text":"test","start_time":null,"end_time":null}"""; + var json = """{"is_final":true,"text":"test","start_time":null,"end_time":null}"""; var result = LiveAudioTranscriptionResponse.FromJson(json); @@ -100,7 +102,7 @@ public async Task Options_DefaultValues() [Test] public async Task CoreErrorResponse_TryParse_ValidJson() { - var json = /*lang=json,strict*/ """{"code":"ASR_SESSION_NOT_FOUND","message":"Session not found","isTransient":false}"""; + var json = """{"code":"ASR_SESSION_NOT_FOUND","message":"Session not found","isTransient":false}"""; var error = CoreErrorResponse.TryParse(json); @@ -120,7 +122,7 @@ public async Task CoreErrorResponse_TryParse_InvalidJson_ReturnsNull() [Test] public async Task CoreErrorResponse_TryParse_TransientError() { - var json = /*lang=json,strict*/ """{"code":"BUSY","message":"Model busy","isTransient":true}"""; + var json = """{"code":"BUSY","message":"Model busy","isTransient":true}"""; var error = CoreErrorResponse.TryParse(json); @@ -222,22 +224,22 @@ public async Task LiveStreaming_E2E_WithSyntheticPCM_ReturnsValidResponse() const int sampleRate = 16000; const int durationSeconds = 2; const double frequency = 440.0; - var totalSamples = sampleRate * durationSeconds; + int totalSamples = sampleRate * durationSeconds; var pcmBytes = new byte[totalSamples * 2]; // 16-bit = 2 bytes per sample - for (var i = 0; i < totalSamples; i++) + for (int i = 0; i < totalSamples; i++) { - var t = (double)i / sampleRate; - var sample = (short)(short.MaxValue * 0.5 * Math.Sin(2 * Math.PI * frequency * t)); + double t = (double)i / sampleRate; + short sample = (short)(short.MaxValue * 0.5 * Math.Sin(2 * Math.PI * frequency * t)); pcmBytes[i * 2] = (byte)(sample & 0xFF); - pcmBytes[(i * 2) + 1] = (byte)((sample >> 8) & 0xFF); + pcmBytes[i * 2 + 1] = (byte)((sample >> 8) & 0xFF); } // Push audio in chunks (100ms each, matching typical mic callback size) - var chunkSize = sampleRate / 10 * 2; // 100ms of 16-bit audio - for (var offset = 0; offset < pcmBytes.Length; offset += chunkSize) + int chunkSize = sampleRate / 10 * 2; // 100ms of 16-bit audio + for (int offset = 0; offset < pcmBytes.Length; offset += chunkSize) { - var len = Math.Min(chunkSize, pcmBytes.Length - offset); + int len = Math.Min(chunkSize, pcmBytes.Length - offset); await session.AppendAsync(new ReadOnlyMemory(pcmBytes, offset, len)); } diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index 8080ad60..005d8b0b 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -358,8 +358,8 @@ private static List BuildTestCatalog(bool includeCuda = true) }); } - list.AddRange( - [ + list.AddRange(new[] + { new ModelInfo { Id = "model-3-generic-gpu:1", @@ -403,7 +403,7 @@ private static List BuildTestCatalog(bool includeCuda = true) MaxOutputTokens = common.MaxOutputTokens, MinFLVersion = common.MinFLVersion } - ]); + }); // model-4 generic-gpu (nullable prompt) list.Add(new ModelInfo From 1d7365030ca3cfd6b9b729d7b307449a2c74acf5 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:25:19 -0500 Subject: [PATCH 10/35] Remove stale Id field from LiveAudioTranscriptionResponse table in C# README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/cs/README.md b/sdk/cs/README.md index b7b4c487..ff03e6e6 100644 --- a/sdk/cs/README.md +++ b/sdk/cs/README.md @@ -314,7 +314,6 @@ await session.StopAsync(); | `IsFinal` | `bool` | Whether this is a final or interim result. Nemotron always returns `true`. | | `StartTime` | `double?` | Start time offset in the audio stream (seconds). | | `EndTime` | `double?` | End time offset in the audio stream (seconds). | -| `Id` | `string?` | Unique identifier for this result (if available). | #### Session Lifecycle From 039fc8546e8a28442bacc8b8b1cff737df05978c Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:34:36 -0500 Subject: [PATCH 11/35] Rename extension classes for clarity - AudioTranscriptionRequestResponseExtensions -> AudioTranscriptionExtensions - ChatCompletionsRequestResponseExtensions -> ChatCompletionExtensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/AudioClient.cs | 6 +++--- sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs | 2 +- sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/cs/src/OpenAI/AudioClient.cs b/sdk/cs/src/OpenAI/AudioClient.cs index 1052ca97..e5ce01a4 100644 --- a/sdk/cs/src/OpenAI/AudioClient.cs +++ b/sdk/cs/src/OpenAI/AudioClient.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -94,7 +94,7 @@ public LiveAudioTranscriptionSession CreateLiveTranscriptionSession() private async Task TranscribeAudioImplAsync(string audioFilePath, CancellationToken? ct) { - var openaiRequest = AudioTranscriptionRequestResponseExtensions.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionExtensions.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest @@ -117,7 +117,7 @@ private async Task TranscribeAudioImplAsync(string a private async IAsyncEnumerable TranscribeAudioStreamingImplAsync( string audioFilePath, [EnumeratorCancellation] CancellationToken ct) { - var openaiRequest = AudioTranscriptionRequestResponseExtensions.FromUserInput(_modelId, audioFilePath, Settings); + var openaiRequest = AudioTranscriptionExtensions.FromUserInput(_modelId, audioFilePath, Settings); var request = new CoreInteropRequest { diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index f3a0eee2..bd06320d 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -14,7 +14,7 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using Microsoft.Extensions.Logging; -internal static class AudioTranscriptionRequestResponseExtensions +internal static class AudioTranscriptionExtensions { internal static AudioTranscriptionRequest FromUserInput(string modelId, string audioFilePath, diff --git a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs index 1307ec26..c09838fc 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs @@ -113,7 +113,7 @@ internal static ChatCompletionRequest FromUserInput(string modelId, } } -internal static class ChatCompletionsRequestResponseExtensions +internal static class ChatCompletionExtensions { internal static string ToJson(this ChatCompletionRequest request) { From 976d9fbf04698c93e7994ed3d745beae888596e4 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 20:36:16 -0500 Subject: [PATCH 12/35] Restore 'apply our specific settings' comment in AudioTranscriptionExtensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs index bd06320d..8ccb7fef 100644 --- a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs @@ -24,6 +24,8 @@ internal static AudioTranscriptionRequest FromUserInput(string modelId, { Model = modelId, FileName = audioFilePath, + + // apply our specific settings Language = settings.Language, Temperature = settings.Temperature }; From fa30c80d6ee264f75a1f1a358d7a3b6c2ed165ae Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 21:07:29 -0500 Subject: [PATCH 13/35] Update Rust api.md re-exported types to match actual exports - Remove ChatCompletionToolChoiceOption and ChatCompletionNamedToolChoice (not exported; tool choice uses ChatToolChoice enum instead) - Add Audio types section (AudioTranscriptionResponse, AudioTranscriptionStream, TranscriptionSegment, TranscriptionWord) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/rust/docs/api.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sdk/rust/docs/api.md b/sdk/rust/docs/api.md index 00bf934f..aa6926a0 100644 --- a/sdk/rust/docs/api.md +++ b/sdk/rust/docs/api.md @@ -526,8 +526,6 @@ The following OpenAI-compatible types are re-exported at the crate root for conv - `ChatCompletionRequestAssistantMessage` - `ChatCompletionRequestToolMessage` - `ChatCompletionTools` -- `ChatCompletionToolChoiceOption` -- `ChatCompletionNamedToolChoice` - `FunctionObject` **Response types:** @@ -546,3 +544,9 @@ The following OpenAI-compatible types are re-exported at the crate root for conv - `ChatCompletionMessageToolCalls` - `FunctionCall` - `FunctionCallStream` + +**Audio types:** +- `AudioTranscriptionResponse` +- `AudioTranscriptionStream` +- `TranscriptionSegment` +- `TranscriptionWord` From 8a7c84dc539b8af5226105954978d923ffa0dc82 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 21:26:43 -0500 Subject: [PATCH 14/35] Address PR review: nullable Role for streaming deltas, robust ToolChoiceConverter, fix comment - ChatMessage.Role: ChatMessageRole -> ChatMessageRole? (nullable) so streaming deltas that omit role get null instead of defaulting to System (0) - ToolChoiceConverter.Read(): explicit JsonTokenType.Null handling, throw JsonException for unexpected token types instead of silently returning null - AudioTypes.cs: update comment to note Metadata as exception to PascalCase rule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/AudioTypes.cs | 5 +++-- sdk/cs/src/OpenAI/ChatMessage.cs | 2 +- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs index 8906b20a..5cfd22c9 100644 --- a/sdk/cs/src/OpenAI/AudioTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -31,8 +31,9 @@ public class AudioTranscriptionResponse } /// -/// Internal request DTO for audio transcription. Uses PascalCase properties (no JsonPropertyName) -/// to match the convention that the SDK previously relied on for native core communication. +/// Internal request DTO for audio transcription. Most properties use PascalCase +/// (no JsonPropertyName) for native core communication; Metadata is the exception +/// as it follows the OpenAI wire format. /// internal class AudioTranscriptionRequest { diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 4a0f8084..b7b95ca0 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -50,7 +50,7 @@ public class ChatMessage { /// The role of the message author. [JsonPropertyName("role")] - public ChatMessageRole Role { get; set; } + public ChatMessageRole? Role { get; set; } /// The text content of the message. [JsonPropertyName("content")] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 487d9bd0..a7bd0164 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -158,6 +158,11 @@ internal class ToolChoiceConverter : JsonConverter { public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType == JsonTokenType.String) { return new ToolChoice { Type = reader.GetString() }; @@ -165,7 +170,7 @@ internal class ToolChoiceConverter : JsonConverter if (reader.TokenType != JsonTokenType.StartObject) { - return null; + throw new JsonException($"Unexpected token type {reader.TokenType} when deserializing ToolChoice."); } var choice = new ToolChoice(); From b70bfcd75e8a621720bc272b4abcf3517296e498 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 21:39:02 -0500 Subject: [PATCH 15/35] Add FunctionCall to C# FinishReason enum to match Rust and official OpenAI SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 373aa2c8..302051ec 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -29,7 +29,11 @@ public enum FinishReason /// Content was filtered by safety policy. [JsonStringEnumMemberName("content_filter")] - ContentFilter + ContentFilter, + + /// The model called a function (deprecated in favor of tool_calls). + [JsonStringEnumMemberName("function_call")] + FunctionCall } /// From b179f977c5cac4db24d628fac1173e6e87c916cd Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 21:50:16 -0500 Subject: [PATCH 16/35] Add max_completion_tokens to Rust, Python, and JS SDKs (closes #576) The newer OpenAI field max_completion_tokens was only in the C# SDK. Add it to all other SDKs for full cross-SDK parity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/js/src/openai/chatClient.ts | 2 ++ sdk/python/src/openai/chat_client.py | 3 +++ sdk/rust/src/openai/chat_client.rs | 10 ++++++++++ 3 files changed, 15 insertions(+) diff --git a/sdk/js/src/openai/chatClient.ts b/sdk/js/src/openai/chatClient.ts index f844da41..fc2c84aa 100644 --- a/sdk/js/src/openai/chatClient.ts +++ b/sdk/js/src/openai/chatClient.ts @@ -4,6 +4,7 @@ import { ResponseFormat, ToolChoice } from '../types.js'; export class ChatClientSettings { frequencyPenalty?: number; maxTokens?: number; + maxCompletionTokens?: number; n?: number; temperature?: number; presencePenalty?: number; @@ -31,6 +32,7 @@ export class ChatClientSettings { const result: any = { frequency_penalty: this.frequencyPenalty, max_tokens: this.maxTokens, + max_completion_tokens: this.maxCompletionTokens, n: this.n, presence_penalty: this.presencePenalty, temperature: this.temperature, diff --git a/sdk/python/src/openai/chat_client.py b/sdk/python/src/openai/chat_client.py index 0b0d58bc..4a214661 100644 --- a/sdk/python/src/openai/chat_client.py +++ b/sdk/python/src/openai/chat_client.py @@ -34,6 +34,7 @@ def __init__( self, frequency_penalty: Optional[float] = None, max_tokens: Optional[int] = None, + max_completion_tokens: Optional[int] = None, n: Optional[int] = None, temperature: Optional[float] = None, presence_penalty: Optional[float] = None, @@ -45,6 +46,7 @@ def __init__( ): self.frequency_penalty = frequency_penalty self.max_tokens = max_tokens + self.max_completion_tokens = max_completion_tokens self.n = n self.temperature = temperature self.presence_penalty = presence_penalty @@ -63,6 +65,7 @@ def _serialize(self) -> Dict[str, Any]: k: v for k, v in { "frequency_penalty": self.frequency_penalty, "max_tokens": self.max_tokens, + "max_completion_tokens": self.max_completion_tokens, "n": self.n, "presence_penalty": self.presence_penalty, "temperature": self.temperature, diff --git a/sdk/rust/src/openai/chat_client.rs b/sdk/rust/src/openai/chat_client.rs index 81a0d39e..6c709b33 100644 --- a/sdk/rust/src/openai/chat_client.rs +++ b/sdk/rust/src/openai/chat_client.rs @@ -28,6 +28,7 @@ use super::json_stream::JsonStream; pub struct ChatClientSettings { frequency_penalty: Option, max_tokens: Option, + max_completion_tokens: Option, n: Option, temperature: Option, presence_penalty: Option, @@ -48,6 +49,9 @@ impl ChatClientSettings { if let Some(v) = self.max_tokens { map.insert("max_tokens".into(), json!(v)); } + if let Some(v) = self.max_completion_tokens { + map.insert("max_completion_tokens".into(), json!(v)); + } if let Some(v) = self.n { map.insert("n".into(), json!(v)); } @@ -152,6 +156,12 @@ impl ChatClient { self } + /// Set the maximum number of completion tokens to generate (newer OpenAI field). + pub fn max_completion_tokens(mut self, v: u32) -> Self { + self.settings.max_completion_tokens = Some(v); + self + } + /// Set the number of completions to generate. pub fn n(mut self, v: u32) -> Self { self.settings.n = Some(v); From 419290913382a7ec278949aa9a9cb7847cae1246 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 21:54:50 -0500 Subject: [PATCH 17/35] Fix Rust clippy: remove useless String-to-String conversions Clippy with -D warnings rejects .into() on values already of type String. Remove redundant .into() calls in examples, samples, and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/rust/tool-calling-foundry-local/src/main.rs | 2 +- samples/rust/tutorial-tool-calling/src/main.rs | 2 +- sdk/rust/examples/interactive_chat.rs | 2 +- sdk/rust/examples/tool_calling.rs | 2 +- sdk/rust/tests/integration/chat_client_test.rs | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/samples/rust/tool-calling-foundry-local/src/main.rs b/samples/rust/tool-calling-foundry-local/src/main.rs index 1ccda1e8..9928224e 100644 --- a/samples/rust/tool-calling-foundry-local/src/main.rs +++ b/samples/rust/tool-calling-foundry-local/src/main.rs @@ -189,7 +189,7 @@ async fn main() -> Result<(), Box> { messages.push(assistant_msg); messages.push( ChatCompletionRequestToolMessage { - content: result.into(), + content: result, tool_call_id: tc["id"].as_str().unwrap_or_default().to_string(), } .into(), diff --git a/samples/rust/tutorial-tool-calling/src/main.rs b/samples/rust/tutorial-tool-calling/src/main.rs index f4476643..356303e2 100644 --- a/samples/rust/tutorial-tool-calling/src/main.rs +++ b/samples/rust/tutorial-tool-calling/src/main.rs @@ -294,7 +294,7 @@ async fn main() -> anyhow::Result<()> { execute_tool(function_name, &arguments); messages.push( ChatCompletionRequestToolMessage { - content: result.to_string().into(), + content: result.to_string(), tool_call_id: tool_call.id.clone(), } .into(), diff --git a/sdk/rust/examples/interactive_chat.rs b/sdk/rust/examples/interactive_chat.rs index bd230155..44ea1887 100644 --- a/sdk/rust/examples/interactive_chat.rs +++ b/sdk/rust/examples/interactive_chat.rs @@ -96,7 +96,7 @@ async fn main() -> Result<(), Box> { // Add assistant reply to history for multi-turn conversation messages.push( ChatCompletionRequestAssistantMessage { - content: Some(full_response.into()), + content: Some(full_response), ..Default::default() } .into(), diff --git a/sdk/rust/examples/tool_calling.rs b/sdk/rust/examples/tool_calling.rs index fecf6bc5..ea8bce5a 100644 --- a/sdk/rust/examples/tool_calling.rs +++ b/sdk/rust/examples/tool_calling.rs @@ -167,7 +167,7 @@ async fn main() -> Result<()> { messages.push(assistant_msg); messages.push( ChatCompletionRequestToolMessage { - content: result.into(), + content: result, tool_call_id: tc["id"].as_str().unwrap_or_default().to_string(), } .into(), diff --git a/sdk/rust/tests/integration/chat_client_test.rs b/sdk/rust/tests/integration/chat_client_test.rs index b24f3804..e7758ad5 100644 --- a/sdk/rust/tests/integration/chat_client_test.rs +++ b/sdk/rust/tests/integration/chat_client_test.rs @@ -208,7 +208,7 @@ async fn should_perform_tool_calling_chat_completion_non_streaming() { messages.push(assistant_msg); messages.push( ChatCompletionRequestToolMessage { - content: product.to_string().into(), + content: product.to_string(), tool_call_id: tool_call_id.clone(), } .into(), @@ -302,7 +302,7 @@ async fn should_perform_tool_calling_chat_completion_streaming() { messages.push(assistant_msg); messages.push( ChatCompletionRequestToolMessage { - content: product.to_string().into(), + content: product.to_string(), tool_call_id: tool_call_id.clone(), } .into(), From 5c677d67c2d25e363bb9f83f0802a27c90c6c0fa Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 22:16:58 -0500 Subject: [PATCH 18/35] Simplify Rust SDK: derive Serialize for settings, deduplicate From impls - Replace 80-line manual JSON serialization in ChatClientSettings::serialize() with #[derive(Serialize)] + serde attributes. Only metadata (top_k, random_seed) still needs manual handling since they're Foundry-specific string-encoded values. - Add custom Serialize impls for ChatResponseFormat and ChatToolChoice enums so they produce the correct wire-format JSON. - Simplify From<&str> impls for SystemMessage/UserMessage to delegate to From, eliminating duplicate struct construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/rust/src/openai/chat_client.rs | 82 ++++++------------------------ sdk/rust/src/openai/chat_types.rs | 10 +--- sdk/rust/src/types.rs | 61 +++++++++++++++++++++- 3 files changed, 78 insertions(+), 75 deletions(-) diff --git a/sdk/rust/src/openai/chat_client.rs b/sdk/rust/src/openai/chat_client.rs index 6c709b33..4653bc9f 100644 --- a/sdk/rust/src/openai/chat_client.rs +++ b/sdk/rust/src/openai/chat_client.rs @@ -7,6 +7,7 @@ use super::chat_types::{ ChatCompletionRequestMessage, ChatCompletionTools, CreateChatCompletionResponse, CreateChatCompletionStreamResponse, }; +use serde::Serialize; use serde_json::{json, Value}; use crate::detail::core_interop::CoreInterop; @@ -24,87 +25,36 @@ use super::json_stream::JsonStream; /// .temperature(0.7) /// .max_tokens(256); /// ``` -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize)] pub struct ChatClientSettings { + #[serde(skip_serializing_if = "Option::is_none")] frequency_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] max_completion_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] n: Option, + #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] presence_penalty: Option, + #[serde(skip_serializing_if = "Option::is_none")] top_p: Option, + #[serde(skip)] top_k: Option, + #[serde(skip)] random_seed: Option, + #[serde(skip_serializing_if = "Option::is_none")] response_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, } impl ChatClientSettings { fn serialize(&self) -> Value { - let mut map = serde_json::Map::new(); - - if let Some(v) = self.frequency_penalty { - map.insert("frequency_penalty".into(), json!(v)); - } - if let Some(v) = self.max_tokens { - map.insert("max_tokens".into(), json!(v)); - } - if let Some(v) = self.max_completion_tokens { - map.insert("max_completion_tokens".into(), json!(v)); - } - if let Some(v) = self.n { - map.insert("n".into(), json!(v)); - } - if let Some(v) = self.presence_penalty { - map.insert("presence_penalty".into(), json!(v)); - } - if let Some(v) = self.temperature { - map.insert("temperature".into(), json!(v)); - } - if let Some(v) = self.top_p { - map.insert("top_p".into(), json!(v)); - } - - if let Some(ref rf) = self.response_format { - let mut rf_map = serde_json::Map::new(); - match rf { - ChatResponseFormat::Text => { - rf_map.insert("type".into(), json!("text")); - } - ChatResponseFormat::JsonObject => { - rf_map.insert("type".into(), json!("json_object")); - } - ChatResponseFormat::JsonSchema(schema) => { - rf_map.insert("type".into(), json!("json_schema")); - rf_map.insert("jsonSchema".into(), json!(schema)); - } - ChatResponseFormat::LarkGrammar(grammar) => { - rf_map.insert("type".into(), json!("lark_grammar")); - rf_map.insert("larkGrammar".into(), json!(grammar)); - } - } - map.insert("response_format".into(), Value::Object(rf_map)); - } - - if let Some(ref tc) = self.tool_choice { - let mut tc_map = serde_json::Map::new(); - match tc { - ChatToolChoice::None => { - tc_map.insert("type".into(), json!("none")); - } - ChatToolChoice::Auto => { - tc_map.insert("type".into(), json!("auto")); - } - ChatToolChoice::Required => { - tc_map.insert("type".into(), json!("required")); - } - ChatToolChoice::Function(name) => { - tc_map.insert("type".into(), json!("function")); - tc_map.insert("name".into(), json!(name)); - } - } - map.insert("tool_choice".into(), Value::Object(tc_map)); - } + let mut value = serde_json::to_value(self).unwrap(); + let map = value.as_object_mut().unwrap(); // Foundry-specific metadata for settings that don't map directly to // the OpenAI spec. @@ -119,7 +69,7 @@ impl ChatClientSettings { map.insert("metadata".into(), json!(metadata)); } - Value::Object(map) + value } } diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs index a4e444c8..8f01a3d9 100644 --- a/sdk/rust/src/openai/chat_types.rs +++ b/sdk/rust/src/openai/chat_types.rs @@ -29,10 +29,7 @@ pub struct ChatCompletionRequestSystemMessage { impl From<&str> for ChatCompletionRequestSystemMessage { fn from(s: &str) -> Self { - Self { - content: s.to_owned(), - name: None, - } + Self::from(s.to_owned()) } } @@ -61,10 +58,7 @@ pub struct ChatCompletionRequestUserMessage { impl From<&str> for ChatCompletionRequestUserMessage { fn from(s: &str) -> Self { - Self { - content: s.to_owned(), - name: None, - } + Self::from(s.to_owned()) } } diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index 28b37ed2..9e85034b 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; /// Hardware device type for model execution. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -113,6 +113,36 @@ pub enum ChatResponseFormat { LarkGrammar(String), } +impl Serialize for ChatResponseFormat { + fn serialize(&self, serializer: S) -> std::result::Result { + use serde::ser::SerializeMap; + match self { + ChatResponseFormat::Text => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("type", "text")?; + map.end() + } + ChatResponseFormat::JsonObject => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("type", "json_object")?; + map.end() + } + ChatResponseFormat::JsonSchema(schema) => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", "json_schema")?; + map.serialize_entry("jsonSchema", schema)?; + map.end() + } + ChatResponseFormat::LarkGrammar(grammar) => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", "lark_grammar")?; + map.serialize_entry("larkGrammar", grammar)?; + map.end() + } + } + } +} + /// Tool choice configuration for chat completions. #[derive(Debug, Clone)] pub enum ChatToolChoice { @@ -126,6 +156,35 @@ pub enum ChatToolChoice { Function(String), } +impl Serialize for ChatToolChoice { + fn serialize(&self, serializer: S) -> std::result::Result { + use serde::ser::SerializeMap; + match self { + ChatToolChoice::None => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("type", "none")?; + map.end() + } + ChatToolChoice::Auto => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("type", "auto")?; + map.end() + } + ChatToolChoice::Required => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("type", "required")?; + map.end() + } + ChatToolChoice::Function(name) => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", "function")?; + map.serialize_entry("name", name)?; + map.end() + } + } + } +} + /// Information about an available execution provider bootstrapper. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] From 3f3efb8b4752bb5e68b8fc10254aa4f18496fd8f Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 22:19:28 -0500 Subject: [PATCH 19/35] Add refusal property to C# ChatMessage for cross-SDK parity Rust and JS already have refusal on their message types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/ChatMessage.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index b7b95ca0..9a6d755f 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -71,6 +71,10 @@ public class ChatMessage /// Deprecated function call generated by the model. [JsonPropertyName("function_call")] public FunctionCall? FunctionCall { get; set; } + + /// The refusal message generated by the model. + [JsonPropertyName("refusal")] + public string? Refusal { get; set; } } /// From b087f62c07cb60e3fc0d3cae2673cc53a6274863 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 22:27:12 -0500 Subject: [PATCH 20/35] Make ToolCall.Index and AudioTranscriptionResponse.Duration nullable ToolCall.Index is only present in streaming delta chunks, not in non-streaming responses. Duration may be absent from transcription responses. Both now match Rust SDK nullability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/AudioTypes.cs | 2 +- sdk/cs/src/OpenAI/ChatMessage.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs index 5cfd22c9..898e8c32 100644 --- a/sdk/cs/src/OpenAI/AudioTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -27,7 +27,7 @@ public class AudioTranscriptionResponse /// The duration of the audio in seconds. [JsonPropertyName("duration")] - public float Duration { get; set; } + public float? Duration { get; set; } } /// diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 9a6d755f..62aab957 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -82,9 +82,9 @@ public class ChatMessage /// public class ToolCall { - /// The index of this tool call in the list. + /// The index of this tool call in the list (streaming only). [JsonPropertyName("index")] - public int Index { get; set; } + public int? Index { get; set; } /// The unique ID of the tool call. [JsonPropertyName("id")] From 2d3f6593b769fb847133ee18adec52a6c13104b3 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 22:41:44 -0500 Subject: [PATCH 21/35] Rust review fixes: replace unwrap with expect, restore pub(crate) on detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use .expect() with descriptive messages instead of .unwrap() in ChatClientSettings::serialize() to document invariants - Restore detail module to pub(crate) — no external consumers need it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/rust/src/lib.rs | 2 +- sdk/rust/src/openai/chat_client.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 7272f665..1d47a52e 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -8,7 +8,7 @@ mod error; mod foundry_local_manager; mod types; -pub mod detail; +pub(crate) mod detail; pub mod openai; pub use self::catalog::Catalog; diff --git a/sdk/rust/src/openai/chat_client.rs b/sdk/rust/src/openai/chat_client.rs index 4653bc9f..a5e84e45 100644 --- a/sdk/rust/src/openai/chat_client.rs +++ b/sdk/rust/src/openai/chat_client.rs @@ -53,8 +53,11 @@ pub struct ChatClientSettings { impl ChatClientSettings { fn serialize(&self) -> Value { - let mut value = serde_json::to_value(self).unwrap(); - let map = value.as_object_mut().unwrap(); + let mut value = + serde_json::to_value(self).expect("ChatClientSettings should always be serializable"); + let map = value + .as_object_mut() + .expect("ChatClientSettings serializes to a JSON object"); // Foundry-specific metadata for settings that don't map directly to // the OpenAI spec. From e666360d956c1bea88cd04023d4d70910d7e3cbc Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 22:47:50 -0500 Subject: [PATCH 22/35] Clarify AudioTranscriptionRequest comment about PascalCase convention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/AudioTypes.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/cs/src/OpenAI/AudioTypes.cs b/sdk/cs/src/OpenAI/AudioTypes.cs index 898e8c32..a10e36af 100644 --- a/sdk/cs/src/OpenAI/AudioTypes.cs +++ b/sdk/cs/src/OpenAI/AudioTypes.cs @@ -31,9 +31,9 @@ public class AudioTranscriptionResponse } /// -/// Internal request DTO for audio transcription. Most properties use PascalCase -/// (no JsonPropertyName) for native core communication; Metadata is the exception -/// as it follows the OpenAI wire format. +/// Internal request DTO for audio transcription. Properties use PascalCase +/// (the default with no JsonPropertyName) for native Core interop communication. +/// Metadata is the exception — Core expects it as lowercase "metadata". /// internal class AudioTranscriptionRequest { From 4289c9c58f11a7c38b8504f45258321928291558 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 23:45:23 -0500 Subject: [PATCH 23/35] Add Developer role to ChatMessageRole in C# and Rust SDKs Developer is the newer replacement for system in reasoning models (o1, o3). Core already supports it; our SDKs would fail to deserialize it without this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/ChatMessage.cs | 6 +++++- sdk/rust/src/openai/chat_types.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 62aab957..67edc546 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -29,7 +29,11 @@ public enum ChatMessageRole /// Tool result message. [JsonStringEnumMemberName("tool")] - Tool + Tool, + + /// Developer instruction message (replaces system for reasoning models). + [JsonStringEnumMemberName("developer")] + Developer } /// diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs index 8f01a3d9..65f89b00 100644 --- a/sdk/rust/src/openai/chat_types.rs +++ b/sdk/rust/src/openai/chat_types.rs @@ -17,6 +17,8 @@ pub enum ChatCompletionRequestMessage { Assistant(ChatCompletionRequestAssistantMessage), #[serde(rename = "tool")] Tool(ChatCompletionRequestToolMessage), + #[serde(rename = "developer")] + Developer(ChatCompletionRequestDeveloperMessage), } /// A system message in a chat completion request. @@ -111,6 +113,35 @@ impl From for ChatCompletionRequestMessage { } } +/// A developer message in a chat completion request (replaces system for reasoning models). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionRequestDeveloperMessage { + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl From<&str> for ChatCompletionRequestDeveloperMessage { + fn from(s: &str) -> Self { + Self::from(s.to_owned()) + } +} + +impl From for ChatCompletionRequestDeveloperMessage { + fn from(s: String) -> Self { + Self { + content: s, + name: None, + } + } +} + +impl From for ChatCompletionRequestMessage { + fn from(msg: ChatCompletionRequestDeveloperMessage) -> Self { + Self::Developer(msg) + } +} + /// A tool definition for a chat completion request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatCompletionTools { From 26f34064941d3c59ffd26e15142b95d56690c1fe Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Fri, 3 Apr 2026 23:52:58 -0500 Subject: [PATCH 24/35] Rename internal extension files for clarity AudioTranscriptionRequestResponseTypes.cs -> AudioTranscriptionExtensions.cs ChatCompletionRequestResponseTypes.cs -> ChatCompletionExtensions.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ionRequestResponseTypes.cs => AudioTranscriptionExtensions.cs} | 0 ...pletionRequestResponseTypes.cs => ChatCompletionExtensions.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename sdk/cs/src/OpenAI/{AudioTranscriptionRequestResponseTypes.cs => AudioTranscriptionExtensions.cs} (100%) rename sdk/cs/src/OpenAI/{ChatCompletionRequestResponseTypes.cs => ChatCompletionExtensions.cs} (100%) diff --git a/sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/AudioTranscriptionExtensions.cs similarity index 100% rename from sdk/cs/src/OpenAI/AudioTranscriptionRequestResponseTypes.cs rename to sdk/cs/src/OpenAI/AudioTranscriptionExtensions.cs diff --git a/sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs b/sdk/cs/src/OpenAI/ChatCompletionExtensions.cs similarity index 100% rename from sdk/cs/src/OpenAI/ChatCompletionRequestResponseTypes.cs rename to sdk/cs/src/OpenAI/ChatCompletionExtensions.cs From 3708b9b1efe9719b74e0e4a04c6fd13d83fbfad5 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 00:05:21 -0500 Subject: [PATCH 25/35] Add token usage details and logprobs to C# and Rust SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompletionTokensDetails (reasoning_tokens) on CompletionUsage - PromptTokensDetails (cached_tokens) on CompletionUsage - logprobs on ChatChoice (C#) and ChatChoiceStream (Rust) Core has these internally but doesn't emit them in the OpenAI layer yet. Adding now for forward compatibility — all fields are nullable/optional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Detail/JsonSerializationContext.cs | 2 ++ sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 33 +++++++++++++++++++ sdk/rust/src/openai/chat_types.rs | 20 +++++++++++ 3 files changed, 55 insertions(+) diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index ae29256e..6de41ca9 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -23,6 +23,8 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ToolType))] [JsonSerializable(typeof(FinishReason))] [JsonSerializable(typeof(CompletionUsage))] +[JsonSerializable(typeof(CompletionTokensDetails))] +[JsonSerializable(typeof(PromptTokensDetails))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] [JsonSerializable(typeof(AudioTranscriptionResponse))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 302051ec..929d9ce3 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -7,6 +7,7 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; /// @@ -94,6 +95,10 @@ public class ChatChoice /// The reason the model stopped generating. [JsonPropertyName("finish_reason")] public FinishReason? FinishReason { get; set; } + + /// Log probability information for the choice. + [JsonPropertyName("logprobs")] + public JsonElement? Logprobs { get; set; } } /// @@ -112,6 +117,34 @@ public class CompletionUsage /// Total number of tokens used. [JsonPropertyName("total_tokens")] public int TotalTokens { get; set; } + + /// Breakdown of completion token usage. + [JsonPropertyName("completion_tokens_details")] + public CompletionTokensDetails? CompletionTokensDetails { get; set; } + + /// Breakdown of prompt token usage. + [JsonPropertyName("prompt_tokens_details")] + public PromptTokensDetails? PromptTokensDetails { get; set; } +} + +/// +/// Breakdown of completion token usage. +/// +public class CompletionTokensDetails +{ + /// Tokens used for reasoning. + [JsonPropertyName("reasoning_tokens")] + public int? ReasoningTokens { get; set; } +} + +/// +/// Breakdown of prompt token usage. +/// +public class PromptTokensDetails +{ + /// Tokens from cached content. + [JsonPropertyName("cached_tokens")] + public int? CachedTokens { get; set; } } /// diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs index 65f89b00..a2e18f64 100644 --- a/sdk/rust/src/openai/chat_types.rs +++ b/sdk/rust/src/openai/chat_types.rs @@ -210,6 +210,24 @@ pub struct CompletionUsage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_tokens_details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_tokens_details: Option, +} + +/// Breakdown of completion token usage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionTokensDetails { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, +} + +/// Breakdown of prompt token usage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptTokensDetails { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, } /// Reason the model stopped generating tokens. @@ -270,6 +288,8 @@ pub struct ChatChoiceStream { pub delta: ChatCompletionStreamResponseDelta, #[serde(default)] pub finish_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logprobs: Option, } /// The delta payload inside a streaming [`ChatChoiceStream`]. From de24f584b93c1b758b03f2006230f945c6d4478f Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 00:49:18 -0500 Subject: [PATCH 26/35] Remove forward-compat fields not yet emitted by Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert completion_tokens_details, prompt_tokens_details, and logprobs from C# and Rust SDKs. Core's OpenAI layer doesn't emit these yet — add them when it does instead of shipping always-null fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Detail/JsonSerializationContext.cs | 2 -- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 33 ------------------- sdk/rust/src/openai/chat_types.rs | 22 ------------- 3 files changed, 57 deletions(-) diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 6de41ca9..ae29256e 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -23,8 +23,6 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ToolType))] [JsonSerializable(typeof(FinishReason))] [JsonSerializable(typeof(CompletionUsage))] -[JsonSerializable(typeof(CompletionTokensDetails))] -[JsonSerializable(typeof(PromptTokensDetails))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] [JsonSerializable(typeof(AudioTranscriptionResponse))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 929d9ce3..302051ec 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -7,7 +7,6 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; using System.Collections.Generic; -using System.Text.Json; using System.Text.Json.Serialization; /// @@ -95,10 +94,6 @@ public class ChatChoice /// The reason the model stopped generating. [JsonPropertyName("finish_reason")] public FinishReason? FinishReason { get; set; } - - /// Log probability information for the choice. - [JsonPropertyName("logprobs")] - public JsonElement? Logprobs { get; set; } } /// @@ -117,34 +112,6 @@ public class CompletionUsage /// Total number of tokens used. [JsonPropertyName("total_tokens")] public int TotalTokens { get; set; } - - /// Breakdown of completion token usage. - [JsonPropertyName("completion_tokens_details")] - public CompletionTokensDetails? CompletionTokensDetails { get; set; } - - /// Breakdown of prompt token usage. - [JsonPropertyName("prompt_tokens_details")] - public PromptTokensDetails? PromptTokensDetails { get; set; } -} - -/// -/// Breakdown of completion token usage. -/// -public class CompletionTokensDetails -{ - /// Tokens used for reasoning. - [JsonPropertyName("reasoning_tokens")] - public int? ReasoningTokens { get; set; } -} - -/// -/// Breakdown of prompt token usage. -/// -public class PromptTokensDetails -{ - /// Tokens from cached content. - [JsonPropertyName("cached_tokens")] - public int? CachedTokens { get; set; } } /// diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs index a2e18f64..6a03c06f 100644 --- a/sdk/rust/src/openai/chat_types.rs +++ b/sdk/rust/src/openai/chat_types.rs @@ -185,8 +185,6 @@ pub struct ChatChoice { pub message: ChatCompletionResponseMessage, #[serde(default)] pub finish_reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logprobs: Option, } /// The assistant's message inside a [`ChatChoice`]. @@ -210,24 +208,6 @@ pub struct CompletionUsage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub completion_tokens_details: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_tokens_details: Option, -} - -/// Breakdown of completion token usage. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompletionTokensDetails { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_tokens: Option, -} - -/// Breakdown of prompt token usage. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PromptTokensDetails { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cached_tokens: Option, } /// Reason the model stopped generating tokens. @@ -288,8 +268,6 @@ pub struct ChatChoiceStream { pub delta: ChatCompletionStreamResponseDelta, #[serde(default)] pub finish_reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logprobs: Option, } /// The delta payload inside a streaming [`ChatChoiceStream`]. From 20dc2aaa090a20bc84c69e1156dea52b7b13332b Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 00:55:33 -0500 Subject: [PATCH 27/35] =?UTF-8?q?Remove=20refusal=20from=20C#=20and=20Rust?= =?UTF-8?q?=20SDKs=20=E2=80=94=20Core=20doesn't=20emit=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's OpenAI ChatMessage doesn't have a refusal field. Adding it to SDKs would create an always-null property. Will add when Core supports it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/OpenAI/ChatMessage.cs | 4 ---- sdk/rust/src/openai/chat_types.rs | 6 ------ 2 files changed, 10 deletions(-) diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 67edc546..94c8de7b 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -75,10 +75,6 @@ public class ChatMessage /// Deprecated function call generated by the model. [JsonPropertyName("function_call")] public FunctionCall? FunctionCall { get; set; } - - /// The refusal message generated by the model. - [JsonPropertyName("refusal")] - public string? Refusal { get; set; } } /// diff --git a/sdk/rust/src/openai/chat_types.rs b/sdk/rust/src/openai/chat_types.rs index 6a03c06f..003d5d2c 100644 --- a/sdk/rust/src/openai/chat_types.rs +++ b/sdk/rust/src/openai/chat_types.rs @@ -89,8 +89,6 @@ pub struct ChatCompletionRequestAssistantMessage { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub refusal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub function_call: Option, } @@ -198,8 +196,6 @@ pub struct ChatCompletionResponseMessage { pub tool_calls: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub function_call: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub refusal: Option, } /// Token usage statistics. @@ -281,8 +277,6 @@ pub struct ChatCompletionStreamResponseDelta { pub tool_calls: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub function_call: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub refusal: Option, } /// A partial tool call chunk received during streaming. From f2613abb20e27687442864811a78083203d5a77c Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 03:28:29 -0500 Subject: [PATCH 28/35] Fix PR review comments: wire format alignment and null safety - Rust ChatResponseFormat: use snake_case keys (json_schema, lark_grammar) instead of camelCase to match OpenAI wire format - Rust ChatToolChoice: serialize None/Auto/Required as plain strings and Function as {type: function, function: {name: ...}} per OpenAI spec - C# ToolChoiceConverter.Write(): add null check for Type to throw a clear JsonException instead of a runtime NullReferenceException - README: add missing using Microsoft.AI.Foundry.Local.OpenAI directive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + sdk/cs/src/OpenAI/ToolCallingTypes.cs | 6 +++++- sdk/rust/src/types.rs | 25 +++++++------------------ 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f0a83778..1866518a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ The Foundry Local SDK makes it easy to integrate local AI models into your appli 2. Use the SDK in your application as follows: ```csharp using Microsoft.AI.Foundry.Local; + using Microsoft.AI.Foundry.Local.OpenAI; var config = new Configuration { AppName = "foundry_local_samples" }; await FoundryLocalManager.CreateAsync(config); diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index a7bd0164..921e59df 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -203,12 +203,16 @@ public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializ { if (value.Function == null) { + if (value.Type == null) + { + throw new JsonException("ToolChoice.Type must not be null when serializing."); + } writer.WriteStringValue(value.Type); return; } writer.WriteStartObject(); - writer.WriteString("type", value.Type); + writer.WriteString("type", value.Type ?? "function"); writer.WritePropertyName("function"); JsonSerializer.Serialize(writer, value.Function, JsonSerializationContext.Default.FunctionTool); writer.WriteEndObject(); diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index 9e85034b..9e1ffb63 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -130,13 +130,13 @@ impl Serialize for ChatResponseFormat { ChatResponseFormat::JsonSchema(schema) => { let mut map = serializer.serialize_map(Some(2))?; map.serialize_entry("type", "json_schema")?; - map.serialize_entry("jsonSchema", schema)?; + map.serialize_entry("json_schema", schema)?; map.end() } ChatResponseFormat::LarkGrammar(grammar) => { let mut map = serializer.serialize_map(Some(2))?; map.serialize_entry("type", "lark_grammar")?; - map.serialize_entry("larkGrammar", grammar)?; + map.serialize_entry("lark_grammar", grammar)?; map.end() } } @@ -160,25 +160,14 @@ impl Serialize for ChatToolChoice { fn serialize(&self, serializer: S) -> std::result::Result { use serde::ser::SerializeMap; match self { - ChatToolChoice::None => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("type", "none")?; - map.end() - } - ChatToolChoice::Auto => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("type", "auto")?; - map.end() - } - ChatToolChoice::Required => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("type", "required")?; - map.end() - } + ChatToolChoice::None => serializer.serialize_str("none"), + ChatToolChoice::Auto => serializer.serialize_str("auto"), + ChatToolChoice::Required => serializer.serialize_str("required"), ChatToolChoice::Function(name) => { let mut map = serializer.serialize_map(Some(2))?; map.serialize_entry("type", "function")?; - map.serialize_entry("name", name)?; + let func = serde_json::json!({ "name": name }); + map.serialize_entry("function", &func)?; map.end() } } From 3fa11013d19b023c7e6c39492e86cb1513aab2b1 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 03:32:15 -0500 Subject: [PATCH 29/35] Remove redundant readonly modifiers from auto-properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readonly modifier on auto-properties inside a readonly struct is redundant — the struct and get-only accessors already guarantee immutability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/test/FoundryLocal.Tests/Utils.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index 005d8b0b..c10ea484 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -23,8 +23,8 @@ internal static class Utils { internal readonly struct TestCatalogInfo { - internal readonly List TestCatalog { get; } - internal readonly string ModelListJson { get; } + internal List TestCatalog { get; } + internal string ModelListJson { get; } internal TestCatalogInfo(bool includeCuda) { From 78a964f7abbb7e371a855e121a50cbe46ae249cb Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 04:07:54 -0500 Subject: [PATCH 30/35] Align ToolChoice with official OpenAI .NET SDK patterns - ToolDefinition.Type: non-nullable with default ToolType.Function - FunctionDefinition: Function and Name marked as required - ToolChoice: factory methods (CreateAutoChoice, CreateNoneChoice, CreateRequiredChoice, CreateFunctionChoice) instead of static properties - Update all callers in samples and tests - Update sample Directory.Packages.props WinML version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tool-calling-foundry-local-sdk/Program.cs | 4 +- samples/cs/tutorial-tool-calling/Program.cs | 2 +- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 37 ++++++++++++------- .../ChatCompletionsTests.cs | 10 ++--- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index f912913a..19b46347 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -57,7 +57,7 @@ await model.DownloadAsync(progress => // Get a chat client var chatClient = await model.GetChatClientAsync(); -chatClient.Settings.ToolChoice = ToolChoice.Required; // Force the model to make a tool call +chatClient.Settings.ToolChoice = ToolChoice.CreateRequiredChoice(); // Force the model to make a tool call // Prepare messages @@ -145,7 +145,7 @@ await model.DownloadAsync(progress => // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt -chatClient.Settings.ToolChoice = ToolChoice.Auto; +chatClient.Settings.ToolChoice = ToolChoice.CreateAutoChoice(); // Run the next turn of the conversation diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index ea59e679..163a6e21 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -134,7 +134,7 @@ await model.DownloadAsync(progress => Console.WriteLine("Model loaded and ready."); var chatClient = await model.GetChatClientAsync(); -chatClient.Settings.ToolChoice = ToolChoice.Auto; +chatClient.Settings.ToolChoice = ToolChoice.CreateAutoChoice(); var messages = new List { diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 921e59df..c0edae23 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -18,13 +18,13 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// public class ToolDefinition { - /// The type of tool. + /// The type of tool. Defaults to . [JsonPropertyName("type")] - public ToolType? Type { get; set; } + public ToolType Type { get; set; } = ToolType.Function; /// The function definition. [JsonPropertyName("function")] - public FunctionDefinition? Function { get; set; } + public required FunctionDefinition Function { get; set; } } /// @@ -34,7 +34,7 @@ public class FunctionDefinition { /// The name of the function. [JsonPropertyName("name")] - public string? Name { get; set; } + public required string Name { get; set; } /// A description of what the function does. [JsonPropertyName("description")] @@ -117,26 +117,35 @@ public class JsonSchema /// /// Controls which tool the model should use. -/// Use static properties , , or -/// for standard choices. +/// Use static methods , , +/// , or . /// [JsonConverter(typeof(ToolChoiceConverter))] public class ToolChoice { /// The tool choice type. - public string? Type { get; set; } + public string? Type { get; internal set; } /// Specifies a particular function to call. - public FunctionTool? Function { get; set; } + public FunctionTool? Function { get; internal set; } + + /// Creates a choice indicating the model will not call any tool. + public static ToolChoice CreateNoneChoice() => new() { Type = "none" }; - /// The model will not call any tool. - public static ToolChoice None => new() { Type = "none" }; + /// Creates a choice indicating the model can choose whether to call a tool. + public static ToolChoice CreateAutoChoice() => new() { Type = "auto" }; - /// The model can choose whether to call a tool. - public static ToolChoice Auto => new() { Type = "auto" }; + /// Creates a choice indicating the model must call one or more tools. + public static ToolChoice CreateRequiredChoice() => new() { Type = "required" }; - /// The model must call one or more tools. - public static ToolChoice Required => new() { Type = "required" }; + /// Creates a choice indicating the model must call the specified function. + /// is null. + /// is empty. + public static ToolChoice CreateFunctionChoice(string functionName) + { + ArgumentNullException.ThrowIfNullOrEmpty(functionName, nameof(functionName)); + return new() { Type = "function", Function = new FunctionTool { Name = functionName } }; + } /// /// Specifies a specific function tool to call. diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index eb77740a..2b57966a 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -136,7 +136,7 @@ public async Task DirectTool_NoStreaming_Succeeds() chatClient.Settings.MaxTokens = 500; chatClient.Settings.Temperature = 0.0f; // for deterministic results - chatClient.Settings.ToolChoice = ToolChoice.Required; // Force the model to make a tool call + chatClient.Settings.ToolChoice = ToolChoice.CreateRequiredChoice(); // Force the model to make a tool call // Prepare messages and tools List messages = @@ -202,7 +202,7 @@ public async Task DirectTool_NoStreaming_Succeeds() // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt - chatClient.Settings.ToolChoice = ToolChoice.Auto; + chatClient.Settings.ToolChoice = ToolChoice.CreateAutoChoice(); // Run the next turn of the conversation response = await chatClient.CompleteChatAsync(messages, tools).ConfigureAwait(false); @@ -220,7 +220,7 @@ public async Task DirectTool_Streaming_Succeeds() chatClient.Settings.MaxTokens = 500; chatClient.Settings.Temperature = 0.0f; // for deterministic results - chatClient.Settings.ToolChoice = ToolChoice.Required; // Force the model to make a tool call + chatClient.Settings.ToolChoice = ToolChoice.CreateRequiredChoice(); // Force the model to make a tool call // Prepare messages and tools List messages = @@ -306,7 +306,7 @@ public async Task DirectTool_Streaming_Succeeds() // Set tool calling back to auto so that the model can decide whether to call // the tool again or continue the conversation based on the new user prompt - chatClient.Settings.ToolChoice = ToolChoice.Auto; + chatClient.Settings.ToolChoice = ToolChoice.CreateAutoChoice(); // Run the next turn of the conversation updates = chatClient.CompleteChatStreamingAsync(messages, tools, CancellationToken.None).ConfigureAwait(false); From c5dd389f6657c50368849642078daf071083e6e4 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sat, 4 Apr 2026 21:48:15 -0500 Subject: [PATCH 31/35] Rename FinishReason -> ChatFinishReason, ToolType -> ChatToolKind Align enum names with the official OpenAI .NET SDK: - FinishReason renamed to ChatFinishReason - ToolType renamed to ChatToolKind (matching ChatToolKind in openai-dotnet) - Updated all references in SDK, tests, and samples - Updated sample Directory.Packages.props WinML version pin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/cs/Directory.Packages.props | 2 +- .../cs/tool-calling-foundry-local-sdk/Program.cs | 4 ++-- samples/cs/tutorial-tool-calling/Program.cs | 4 ++-- sdk/cs/src/Detail/JsonSerializationContext.cs | 4 ++-- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 6 +++--- sdk/cs/src/OpenAI/ChatMessage.cs | 8 ++++---- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 4 ++-- .../FoundryLocal.Tests/ChatCompletionsTests.cs | 14 +++++++------- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/samples/cs/Directory.Packages.props b/samples/cs/Directory.Packages.props index 428cbbf8..b1d67c33 100644 --- a/samples/cs/Directory.Packages.props +++ b/samples/cs/Directory.Packages.props @@ -6,7 +6,7 @@ - + diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index 19b46347..c250ad77 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -74,7 +74,7 @@ await model.DownloadAsync(progress => [ new ToolDefinition { - Type = ToolType.Function, + Type = ChatToolKind.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -106,7 +106,7 @@ await model.DownloadAsync(progress => Console.Write(content); Console.Out.Flush(); - if (chunk.Choices[0].FinishReason == FinishReason.ToolCalls) + if (chunk.Choices[0].FinishReason == ChatFinishReason.ToolCalls) { toolCallResponses.Add(chunk); } diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index 163a6e21..d009a028 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -14,7 +14,7 @@ [ new ToolDefinition { - Type = ToolType.Function, + Type = ChatToolKind.Function, Function = new FunctionDefinition() { Name = "get_weather", @@ -33,7 +33,7 @@ }, new ToolDefinition { - Type = ToolType.Function, + Type = ChatToolKind.Function, Function = new FunctionDefinition() { Name = "calculate", diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index ae29256e..19e6871c 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -20,8 +20,8 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ChatChoice))] [JsonSerializable(typeof(ChatMessage))] [JsonSerializable(typeof(ChatMessageRole))] -[JsonSerializable(typeof(ToolType))] -[JsonSerializable(typeof(FinishReason))] +[JsonSerializable(typeof(ChatToolKind))] +[JsonSerializable(typeof(ChatFinishReason))] [JsonSerializable(typeof(CompletionUsage))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 302051ec..c834db44 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -12,8 +12,8 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// /// Reason the model stopped generating tokens. /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum FinishReason +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChatFinishReason { /// The model finished naturally or hit a stop sequence. [JsonStringEnumMemberName("stop")] @@ -93,7 +93,7 @@ public class ChatChoice /// The reason the model stopped generating. [JsonPropertyName("finish_reason")] - public FinishReason? FinishReason { get; set; } + public ChatFinishReason? FinishReason { get; set; } } /// diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 94c8de7b..8415ffea 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -37,10 +37,10 @@ public enum ChatMessageRole } /// -/// Type of tool call or tool definition. +/// The kind of tool, corresponding to the type field in the OpenAI wire format. /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ToolType +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChatToolKind { /// A function tool. [JsonStringEnumMemberName("function")] @@ -92,7 +92,7 @@ public class ToolCall /// The type of tool call. [JsonPropertyName("type")] - public ToolType? Type { get; set; } + public ChatToolKind? Type { get; set; } /// The function that the model called. [JsonPropertyName("function")] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index c0edae23..da1d14ee 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -18,9 +18,9 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// public class ToolDefinition { - /// The type of tool. Defaults to . + /// The kind of tool. Defaults to . [JsonPropertyName("type")] - public ToolType Type { get; set; } = ToolType.Function; + public ChatToolKind Type { get; set; } = ChatToolKind.Function; /// The function definition. [JsonPropertyName("function")] diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 2b57966a..624ff7ff 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -148,7 +148,7 @@ public async Task DirectTool_NoStreaming_Succeeds() [ new ToolDefinition { - Type = ToolType.Function, + Type = ChatToolKind.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -175,7 +175,7 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response).IsNotNull(); await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); await Assert.That(response.Choices.Count).IsEqualTo(1); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo(ChatFinishReason.ToolCalls); await Assert.That(response.Choices[0].Message).IsNotNull(); await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); @@ -186,7 +186,7 @@ public async Task DirectTool_NoStreaming_Succeeds() var expectedResponse = "" + toolCall + ""; await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; @@ -232,7 +232,7 @@ public async Task DirectTool_Streaming_Succeeds() [ new ToolDefinition { - Type = ToolType.Function, + Type = ChatToolKind.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -271,7 +271,7 @@ public async Task DirectTool_Streaming_Succeeds() responseMessage.Append(content); numTokens += 1; } - if (response.Choices[0].FinishReason == FinishReason.ToolCalls) + if (response.Choices[0].FinishReason == ChatFinishReason.ToolCalls) { toolCallResponse = response; } @@ -287,10 +287,10 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(fullResponse).IsNotNull(); await Assert.That(fullResponse).IsEqualTo(expectedResponse); await Assert.That(toolCallResponse?.Choices.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); + await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(ChatFinishReason.ToolCalls); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; From f4b80ab6e7f68abada8b2e6cae8f69e9d561e30e Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sun, 5 Apr 2026 00:30:05 -0500 Subject: [PATCH 32/35] Rename CreatedAtUnix -> Created, ToolCall.FunctionCall -> ToolCall.Function Align property names with OpenAI wire format JSON field names: - ChatCompletionResponse.CreatedAtUnix -> Created (wire: "created") - ToolCall.FunctionCall -> ToolCall.Function (wire: "function") - Updated all references in tests and samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/cs/tool-calling-foundry-local-sdk/Program.cs | 2 +- samples/cs/tutorial-tool-calling/Program.cs | 6 +++--- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 2 +- sdk/cs/src/OpenAI/ChatMessage.cs | 2 +- sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index c250ad77..e1146044 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -117,7 +117,7 @@ await model.DownloadAsync(progress => // Invoke tools called and append responses to the chat foreach (var chunk in toolCallResponses) { - var call = chunk?.Choices[0].Message.ToolCalls?[0].FunctionCall; + var call = chunk?.Choices[0].Message.ToolCalls?[0].Function; if (call?.Name == "multiply_numbers") { var arguments = JsonSerializer.Deserialize>(call.Arguments!)!; diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index d009a028..81d1e486 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -180,14 +180,14 @@ await model.DownloadAsync(progress => foreach (var toolCall in choice.ToolCalls) { var toolArgs = JsonDocument.Parse( - toolCall.FunctionCall.Arguments + toolCall.Function.Arguments ).RootElement; Console.WriteLine( - $" Tool call: {toolCall.FunctionCall.Name}({toolArgs})" + $" Tool call: {toolCall.Function.Name}({toolArgs})" ); var result = ExecuteTool( - toolCall.FunctionCall.Name, toolArgs + toolCall.Function.Name, toolArgs ); messages.Add(new ChatMessage { diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index c834db44..2f8f8d95 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -51,7 +51,7 @@ public class ChatCompletionResponse /// The Unix timestamp when the completion was created. [JsonPropertyName("created")] - public long CreatedAtUnix { get; set; } + public long Created { get; set; } /// The model used for the completion. [JsonPropertyName("model")] diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 8415ffea..75ffe218 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -96,7 +96,7 @@ public class ToolCall /// The function that the model called. [JsonPropertyName("function")] - public FunctionCall? FunctionCall { get; set; } + public FunctionCall? Function { get; set; } } /// diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 624ff7ff..52b31ac1 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -187,11 +187,11 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Function?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].FunctionCall?.Arguments).IsEqualTo(expectedArguments); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Function?.Arguments).IsEqualTo(expectedArguments); // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolCallResponse = "7 x 6 = 42."; @@ -291,11 +291,11 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Name).IsEqualTo("multiply_numbers"); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Function?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; expectedArguments = OperatingSystemConverter.ToJson(expectedArguments); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].FunctionCall?.Arguments).IsEqualTo(expectedArguments); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Function?.Arguments).IsEqualTo(expectedArguments); // Add the response from invoking the tool call to the conversation and check if the model can continue correctly var toolResponse = "7 x 6 = 42."; From 3da32729f66b51c07cf4752d8e81e6b19eae1707 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sun, 5 Apr 2026 00:45:47 -0500 Subject: [PATCH 33/35] Revert ChatFinishReason/ChatToolKind to wire-aligned FinishReason/ToolType The Chat-prefixed names matched the official OpenAI SDK but were inconsistent with our wire-format-aligned naming convention for all other types. Reverting to the original names for consistency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cs/tool-calling-foundry-local-sdk/Program.cs | 4 ++-- samples/cs/tutorial-tool-calling/Program.cs | 4 ++-- sdk/cs/src/Detail/JsonSerializationContext.cs | 6 +++--- sdk/cs/src/OpenAI/ChatCompletionResponse.cs | 8 ++++---- sdk/cs/src/OpenAI/ChatMessage.cs | 10 +++++----- sdk/cs/src/OpenAI/ToolCallingTypes.cs | 6 +++--- .../FoundryLocal.Tests/ChatCompletionsTests.cs | 14 +++++++------- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/samples/cs/tool-calling-foundry-local-sdk/Program.cs b/samples/cs/tool-calling-foundry-local-sdk/Program.cs index e1146044..1050e075 100644 --- a/samples/cs/tool-calling-foundry-local-sdk/Program.cs +++ b/samples/cs/tool-calling-foundry-local-sdk/Program.cs @@ -74,7 +74,7 @@ await model.DownloadAsync(progress => [ new ToolDefinition { - Type = ChatToolKind.Function, + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -106,7 +106,7 @@ await model.DownloadAsync(progress => Console.Write(content); Console.Out.Flush(); - if (chunk.Choices[0].FinishReason == ChatFinishReason.ToolCalls) + if (chunk.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponses.Add(chunk); } diff --git a/samples/cs/tutorial-tool-calling/Program.cs b/samples/cs/tutorial-tool-calling/Program.cs index 81d1e486..761556a7 100644 --- a/samples/cs/tutorial-tool-calling/Program.cs +++ b/samples/cs/tutorial-tool-calling/Program.cs @@ -14,7 +14,7 @@ [ new ToolDefinition { - Type = ChatToolKind.Function, + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "get_weather", @@ -33,7 +33,7 @@ }, new ToolDefinition { - Type = ChatToolKind.Function, + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "calculate", diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 19e6871c..94cb2f06 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -20,8 +20,8 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(ChatChoice))] [JsonSerializable(typeof(ChatMessage))] [JsonSerializable(typeof(ChatMessageRole))] -[JsonSerializable(typeof(ChatToolKind))] -[JsonSerializable(typeof(ChatFinishReason))] +[JsonSerializable(typeof(ToolType))] +[JsonSerializable(typeof(FinishReason))] [JsonSerializable(typeof(CompletionUsage))] [JsonSerializable(typeof(ResponseError))] [JsonSerializable(typeof(AudioTranscriptionRequest))] diff --git a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs index 2f8f8d95..e499705e 100644 --- a/sdk/cs/src/OpenAI/ChatCompletionResponse.cs +++ b/sdk/cs/src/OpenAI/ChatCompletionResponse.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -12,8 +12,8 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// /// Reason the model stopped generating tokens. /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ChatFinishReason +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FinishReason { /// The model finished naturally or hit a stop sequence. [JsonStringEnumMemberName("stop")] @@ -93,7 +93,7 @@ public class ChatChoice /// The reason the model stopped generating. [JsonPropertyName("finish_reason")] - public ChatFinishReason? FinishReason { get; set; } + public FinishReason? FinishReason { get; set; } } /// diff --git a/sdk/cs/src/OpenAI/ChatMessage.cs b/sdk/cs/src/OpenAI/ChatMessage.cs index 75ffe218..ddf7da5e 100644 --- a/sdk/cs/src/OpenAI/ChatMessage.cs +++ b/sdk/cs/src/OpenAI/ChatMessage.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -37,10 +37,10 @@ public enum ChatMessageRole } /// -/// The kind of tool, corresponding to the type field in the OpenAI wire format. +/// Type of tool call or tool definition. /// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ChatToolKind +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ToolType { /// A function tool. [JsonStringEnumMemberName("function")] @@ -92,7 +92,7 @@ public class ToolCall /// The type of tool call. [JsonPropertyName("type")] - public ChatToolKind? Type { get; set; } + public ToolType? Type { get; set; } /// The function that the model called. [JsonPropertyName("function")] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index da1d14ee..1326fca8 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------------------------------- // // Copyright (c) Microsoft. All rights reserved. // @@ -18,9 +18,9 @@ namespace Microsoft.AI.Foundry.Local.OpenAI; /// public class ToolDefinition { - /// The kind of tool. Defaults to . + /// The type of tool. Defaults to . [JsonPropertyName("type")] - public ChatToolKind Type { get; set; } = ChatToolKind.Function; + public ToolType Type { get; set; } = ToolType.Function; /// The function definition. [JsonPropertyName("function")] diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 52b31ac1..b12d5d23 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -148,7 +148,7 @@ public async Task DirectTool_NoStreaming_Succeeds() [ new ToolDefinition { - Type = ChatToolKind.Function, + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -175,7 +175,7 @@ public async Task DirectTool_NoStreaming_Succeeds() await Assert.That(response).IsNotNull(); await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); await Assert.That(response.Choices.Count).IsEqualTo(1); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo(ChatFinishReason.ToolCalls); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(response.Choices[0].Message).IsNotNull(); await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); @@ -186,7 +186,7 @@ public async Task DirectTool_NoStreaming_Succeeds() var expectedResponse = "" + toolCall + ""; await Assert.That(response.Choices[0].Message.Content).IsEqualTo(expectedResponse); - await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); + await Assert.That(response.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); await Assert.That(response.Choices[0].Message.ToolCalls?[0].Function?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; @@ -232,7 +232,7 @@ public async Task DirectTool_Streaming_Succeeds() [ new ToolDefinition { - Type = ChatToolKind.Function, + Type = ToolType.Function, Function = new FunctionDefinition() { Name = "multiply_numbers", @@ -271,7 +271,7 @@ public async Task DirectTool_Streaming_Succeeds() responseMessage.Append(content); numTokens += 1; } - if (response.Choices[0].FinishReason == ChatFinishReason.ToolCalls) + if (response.Choices[0].FinishReason == FinishReason.ToolCalls) { toolCallResponse = response; } @@ -287,10 +287,10 @@ public async Task DirectTool_Streaming_Succeeds() await Assert.That(fullResponse).IsNotNull(); await Assert.That(fullResponse).IsEqualTo(expectedResponse); await Assert.That(toolCallResponse?.Choices.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(ChatFinishReason.ToolCalls); + await Assert.That(toolCallResponse?.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls).IsNotNull(); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?.Count).IsEqualTo(1); - await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ChatToolKind.Function); + await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Type).IsEqualTo(ToolType.Function); await Assert.That(toolCallResponse?.Choices[0].Message.ToolCalls?[0].Function?.Name).IsEqualTo("multiply_numbers"); var expectedArguments = /*lang=json*/ "{\r\n \"first\": 7,\r\n \"second\": 6\r\n}"; From 51828db2372d9d3ddaa71ca1b059e83ba285af6a Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sun, 5 Apr 2026 15:07:52 -0500 Subject: [PATCH 34/35] Add cross-SDK integration tests for type array tool parameters (#576) - C# SDK: Fix PropertyDefinition.Type from string? to object? with JsonSchemaTypeConverter to support both single types and type arrays (e.g. ["string", "null"]) per JSON Schema specification - Add type array tool calling test to all 4 SDKs (C#, Python, JS/TS, Rust) - Each test sends a tool with 'type': ['string', 'null'] parameter and verifies successful tool call response without 500 errors - Add get_type_array_tool() helper to shared test utilities in each SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Detail/JsonSerializationContext.cs | 1 + sdk/cs/src/OpenAI/ToolCallingTypes.cs | 71 ++++++++++++++++++- .../ChatCompletionsTests.cs | 53 ++++++++++++++ sdk/js/test/openai/chatClient.test.ts | 44 +++++++++++- sdk/js/test/testUtils.ts | 30 ++++++++ sdk/python/test/conftest.py | 30 ++++++++ sdk/python/test/openai/test_chat_client.py | 33 ++++++++- .../tests/integration/chat_client_test.rs | 43 +++++++++++ sdk/rust/tests/integration/common/mod.rs | 30 ++++++++ 9 files changed, 331 insertions(+), 4 deletions(-) diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 94cb2f06..8f56ac5b 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -40,6 +40,7 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(PropertyDefinition))] [JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(ToolCall))] [JsonSerializable(typeof(FunctionCall))] [JsonSerializable(typeof(JsonSchema))] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 1326fca8..9392a055 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -54,9 +54,13 @@ public class FunctionDefinition /// public class PropertyDefinition { - /// The data type (e.g. "object", "string", "integer", "array"). + /// + /// The data type. Can be a single type string (e.g. "object", "string", "integer") + /// or an array of types (e.g. ["string", "null"]) per JSON Schema specification. + /// [JsonPropertyName("type")] - public string? Type { get; set; } + [JsonConverter(typeof(JsonSchemaTypeConverter))] + public object? Type { get; set; } /// A description of the property. [JsonPropertyName("description")] @@ -227,3 +231,66 @@ public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializ writer.WriteEndObject(); } } + +/// +/// Custom JSON converter for the property that handles +/// both single type strings ("string") and type arrays (["string", "null"]) +/// per JSON Schema specification. +/// +internal class JsonSchemaTypeConverter : JsonConverter +{ + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString(); + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String) + { + list.Add(reader.GetString()!); + } + else + { + throw new JsonException($"Expected string in type array, got {reader.TokenType}."); + } + } + return list; + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for JSON Schema 'type'."); + } + + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string s: + writer.WriteStringValue(s); + break; + case IEnumerable arr: + writer.WriteStartArray(); + foreach (var item in arr) + { + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + break; + default: + throw new JsonException($"Cannot serialize {value.GetType()} as JSON Schema 'type'."); + } + } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index b12d5d23..192df5f0 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -330,4 +330,57 @@ public async Task DirectTool_Streaming_Succeeds() Console.WriteLine(fullResponse); await Assert.That(fullResponse).Contains("42"); } + + /// + /// Issue #576: Tools with JSON Schema type arrays (e.g. "type": ["string", "null"]) in + /// parameter definitions must not cause 500 errors. + /// + [Test] + public async Task TypeArrayToolParametersShouldNotCauseError() + { + await Assert.That(model).IsNotNull(); + + var chatClient = await model!.GetChatClientAsync(); + chatClient.Settings.MaxTokens = 200; + chatClient.Settings.Temperature = 0.0f; + chatClient.Settings.ToolChoice = ToolChoice.CreateRequiredChoice(); + + var messages = new List + { + new() { Role = ChatMessageRole.System, Content = "You are a helpful assistant with search tools." }, + new() { Role = ChatMessageRole.User, Content = "Search for 'hello world' in Python files." } + }; + + List tools = + [ + new ToolDefinition + { + Type = ToolType.Function, + Function = new FunctionDefinition() + { + Name = "grep_search", + Description = "Search files for a pattern", + Parameters = new PropertyDefinition() + { + Type = "object", + Properties = new Dictionary() + { + { "query", new PropertyDefinition() { Type = "string", Description = "The search pattern" } }, + { "includePattern", new PropertyDefinition() { Type = new List { "string", "null" }, Description = "Glob pattern to filter files" } } + }, + Required = ["query"], + AdditionalProperties = false + } + } + } + ]; + + var response = await chatClient.CompleteChatAsync(messages, tools).ConfigureAwait(false); + + await Assert.That(response).IsNotNull(); + await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); + await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); + await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); + await Assert.That(response.Choices[0].Message.ToolCalls![0].Function.Name).IsEqualTo("grep_search"); + } } diff --git a/sdk/js/test/openai/chatClient.test.ts b/sdk/js/test/openai/chatClient.test.ts index 7be190ce..6930bf52 100644 --- a/sdk/js/test/openai/chatClient.test.ts +++ b/sdk/js/test/openai/chatClient.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; -import { getMultiplyTool, getTestManager, TEST_MODEL_ALIAS } from '../testUtils.js'; +import { getMultiplyTool, getTypeArrayTool, getTestManager, TEST_MODEL_ALIAS } from '../testUtils.js'; describe('Chat Client Tests', () => { it('should perform chat completion', async function() { @@ -339,4 +339,46 @@ describe('Chat Client Tests', () => { await model.unload(); } }); + + it('should handle tools with type array parameters (issue #576)', async function() { + this.timeout(20000); + const manager = getTestManager(); + const catalog = manager.catalog; + + const cachedModels = await catalog.getCachedModels(); + expect(cachedModels.length).to.be.greaterThan(0); + + const cachedVariant = cachedModels.find(m => m.alias === TEST_MODEL_ALIAS); + expect(cachedVariant).to.not.be.undefined; + + const model = await catalog.getModel(TEST_MODEL_ALIAS); + expect(model).to.not.be.undefined; + if (!cachedVariant) return; + + model.selectVariant(cachedVariant); + await model.load(); + + try { + const client = model.createChatClient(); + client.settings.maxTokens = 200; + client.settings.temperature = 0.0; + client.settings.toolChoice = { type: 'required' }; + + const messages: any[] = [ + { role: 'system', content: 'You are a helpful assistant with search tools.' }, + { role: 'user', content: "Search for 'hello world' in Python files." } + ]; + const tools: any[] = [getTypeArrayTool()]; + + const response = await client.completeChat(messages, tools); + + expect(response).to.not.be.null; + expect(response.choices).to.be.an('array').and.have.length.greaterThan(0); + expect(response.choices[0].finish_reason).to.equal('tool_calls'); + expect(response.choices[0].message.tool_calls).to.be.an('array').and.have.length.greaterThan(0); + expect(response.choices[0].message.tool_calls[0].function.name).to.equal('grep_search'); + } finally { + await model.unload(); + } + }); }); \ No newline at end of file diff --git a/sdk/js/test/testUtils.ts b/sdk/js/test/testUtils.ts index 62cf7968..fa9fce46 100644 --- a/sdk/js/test/testUtils.ts +++ b/sdk/js/test/testUtils.ts @@ -68,3 +68,33 @@ export function getMultiplyTool() { }; return multiplyTool; } + +/** + * Tool definition using JSON Schema type arrays (issue #576). + * VS Code and other clients send parameter schemas with type arrays like + * "type": ["string", "null"]. This must not cause errors. + */ +export function getTypeArrayTool() { + return { + type: 'function', + function: { + name: 'grep_search', + description: 'Search files for a pattern', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search pattern' + }, + includePattern: { + type: ['string', 'null'], + description: 'Glob pattern to filter files' + } + }, + required: ['query'], + additionalProperties: false + } + } + }; +} diff --git a/sdk/python/test/conftest.py b/sdk/python/test/conftest.py index b7e22c97..fddef0f5 100644 --- a/sdk/python/test/conftest.py +++ b/sdk/python/test/conftest.py @@ -94,6 +94,36 @@ def get_multiply_tool(): } +def get_type_array_tool(): + """Tool definition using JSON Schema type arrays (issue #576). + + VS Code and other clients send parameter schemas with type arrays like + ``"type": ["string", "null"]``. This must not cause errors. + """ + return { + "type": "function", + "function": { + "name": "grep_search", + "description": "Search files for a pattern", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search pattern", + }, + "includePattern": { + "type": ["string", "null"], + "description": "Glob pattern to filter files", + }, + }, + "required": ["query"], + "additionalProperties": False, + }, + }, + } + + # --------------------------------------------------------------------------- # Session-scoped fixtures # --------------------------------------------------------------------------- diff --git a/sdk/python/test/openai/test_chat_client.py b/sdk/python/test/openai/test_chat_client.py index d96891b9..7d9153fa 100644 --- a/sdk/python/test/openai/test_chat_client.py +++ b/sdk/python/test/openai/test_chat_client.py @@ -10,7 +10,7 @@ import pytest -from ..conftest import TEST_MODEL_ALIAS, get_multiply_tool +from ..conftest import TEST_MODEL_ALIAS, get_multiply_tool, get_type_array_tool def _get_loaded_chat_model(catalog): @@ -226,6 +226,37 @@ def test_should_perform_tool_calling_streaming_chat_completion(self, catalog): finally: model.unload() + def test_type_array_tool_should_not_cause_error(self, catalog): + """Issue #576: tools with ``"type": ["string", "null"]`` must not cause 500 errors.""" + model = _get_loaded_chat_model(catalog) + try: + client = model.get_chat_client() + client.settings.max_tokens = 200 + client.settings.temperature = 0.0 + client.settings.tool_choice = {"type": "required"} + + messages = [ + {"role": "system", "content": "You are a helpful assistant with search tools."}, + {"role": "user", "content": "Search for 'hello world' in Python files."}, + ] + tools = [get_type_array_tool()] + + response = client.complete_chat(messages, tools) + + assert response is not None + assert response.choices is not None + assert len(response.choices) > 0 + + choice = response.choices[0] + assert choice.finish_reason == "tool_calls" + assert choice.message.tool_calls is not None + assert len(choice.message.tool_calls) > 0 + + tool_call = choice.message.tool_calls[0] + assert tool_call.function.name == "grep_search" + finally: + model.unload() + def test_should_return_generator(self, catalog): """complete_streaming_chat returns a generator that yields chunks.""" model = _get_loaded_chat_model(catalog) diff --git a/sdk/rust/tests/integration/chat_client_test.rs b/sdk/rust/tests/integration/chat_client_test.rs index e7758ad5..70b7b679 100644 --- a/sdk/rust/tests/integration/chat_client_test.rs +++ b/sdk/rust/tests/integration/chat_client_test.rs @@ -332,3 +332,46 @@ async fn should_perform_tool_calling_chat_completion_streaming() { model.unload().await.expect("model.unload() failed"); } + +/// Issue #576: tools with `"type": ["string", "null"]` parameter schemas must +/// not cause 500 errors. +#[tokio::test] +async fn should_handle_type_array_tool_parameters() { + let (client, model) = setup_chat_client().await; + let client = client.tool_choice(ChatToolChoice::Required); + + let tools = vec![common::get_type_array_tool()]; + let messages = vec![ + system_message("You are a helpful assistant with search tools."), + user_message("Search for 'hello world' in Python files."), + ]; + + let response = client + .complete_chat(&messages, Some(&tools)) + .await + .expect("complete_chat with type array tool failed"); + + let choice = response + .choices + .first() + .expect("Expected at least one choice"); + let tool_calls = choice + .message + .tool_calls + .as_ref() + .expect("Expected tool_calls"); + assert!( + !tool_calls.is_empty(), + "Expected at least one tool call in the response" + ); + + let tool_call = match &tool_calls[0] { + ChatCompletionMessageToolCalls::Function(tc) => tc, + }; + assert_eq!( + tool_call.function.name, "grep_search", + "Expected grep_search tool call" + ); + + model.unload().await.expect("model.unload() failed"); +} diff --git a/sdk/rust/tests/integration/common/mod.rs b/sdk/rust/tests/integration/common/mod.rs index b0ca1a77..1b603c07 100644 --- a/sdk/rust/tests/integration/common/mod.rs +++ b/sdk/rust/tests/integration/common/mod.rs @@ -117,3 +117,33 @@ pub fn get_multiply_tool() -> foundry_local_sdk::ChatCompletionTools { })) .expect("Failed to parse multiply tool definition") } + +/// Returns a tool definition using JSON Schema type arrays (issue #576). +/// +/// VS Code and other clients send parameter schemas with type arrays like +/// `"type": ["string", "null"]`. This must not cause errors. +pub fn get_type_array_tool() -> foundry_local_sdk::ChatCompletionTools { + serde_json::from_value(serde_json::json!({ + "type": "function", + "function": { + "name": "grep_search", + "description": "Search files for a pattern", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search pattern" + }, + "includePattern": { + "type": ["string", "null"], + "description": "Glob pattern to filter files" + } + }, + "required": ["query"], + "additionalProperties": false + } + } + })) + .expect("Failed to parse type array tool definition") +} From ac2168ebedbdbe991ad41ce6dcefc00b129f2c18 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Sun, 5 Apr 2026 19:24:45 -0500 Subject: [PATCH 35/35] Revert "Add cross-SDK integration tests for type array tool parameters (#576)" This reverts commit 51828db2372d9d3ddaa71ca1b059e83ba285af6a. --- sdk/cs/src/Detail/JsonSerializationContext.cs | 1 - sdk/cs/src/OpenAI/ToolCallingTypes.cs | 71 +------------------ .../ChatCompletionsTests.cs | 53 -------------- sdk/js/test/openai/chatClient.test.ts | 44 +----------- sdk/js/test/testUtils.ts | 30 -------- sdk/python/test/conftest.py | 30 -------- sdk/python/test/openai/test_chat_client.py | 33 +-------- .../tests/integration/chat_client_test.rs | 43 ----------- sdk/rust/tests/integration/common/mod.rs | 30 -------- 9 files changed, 4 insertions(+), 331 deletions(-) diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs index 8f56ac5b..94cb2f06 100644 --- a/sdk/cs/src/Detail/JsonSerializationContext.cs +++ b/sdk/cs/src/Detail/JsonSerializationContext.cs @@ -40,7 +40,6 @@ namespace Microsoft.AI.Foundry.Local.Detail; [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(PropertyDefinition))] [JsonSerializable(typeof(IList))] -[JsonSerializable(typeof(List))] [JsonSerializable(typeof(ToolCall))] [JsonSerializable(typeof(FunctionCall))] [JsonSerializable(typeof(JsonSchema))] diff --git a/sdk/cs/src/OpenAI/ToolCallingTypes.cs b/sdk/cs/src/OpenAI/ToolCallingTypes.cs index 9392a055..1326fca8 100644 --- a/sdk/cs/src/OpenAI/ToolCallingTypes.cs +++ b/sdk/cs/src/OpenAI/ToolCallingTypes.cs @@ -54,13 +54,9 @@ public class FunctionDefinition /// public class PropertyDefinition { - /// - /// The data type. Can be a single type string (e.g. "object", "string", "integer") - /// or an array of types (e.g. ["string", "null"]) per JSON Schema specification. - /// + /// The data type (e.g. "object", "string", "integer", "array"). [JsonPropertyName("type")] - [JsonConverter(typeof(JsonSchemaTypeConverter))] - public object? Type { get; set; } + public string? Type { get; set; } /// A description of the property. [JsonPropertyName("description")] @@ -231,66 +227,3 @@ public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializ writer.WriteEndObject(); } } - -/// -/// Custom JSON converter for the property that handles -/// both single type strings ("string") and type arrays (["string", "null"]) -/// per JSON Schema specification. -/// -internal class JsonSchemaTypeConverter : JsonConverter -{ - public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - { - return null; - } - - if (reader.TokenType == JsonTokenType.String) - { - return reader.GetString(); - } - - if (reader.TokenType == JsonTokenType.StartArray) - { - var list = new List(); - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - if (reader.TokenType == JsonTokenType.String) - { - list.Add(reader.GetString()!); - } - else - { - throw new JsonException($"Expected string in type array, got {reader.TokenType}."); - } - } - return list; - } - - throw new JsonException($"Unexpected token type {reader.TokenType} for JSON Schema 'type'."); - } - - public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) - { - switch (value) - { - case null: - writer.WriteNullValue(); - break; - case string s: - writer.WriteStringValue(s); - break; - case IEnumerable arr: - writer.WriteStartArray(); - foreach (var item in arr) - { - writer.WriteStringValue(item); - } - writer.WriteEndArray(); - break; - default: - throw new JsonException($"Cannot serialize {value.GetType()} as JSON Schema 'type'."); - } - } -} diff --git a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs index 192df5f0..b12d5d23 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ChatCompletionsTests.cs @@ -330,57 +330,4 @@ public async Task DirectTool_Streaming_Succeeds() Console.WriteLine(fullResponse); await Assert.That(fullResponse).Contains("42"); } - - /// - /// Issue #576: Tools with JSON Schema type arrays (e.g. "type": ["string", "null"]) in - /// parameter definitions must not cause 500 errors. - /// - [Test] - public async Task TypeArrayToolParametersShouldNotCauseError() - { - await Assert.That(model).IsNotNull(); - - var chatClient = await model!.GetChatClientAsync(); - chatClient.Settings.MaxTokens = 200; - chatClient.Settings.Temperature = 0.0f; - chatClient.Settings.ToolChoice = ToolChoice.CreateRequiredChoice(); - - var messages = new List - { - new() { Role = ChatMessageRole.System, Content = "You are a helpful assistant with search tools." }, - new() { Role = ChatMessageRole.User, Content = "Search for 'hello world' in Python files." } - }; - - List tools = - [ - new ToolDefinition - { - Type = ToolType.Function, - Function = new FunctionDefinition() - { - Name = "grep_search", - Description = "Search files for a pattern", - Parameters = new PropertyDefinition() - { - Type = "object", - Properties = new Dictionary() - { - { "query", new PropertyDefinition() { Type = "string", Description = "The search pattern" } }, - { "includePattern", new PropertyDefinition() { Type = new List { "string", "null" }, Description = "Glob pattern to filter files" } } - }, - Required = ["query"], - AdditionalProperties = false - } - } - } - ]; - - var response = await chatClient.CompleteChatAsync(messages, tools).ConfigureAwait(false); - - await Assert.That(response).IsNotNull(); - await Assert.That(response.Choices).IsNotNull().And.IsNotEmpty(); - await Assert.That(response.Choices[0].FinishReason).IsEqualTo(FinishReason.ToolCalls); - await Assert.That(response.Choices[0].Message.ToolCalls).IsNotNull().And.IsNotEmpty(); - await Assert.That(response.Choices[0].Message.ToolCalls![0].Function.Name).IsEqualTo("grep_search"); - } } diff --git a/sdk/js/test/openai/chatClient.test.ts b/sdk/js/test/openai/chatClient.test.ts index 6930bf52..7be190ce 100644 --- a/sdk/js/test/openai/chatClient.test.ts +++ b/sdk/js/test/openai/chatClient.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; -import { getMultiplyTool, getTypeArrayTool, getTestManager, TEST_MODEL_ALIAS } from '../testUtils.js'; +import { getMultiplyTool, getTestManager, TEST_MODEL_ALIAS } from '../testUtils.js'; describe('Chat Client Tests', () => { it('should perform chat completion', async function() { @@ -339,46 +339,4 @@ describe('Chat Client Tests', () => { await model.unload(); } }); - - it('should handle tools with type array parameters (issue #576)', async function() { - this.timeout(20000); - const manager = getTestManager(); - const catalog = manager.catalog; - - const cachedModels = await catalog.getCachedModels(); - expect(cachedModels.length).to.be.greaterThan(0); - - const cachedVariant = cachedModels.find(m => m.alias === TEST_MODEL_ALIAS); - expect(cachedVariant).to.not.be.undefined; - - const model = await catalog.getModel(TEST_MODEL_ALIAS); - expect(model).to.not.be.undefined; - if (!cachedVariant) return; - - model.selectVariant(cachedVariant); - await model.load(); - - try { - const client = model.createChatClient(); - client.settings.maxTokens = 200; - client.settings.temperature = 0.0; - client.settings.toolChoice = { type: 'required' }; - - const messages: any[] = [ - { role: 'system', content: 'You are a helpful assistant with search tools.' }, - { role: 'user', content: "Search for 'hello world' in Python files." } - ]; - const tools: any[] = [getTypeArrayTool()]; - - const response = await client.completeChat(messages, tools); - - expect(response).to.not.be.null; - expect(response.choices).to.be.an('array').and.have.length.greaterThan(0); - expect(response.choices[0].finish_reason).to.equal('tool_calls'); - expect(response.choices[0].message.tool_calls).to.be.an('array').and.have.length.greaterThan(0); - expect(response.choices[0].message.tool_calls[0].function.name).to.equal('grep_search'); - } finally { - await model.unload(); - } - }); }); \ No newline at end of file diff --git a/sdk/js/test/testUtils.ts b/sdk/js/test/testUtils.ts index fa9fce46..62cf7968 100644 --- a/sdk/js/test/testUtils.ts +++ b/sdk/js/test/testUtils.ts @@ -68,33 +68,3 @@ export function getMultiplyTool() { }; return multiplyTool; } - -/** - * Tool definition using JSON Schema type arrays (issue #576). - * VS Code and other clients send parameter schemas with type arrays like - * "type": ["string", "null"]. This must not cause errors. - */ -export function getTypeArrayTool() { - return { - type: 'function', - function: { - name: 'grep_search', - description: 'Search files for a pattern', - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search pattern' - }, - includePattern: { - type: ['string', 'null'], - description: 'Glob pattern to filter files' - } - }, - required: ['query'], - additionalProperties: false - } - } - }; -} diff --git a/sdk/python/test/conftest.py b/sdk/python/test/conftest.py index fddef0f5..b7e22c97 100644 --- a/sdk/python/test/conftest.py +++ b/sdk/python/test/conftest.py @@ -94,36 +94,6 @@ def get_multiply_tool(): } -def get_type_array_tool(): - """Tool definition using JSON Schema type arrays (issue #576). - - VS Code and other clients send parameter schemas with type arrays like - ``"type": ["string", "null"]``. This must not cause errors. - """ - return { - "type": "function", - "function": { - "name": "grep_search", - "description": "Search files for a pattern", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search pattern", - }, - "includePattern": { - "type": ["string", "null"], - "description": "Glob pattern to filter files", - }, - }, - "required": ["query"], - "additionalProperties": False, - }, - }, - } - - # --------------------------------------------------------------------------- # Session-scoped fixtures # --------------------------------------------------------------------------- diff --git a/sdk/python/test/openai/test_chat_client.py b/sdk/python/test/openai/test_chat_client.py index 7d9153fa..d96891b9 100644 --- a/sdk/python/test/openai/test_chat_client.py +++ b/sdk/python/test/openai/test_chat_client.py @@ -10,7 +10,7 @@ import pytest -from ..conftest import TEST_MODEL_ALIAS, get_multiply_tool, get_type_array_tool +from ..conftest import TEST_MODEL_ALIAS, get_multiply_tool def _get_loaded_chat_model(catalog): @@ -226,37 +226,6 @@ def test_should_perform_tool_calling_streaming_chat_completion(self, catalog): finally: model.unload() - def test_type_array_tool_should_not_cause_error(self, catalog): - """Issue #576: tools with ``"type": ["string", "null"]`` must not cause 500 errors.""" - model = _get_loaded_chat_model(catalog) - try: - client = model.get_chat_client() - client.settings.max_tokens = 200 - client.settings.temperature = 0.0 - client.settings.tool_choice = {"type": "required"} - - messages = [ - {"role": "system", "content": "You are a helpful assistant with search tools."}, - {"role": "user", "content": "Search for 'hello world' in Python files."}, - ] - tools = [get_type_array_tool()] - - response = client.complete_chat(messages, tools) - - assert response is not None - assert response.choices is not None - assert len(response.choices) > 0 - - choice = response.choices[0] - assert choice.finish_reason == "tool_calls" - assert choice.message.tool_calls is not None - assert len(choice.message.tool_calls) > 0 - - tool_call = choice.message.tool_calls[0] - assert tool_call.function.name == "grep_search" - finally: - model.unload() - def test_should_return_generator(self, catalog): """complete_streaming_chat returns a generator that yields chunks.""" model = _get_loaded_chat_model(catalog) diff --git a/sdk/rust/tests/integration/chat_client_test.rs b/sdk/rust/tests/integration/chat_client_test.rs index 70b7b679..e7758ad5 100644 --- a/sdk/rust/tests/integration/chat_client_test.rs +++ b/sdk/rust/tests/integration/chat_client_test.rs @@ -332,46 +332,3 @@ async fn should_perform_tool_calling_chat_completion_streaming() { model.unload().await.expect("model.unload() failed"); } - -/// Issue #576: tools with `"type": ["string", "null"]` parameter schemas must -/// not cause 500 errors. -#[tokio::test] -async fn should_handle_type_array_tool_parameters() { - let (client, model) = setup_chat_client().await; - let client = client.tool_choice(ChatToolChoice::Required); - - let tools = vec![common::get_type_array_tool()]; - let messages = vec![ - system_message("You are a helpful assistant with search tools."), - user_message("Search for 'hello world' in Python files."), - ]; - - let response = client - .complete_chat(&messages, Some(&tools)) - .await - .expect("complete_chat with type array tool failed"); - - let choice = response - .choices - .first() - .expect("Expected at least one choice"); - let tool_calls = choice - .message - .tool_calls - .as_ref() - .expect("Expected tool_calls"); - assert!( - !tool_calls.is_empty(), - "Expected at least one tool call in the response" - ); - - let tool_call = match &tool_calls[0] { - ChatCompletionMessageToolCalls::Function(tc) => tc, - }; - assert_eq!( - tool_call.function.name, "grep_search", - "Expected grep_search tool call" - ); - - model.unload().await.expect("model.unload() failed"); -} diff --git a/sdk/rust/tests/integration/common/mod.rs b/sdk/rust/tests/integration/common/mod.rs index 1b603c07..b0ca1a77 100644 --- a/sdk/rust/tests/integration/common/mod.rs +++ b/sdk/rust/tests/integration/common/mod.rs @@ -117,33 +117,3 @@ pub fn get_multiply_tool() -> foundry_local_sdk::ChatCompletionTools { })) .expect("Failed to parse multiply tool definition") } - -/// Returns a tool definition using JSON Schema type arrays (issue #576). -/// -/// VS Code and other clients send parameter schemas with type arrays like -/// `"type": ["string", "null"]`. This must not cause errors. -pub fn get_type_array_tool() -> foundry_local_sdk::ChatCompletionTools { - serde_json::from_value(serde_json::json!({ - "type": "function", - "function": { - "name": "grep_search", - "description": "Search files for a pattern", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search pattern" - }, - "includePattern": { - "type": ["string", "null"], - "description": "Glob pattern to filter files" - } - }, - "required": ["query"], - "additionalProperties": false - } - } - })) - .expect("Failed to parse type array tool definition") -}