diff --git a/samples/cs/Directory.Packages.props b/samples/cs/Directory.Packages.props index d799c4cd..db615d6c 100644 --- a/samples/cs/Directory.Packages.props +++ b/samples/cs/Directory.Packages.props @@ -10,6 +10,6 @@ - + diff --git a/samples/cs/README.md b/samples/cs/README.md index ad10a3c6..aeb02f25 100644 --- a/samples/cs/README.md +++ b/samples/cs/README.md @@ -15,6 +15,7 @@ Both packages provide the same APIs, so the same source code works on all platfo | [embeddings](embeddings/) | Generate single and batch text embeddings using the Foundry Local SDK. | | [audio-transcription-example](audio-transcription-example/) | Transcribe audio files using the Foundry Local SDK. | | [foundry-local-web-server](foundry-local-web-server/) | Set up a local OpenAI-compliant web server. | +| [responses-foundry-local-web-server](responses-foundry-local-web-server/) | Use the OpenAI Responses API (non-streaming, streaming, tool calling) against the local web server. | | [tool-calling-foundry-local-sdk](tool-calling-foundry-local-sdk/) | Use tool calling with native chat completions. | | [tool-calling-foundry-local-web-server](tool-calling-foundry-local-web-server/) | Use tool calling with the local web server. | | [model-management-example](model-management-example/) | Manage models, variant selection, and updates. | diff --git a/samples/cs/responses-foundry-local-web-server/Program.cs b/samples/cs/responses-foundry-local-web-server/Program.cs new file mode 100644 index 00000000..e850bf9c --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/Program.cs @@ -0,0 +1,174 @@ +// +// Demonstrates the OpenAI Responses API against the Foundry Local OpenAI-compatible web service. +// +// SDK responsibilities (Foundry Local): +// - SDK initialization +// - EP download/registration +// - model lookup, download, load +// - starting/stopping the local web service +// +// Responses API calls go through the official OpenAI .NET package's `ResponsesClient` +// pointed at the local web service, mirroring how `samples/cs/foundry-local-web-server` +// uses `OpenAIClient.GetChatClient(...)` for chat completions. + +using System.ClientModel; + +using Microsoft.AI.Foundry.Local; + +using OpenAI; +using OpenAI.Responses; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, + Web = new Configuration.WebService + { + Urls = "http://127.0.0.1:52495" + } +}; + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + +// Get a model using an alias +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); + +// Start the web service +Console.Write($"Starting web service on {config.Web.Urls}..."); +await mgr.StartWebServiceAsync(); +Console.WriteLine("done."); + +try +{ + // <<<<<< OPEN AI RESPONSES SDK USAGE >>>>>> + // Use the OpenAI Responses client to call the local Foundry web service. + ApiKeyCredential key = new("notneeded"); + OpenAIClient openai = new(key, new OpenAIClientOptions + { + Endpoint = new Uri(config.Web.Urls + "/v1"), + }); + ResponsesClient responses = openai.GetResponsesClient(); + + // 1) Non-streaming + Console.WriteLine("\n=== Non-streaming ==="); + ResponseResult simple = await responses.CreateResponseAsync(model.Id, "Reply with one short sentence about local AI."); + Console.WriteLine($"[ASSISTANT]: {simple.GetOutputText()}"); + + // 2) Streaming + Console.WriteLine("\n=== Streaming ==="); + Console.Write("[ASSISTANT]: "); + await foreach (StreamingResponseUpdate update in responses.CreateResponseStreamingAsync(model.Id, "Count from 1 to 3.")) + { + if (update is StreamingResponseOutputTextDeltaUpdate delta && !string.IsNullOrEmpty(delta.Delta)) + { + Console.Write(delta.Delta); + } + } + Console.WriteLine(); + + // 3) Function/tool calling — full round-trip via previous_response_id. + // The function takes no arguments, which matches the pattern small models handle reliably. + Console.WriteLine("\n=== Function calling ==="); + var emptyParamsSchema = BinaryData.FromString(""" + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """); + + ResponseTool getWeatherTool = ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: emptyParamsSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather. This sample always returns Seattle weather."); + + var toolCallOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateUserMessageItem("Use the get_weather tool and then answer with the weather.") }) + { + StoredOutputEnabled = true, + ToolChoice = ResponseToolChoice.CreateRequiredChoice(), + MaxOutputTokenCount = 64, + Temperature = 0.0f, + }; + toolCallOptions.Tools.Add(getWeatherTool); + + ResponseResult toolResponse = await responses.CreateResponseAsync(toolCallOptions); + + FunctionCallResponseItem? functionCall = null; + foreach (var item in toolResponse.OutputItems) + { + if (item is FunctionCallResponseItem fc && fc.FunctionName == "get_weather") + { + functionCall = fc; + break; + } + } + + if (functionCall is null) + { + Console.WriteLine("Model did not produce a function call; skipping tool round-trip."); + } + else + { + Console.WriteLine($"[TOOL CALL]: {functionCall.FunctionName}({functionCall.FunctionArguments})"); + + const string toolOutput = """{"location": "Seattle", "weather": "72 degrees F and sunny"}"""; + + var followUpOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + { + PreviousResponseId = toolResponse.Id, + StoredOutputEnabled = true, + MaxOutputTokenCount = 64, + Temperature = 0.0f, + }; + followUpOptions.Tools.Add(getWeatherTool); + + ResponseResult finalResponse = await responses.CreateResponseAsync(followUpOptions); + Console.WriteLine($"[ASSISTANT FINAL]: {finalResponse.GetOutputText()}"); + } + // <<<<<< END OPEN AI RESPONSES SDK USAGE >>>>>> +} +finally +{ + // Tidy up + await mgr.StopWebServiceAsync(); + await model.UnloadAsync(); +} +// diff --git a/samples/cs/responses-foundry-local-web-server/README.md b/samples/cs/responses-foundry-local-web-server/README.md new file mode 100644 index 00000000..1cf285bb --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/README.md @@ -0,0 +1,55 @@ +# Foundry Local Responses web service sample (C#) + +This sample starts the Foundry Local OpenAI-compatible web service, then uses the official OpenAI .NET SDK to call the Responses API. + +The pattern is: + +1. `FoundryLocalManager` handles Foundry Local setup, model download/load, web service startup, and cleanup. +1. `OpenAI.Responses.ResponsesClient` (from the official `OpenAI` NuGet package) handles the actual `/v1/responses` calls. + +## Prerequisites + +- .NET 9 SDK +- Internet access on first run to download the sample model + +## What the sample does + +1. Initializes `FoundryLocalManager`. +1. Downloads and registers execution providers. +1. Downloads and loads `qwen2.5-0.5b`. +1. Starts the local web service at `http://127.0.0.1:52495`. +1. Creates an `OpenAIClient` pointed at `http://127.0.0.1:52495/v1`. +1. Runs a non-streaming Responses call. +1. Runs a streaming Responses call (`StreamingResponseOutputTextDeltaUpdate` events). +1. Runs a Responses function-calling flow with a sample `get_weather` tool, then submits a tool result back via `previous_response_id`. +1. Stops the web service and unloads the model. + +## Run the sample + +```powershell +cd samples/cs/responses-foundry-local-web-server +dotnet run +``` + +## Expected output + +```text +=== Non-streaming === +[ASSISTANT]: 4 + +=== Streaming === +[ASSISTANT]: 1, 2, 3. + +=== Function calling === +Tool call: get_weather() +Tool output: {"location": "Seattle", "weather": "72 degrees F and sunny"} +[ASSISTANT]: It's 72 degrees F and sunny in Seattle. +``` + +The exact model text varies. + +## Troubleshooting + +If the sample fails while creating `FoundryLocalManager` with a native symbol error such as `Failed to resolve 'execute_command_with_binary' symbol`, the installed Foundry Local Core runtime is older than the native bits expect. Try the latest stable `Microsoft.AI.Foundry.Local[.WinML]` package, or a recent ORT-Nightly package if needed. + +If port `52495` is already in use, edit `Program.cs` and change `config.Web.Urls`. diff --git a/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj b/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj new file mode 100644 index 00000000..a37de5c1 --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj @@ -0,0 +1,54 @@ + + + + Exe + enable + enable + + $(NoWarn);OPENAI001 + + + + + net9.0-windows10.0.26100 + false + ARM64;x64 + None + false + + + + + net9.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..2c41305b 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 @@ -8,6 +8,8 @@ true false false + + $(NoWarn);OPENAI001 @@ -47,6 +49,7 @@ + diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs new file mode 100644 index 00000000..3fb150c0 --- /dev/null +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs @@ -0,0 +1,241 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.Tests; + +using System; +using System.ClientModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using OfficialOpenAI = global::OpenAI; +using OfficialResponses = global::OpenAI.Responses; + +/// +/// Integration tests for the OpenAI Responses API served by the Foundry Local web service. +/// +/// The Foundry Local SDK is responsible only for model lifecycle and starting the local +/// web service. The Responses API is exercised through the official OpenAI .NET package +/// pointed at the local /v1 endpoint. +/// +/// Mirrors sdk/js/test/openai/responsesWebService.test.ts from the JS PR: +/// - Skips when running in CI. +/// - Skips when qwen2.5-0.5b is not in the local cache. +/// - Covers non-streaming, streaming, and a full function-calling round-trip. +/// +internal sealed class ResponsesIntegrationTests +{ + private const string ModelAlias = "qwen2.5-0.5b"; + private const string ModelVariant = "qwen2.5-0.5b-instruct-generic-cpu:4"; + + private static IModel? model; + private static OfficialOpenAI.OpenAIClient? openAiClient; + private static OfficialResponses.ResponsesClient? responses; + private static string? skipReason; + + [Before(Class)] + public static async Task Setup() + { + if (Utils.IsRunningInCI()) + { + skipReason = "Responses integration tests require a local model cache; skipped in CI."; + return; + } + + var manager = FoundryLocalManager.Instance; // initialized by Utils + + var catalog = await manager.GetCatalogAsync(); + var modelVariant = await catalog.GetModelVariantAsync(ModelVariant).ConfigureAwait(false); + if (modelVariant is null) + { + skipReason = $"Model variant '{ModelVariant}' is not in the catalog."; + return; + } + + if (!await modelVariant.IsCachedAsync().ConfigureAwait(false)) + { + skipReason = $"Model '{ModelAlias}' is not cached locally; pre-cache via the SDK to enable these tests."; + return; + } + + await modelVariant.LoadAsync().ConfigureAwait(false); + await Assert.That(await modelVariant.IsLoadedAsync()).IsTrue(); + model = modelVariant; + + await manager.StartWebServiceAsync().ConfigureAwait(false); + await Assert.That(manager.Urls).IsNotNull().And.IsNotEmpty(); + + var endpoint = new Uri(manager.Urls![0].TrimEnd('/') + "/v1"); + openAiClient = new OfficialOpenAI.OpenAIClient( + new ApiKeyCredential("notneeded"), + new OfficialOpenAI.OpenAIClientOptions { Endpoint = endpoint }); + responses = openAiClient.GetResponsesClient(); + } + + [After(Class)] + public static async Task TearDown() + { + var manager = FoundryLocalManager.Instance; + try + { + await manager.StopWebServiceAsync().ConfigureAwait(false); + } + catch + { + // best-effort cleanup + } + + if (model is not null) + { + try + { + await model.UnloadAsync().ConfigureAwait(false); + } + catch + { + // best-effort cleanup + } + } + } + + private static (OfficialResponses.ResponsesClient Client, IModel Model) RequireSetup() + { + if (skipReason is not null || responses is null || model is null) + { + Skip.Test(skipReason ?? "Responses integration setup did not complete."); + } + + return (responses!, model!); + } + + [Test] + public async Task NonStreaming_SimplePrompt_ReturnsCompletedResponseWithText() + { + var (client, m) = RequireSetup(); + + var options = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("What is 2 + 2? Answer with just the number.") }) + { + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, + }; + + OfficialResponses.ResponseResult response = await client.CreateResponseAsync(options).ConfigureAwait(false); + + await Assert.That(response).IsNotNull(); + await Assert.That(response.Status).IsEqualTo(OfficialResponses.ResponseStatus.Completed); + + var text = response.GetOutputText(); + Console.WriteLine($"[NonStreaming] {text}"); + await Assert.That(text).IsNotNull().And.IsNotEmpty(); + } + + [Test] + public async Task Streaming_EmitsCreatedDeltaAndCompletedEvents() + { + var (client, m) = RequireSetup(); + + var options = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Count from 1 to 3.") }) + { + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, + StreamingEnabled = true, + }; + + var sawCreated = false; + var sawTextDelta = false; + var sawCompleted = false; + var aggregate = new StringBuilder(); + + await foreach (OfficialResponses.StreamingResponseUpdate update in client.CreateResponseStreamingAsync(options).ConfigureAwait(false)) + { + switch (update) + { + case OfficialResponses.StreamingResponseCreatedUpdate: + sawCreated = true; + break; + case OfficialResponses.StreamingResponseOutputTextDeltaUpdate delta when !string.IsNullOrEmpty(delta.Delta): + sawTextDelta = true; + aggregate.Append(delta.Delta); + break; + case OfficialResponses.StreamingResponseCompletedUpdate: + sawCompleted = true; + break; + } + } + + Console.WriteLine($"[Streaming] aggregated: {aggregate}"); + await Assert.That(sawCreated).IsTrue(); + await Assert.That(sawTextDelta).IsTrue(); + await Assert.That(sawCompleted).IsTrue(); + } + + [Test] + public async Task FunctionCalling_FullRoundTrip_ProducesAssistantText() + { + var (client, m) = RequireSetup(); + + var emptyParamsSchema = BinaryData.FromString(""" + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """); + + OfficialResponses.ResponseTool getWeatherTool = OfficialResponses.ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: emptyParamsSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather. This test always returns Seattle weather."); + + var initialOptions = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Use the get_weather tool and then answer with the weather.") }) + { + ToolChoice = OfficialResponses.ResponseToolChoice.CreateRequiredChoice(), + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = true, + }; + initialOptions.Tools.Add(getWeatherTool); + + OfficialResponses.ResponseResult toolResponse = await client.CreateResponseAsync(initialOptions).ConfigureAwait(false); + + var functionCall = toolResponse.OutputItems + .OfType() + .FirstOrDefault(item => item.FunctionName == "get_weather"); + await Assert.That(functionCall).IsNotNull(); + await Assert.That(functionCall!.CallId).IsNotNull().And.IsNotEmpty(); + + const string toolOutput = """{"location": "Seattle", "weather": "72 degrees F and sunny"}"""; + + var followUpOptions = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + { + PreviousResponseId = toolResponse.Id, + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, + }; + followUpOptions.Tools.Add(getWeatherTool); + + OfficialResponses.ResponseResult finalResponse = await client.CreateResponseAsync(followUpOptions).ConfigureAwait(false); + + await Assert.That(finalResponse.Status).IsEqualTo(OfficialResponses.ResponseStatus.Completed); + + var finalText = finalResponse.GetOutputText(); + Console.WriteLine($"[FunctionCalling] tool_call_id={functionCall.CallId} final={finalText}"); + await Assert.That(finalText).IsNotNull().And.IsNotEmpty(); + } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index a289011b..539cbf44 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -435,7 +435,9 @@ private static List BuildTestCatalog(bool includeCuda = true) private static string GetSourceFilePath([CallerFilePath] string path = "") => path; - // Gets the root directory of the foundry-local-sdk repository by finding the .git directory. + // Gets the root directory of the foundry-local-sdk repository by finding the .git entry. + // In a regular clone the .git entry is a directory; in a worktree it is a file containing + // a `gitdir:` pointer. Accept either so tests can run from worktrees. private static string GetRepoRoot() { var sourceFile = GetSourceFilePath(); @@ -443,7 +445,8 @@ private static string GetRepoRoot() while (dir != null) { - if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) + var gitPath = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) return dir.FullName; dir = dir.Parent;