Skip to content

Commit 5e6d6bb

Browse files
.Net: Migrate Python code interpreter C# plugin to the lates Azure code interpreter API version (#11856)
### Motivation, Context and Description This PR migrates the `SessionsPythonPlugin` plugin to the latest Azure code interpreter API version - `2024-10-02-preview`. Taking into account that the new API has breaking changes, these have influenced the public API surface of the plugin respectively. Additionally, this PR takes the first step to tighten the public API surface of the plugin and its model classes by sealing the `SessionsPythonPlugin` and `SessionsRemoteFileMetadata` classes. More changes in this direction will follow in the next PRs.
1 parent 38ac7e8 commit 5e6d6bb

8 files changed

+186
-141
lines changed

dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
using Azure.Identity;
1111
using Azure.Core;
1212
using System.Collections.Generic;
13+
using Microsoft.SemanticKernel;
14+
using Microsoft.Extensions.DependencyInjection;
15+
using Microsoft.SemanticKernel.ChatCompletion;
16+
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
1317

1418
namespace SemanticKernel.IntegrationTests.Plugins.Core;
1519

@@ -20,21 +24,22 @@ public sealed class SessionsPythonPluginTests : IDisposable
2024
private readonly SessionsPythonSettings _settings;
2125
private readonly HttpClientFactory _httpClientFactory;
2226
private readonly SessionsPythonPlugin _sut;
27+
private readonly IConfigurationRoot _configurationRoot;
2328

2429
public SessionsPythonPluginTests()
2530
{
26-
var configurationRoot = new ConfigurationBuilder()
31+
this._configurationRoot = new ConfigurationBuilder()
2732
.AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true)
2833
.AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true)
2934
.AddEnvironmentVariables()
3035
.AddUserSecrets<SessionsPythonPluginTests>()
3136
.Build();
3237

33-
var _configuration = configurationRoot
38+
var _spConfiguration = this._configurationRoot
3439
.GetSection("AzureContainerAppSessionPool")
3540
.Get<AzureContainerAppSessionPoolConfiguration>()!;
3641

37-
this._settings = new(sessionId: Guid.NewGuid().ToString(), endpoint: new Uri(_configuration.Endpoint))
42+
this._settings = new(sessionId: Guid.NewGuid().ToString(), endpoint: new Uri(_spConfiguration.Endpoint))
3843
{
3944
CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous,
4045
CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline
@@ -52,10 +57,10 @@ public async Task ItShouldUploadFileAsync()
5257
var result = await this._sut.UploadFileAsync("test_file.txt", @"TestData\SessionsPythonPlugin\file_to_upload_1.txt");
5358

5459
// Assert
55-
Assert.Equal("test_file.txt", result.Filename);
56-
Assert.Equal(322, result.Size);
57-
Assert.NotNull(result.LastModifiedTime);
58-
Assert.Equal("/mnt/data/test_file.txt", result.FullPath);
60+
Assert.Equal("test_file.txt", result.Name);
61+
Assert.Equal(322, result.SizeInBytes);
62+
Assert.Equal("file", result.Type);
63+
Assert.Equal("text/plain; charset=utf-8", result.ContentType);
5964
}
6065

6166
[Fact(Skip = SkipReason)]
@@ -85,16 +90,16 @@ public async Task ItShouldListFilesAsync()
8590
Assert.Equal(2, files.Count);
8691

8792
var firstFile = files[0];
88-
Assert.Equal("test_file_1.txt", firstFile.Filename);
89-
Assert.Equal(322, firstFile.Size);
90-
Assert.NotNull(firstFile.LastModifiedTime);
91-
Assert.Equal("/mnt/data/test_file_1.txt", firstFile.FullPath);
93+
Assert.Equal("test_file_1.txt", firstFile.Name);
94+
Assert.Equal(322, firstFile.SizeInBytes);
95+
Assert.Equal("file", firstFile.Type);
96+
Assert.Equal("text/plain; charset=utf-8", firstFile.ContentType);
9297

9398
var secondFile = files[1];
94-
Assert.Equal("test_file_2.txt", secondFile.Filename);
95-
Assert.Equal(336, secondFile.Size);
96-
Assert.NotNull(secondFile.LastModifiedTime);
97-
Assert.Equal("/mnt/data/test_file_2.txt", secondFile.FullPath);
99+
Assert.Equal("test_file_2.txt", secondFile.Name);
100+
Assert.Equal(336, secondFile.SizeInBytes);
101+
Assert.Equal("file", secondFile.Type);
102+
Assert.Equal("text/plain; charset=utf-8", secondFile.ContentType);
98103
}
99104

100105
[Fact(Skip = SkipReason)]
@@ -108,7 +113,31 @@ public async Task ItShouldExecutePythonCodeAsync()
108113

109114
// Assert
110115
Assert.Contains("8", result);
111-
Assert.Contains("Success", result);
116+
Assert.Contains("Succeeded", result);
117+
}
118+
119+
[Fact(Skip = SkipReason)]
120+
public async Task LlmShouldUploadFileAndAccessItFromCodeInterpreterAsync()
121+
{
122+
// Arrange
123+
Kernel kernel = this.InitializeKernel();
124+
kernel.Plugins.AddFromObject(this._sut);
125+
126+
var chatCompletionService = kernel.Services.GetRequiredService<IChatCompletionService>();
127+
128+
AzureOpenAIPromptExecutionSettings settings = new()
129+
{
130+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
131+
};
132+
133+
ChatHistory chatHistory = [];
134+
chatHistory.AddUserMessage(@"Upload the local file TestData\SessionsPythonPlugin\file_to_upload_1.txt and use python code to count number of words in it.");
135+
136+
// Act
137+
var result = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings, kernel);
138+
139+
// Assert
140+
Assert.Contains("52", result.ToString());
112141
}
113142

114143
/// <summary>
@@ -125,6 +154,24 @@ private static async Task<string> GetAuthTokenAsync()
125154
return token.Token;
126155
}
127156

157+
private Kernel InitializeKernel()
158+
{
159+
var azureOpenAIConfiguration = this._configurationRoot.GetSection("AzureOpenAI").Get<AzureOpenAIConfiguration>();
160+
Assert.NotNull(azureOpenAIConfiguration);
161+
Assert.NotNull(azureOpenAIConfiguration.ChatDeploymentName);
162+
Assert.NotNull(azureOpenAIConfiguration.Endpoint);
163+
164+
var kernelBuilder = Kernel.CreateBuilder();
165+
166+
kernelBuilder.AddAzureOpenAIChatCompletion(
167+
deploymentName: azureOpenAIConfiguration.ChatDeploymentName,
168+
modelId: azureOpenAIConfiguration.ChatModelId,
169+
endpoint: azureOpenAIConfiguration.Endpoint,
170+
credentials: new AzureCliCredential());
171+
172+
return kernelBuilder.Build();
173+
}
174+
128175
public void Dispose()
129176
{
130177
this._httpClientFactory.Dispose();

dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonCodeExecutionProperties.cs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,6 @@ namespace Microsoft.SemanticKernel.Plugins.Core.CodeInterpreter;
77

88
internal sealed class SessionsPythonCodeExecutionProperties
99
{
10-
/// <summary>
11-
/// The session identifier.
12-
/// </summary>
13-
[JsonPropertyName("identifier")]
14-
public string Identifier { get; }
15-
1610
/// <summary>
1711
/// Code input type.
1812
/// </summary>
@@ -34,12 +28,11 @@ internal sealed class SessionsPythonCodeExecutionProperties
3428
/// <summary>
3529
/// The Python code to execute.
3630
/// </summary>
37-
[JsonPropertyName("pythonCode")]
38-
public string? PythonCode { get; }
31+
[JsonPropertyName("code")]
32+
public string PythonCode { get; }
3933

4034
public SessionsPythonCodeExecutionProperties(SessionsPythonSettings settings, string pythonCode)
4135
{
42-
this.Identifier = settings.SessionId;
4336
this.PythonCode = pythonCode;
4437
this.TimeoutInSeconds = settings.TimeoutInSeconds;
4538
this.CodeInputType = settings.CodeInputType;

dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs

Lines changed: 35 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ namespace Microsoft.SemanticKernel.Plugins.Core.CodeInterpreter;
1818
/// <summary>
1919
/// A plugin for running Python code in an Azure Container Apps dynamic sessions code interpreter.
2020
/// </summary>
21-
public partial class SessionsPythonPlugin
21+
public sealed partial class SessionsPythonPlugin
2222
{
2323
private static readonly string s_assemblyVersion = typeof(Kernel).Assembly.GetName().Version!.ToString();
24-
private const string ApiVersion = "2024-02-02-preview";
24+
private const string ApiVersion = "2024-10-02-preview";
2525
private readonly Uri _poolManagementEndpoint;
2626
private readonly SessionsPythonSettings _settings;
2727
private readonly Func<Task<string>>? _authTokenProvider;
@@ -90,14 +90,11 @@ public async Task<string> ExecuteCodeAsync([Description("The valid Python code t
9090

9191
using var httpClient = this._httpClientFactory.CreateClient();
9292

93-
var requestBody = new
94-
{
95-
properties = new SessionsPythonCodeExecutionProperties(this._settings, code)
96-
};
93+
var requestBody = new SessionsPythonCodeExecutionProperties(this._settings, code);
9794

9895
await this.AddHeadersAsync(httpClient).ConfigureAwait(false);
9996

100-
using var request = new HttpRequestMessage(HttpMethod.Post, this._poolManagementEndpoint + $"python/execute?api-version={ApiVersion}")
97+
using var request = new HttpRequestMessage(HttpMethod.Post, $"{this._poolManagementEndpoint}/executions?identifier={this._settings.SessionId}&api-version={ApiVersion}")
10198
{
10299
Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json")
103100
};
@@ -110,17 +107,19 @@ public async Task<string> ExecuteCodeAsync([Description("The valid Python code t
110107
throw new HttpRequestException($"Failed to execute python code. Status: {response.StatusCode}. Details: {errorBody}.");
111108
}
112109

113-
var jsonElementResult = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync().ConfigureAwait(false));
110+
var responseContent = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync().ConfigureAwait(false));
111+
112+
var result = responseContent.GetProperty("result");
114113

115114
return $"""
116115
Status:
117-
{jsonElementResult.GetProperty("status").GetRawText()}
116+
{responseContent.GetProperty("status").GetRawText()}
118117
Result:
119-
{jsonElementResult.GetProperty("result").GetRawText()}
118+
{result.GetProperty("executionResult").GetRawText()}
120119
Stdout:
121-
{jsonElementResult.GetProperty("stdout").GetRawText()}
120+
{result.GetProperty("stdout").GetRawText()}
122121
Stderr:
123-
{jsonElementResult.GetProperty("stderr").GetRawText()}
122+
{result.GetProperty("stderr").GetRawText()}
124123
""";
125124
}
126125

@@ -135,33 +134,33 @@ private async Task AddHeadersAsync(HttpClient httpClient)
135134
}
136135

137136
/// <summary>
138-
/// Upload a file to the session pool.
137+
/// Uploads a file to the `/mnt/data` directory of the current session.
139138
/// </summary>
140-
/// <param name="remoteFilePath">The path to the file in the session.</param>
139+
/// <param name="remoteFileName">The name of the remote file, relative to `/mnt/data`.</param>
141140
/// <param name="localFilePath">The path to the file on the local machine.</param>
142141
/// <returns>The metadata of the uploaded file.</returns>
143142
/// <exception cref="ArgumentNullException"></exception>
144143
/// <exception cref="HttpRequestException"></exception>
145-
[KernelFunction, Description("Uploads a file for the current session id pool.")]
144+
[KernelFunction, Description("Uploads a file to the `/mnt/data` directory of the current session.")]
146145
public async Task<SessionsRemoteFileMetadata> UploadFileAsync(
147-
[Description("The path to the file in the session.")] string remoteFilePath,
148-
[Description("The path to the file on the local machine.")] string? localFilePath)
146+
[Description("The name of the remote file, relative to `/mnt/data`.")] string remoteFileName,
147+
[Description("The path to the file on the local machine.")] string localFilePath)
149148
{
150-
Verify.NotNullOrWhiteSpace(remoteFilePath, nameof(remoteFilePath));
149+
Verify.NotNullOrWhiteSpace(remoteFileName, nameof(remoteFileName));
151150
Verify.NotNullOrWhiteSpace(localFilePath, nameof(localFilePath));
152151

153-
this._logger.LogInformation("Uploading file: {LocalFilePath} to {RemoteFilePath}", localFilePath, remoteFilePath);
152+
this._logger.LogInformation("Uploading file: {LocalFilePath} to {RemoteFilePath}", localFilePath, remoteFileName);
154153

155154
using var httpClient = this._httpClientFactory.CreateClient();
156155

157156
await this.AddHeadersAsync(httpClient).ConfigureAwait(false);
158157

159158
using var fileContent = new ByteArrayContent(File.ReadAllBytes(localFilePath));
160-
using var request = new HttpRequestMessage(HttpMethod.Post, $"{this._poolManagementEndpoint}files/upload?identifier={this._settings.SessionId}&api-version={ApiVersion}")
159+
using var request = new HttpRequestMessage(HttpMethod.Post, $"{this._poolManagementEndpoint}files?identifier={this._settings.SessionId}&api-version={ApiVersion}")
161160
{
162161
Content = new MultipartFormDataContent
163162
{
164-
{ fileContent, "file", remoteFilePath },
163+
{ fileContent, "file", remoteFileName },
165164
}
166165
};
167166

@@ -173,30 +172,30 @@ public async Task<SessionsRemoteFileMetadata> UploadFileAsync(
173172
throw new HttpRequestException($"Failed to upload file. Status code: {response.StatusCode}. Details: {errorBody}.");
174173
}
175174

176-
var JsonElementResult = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync().ConfigureAwait(false));
175+
var stringContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
177176

178-
return JsonSerializer.Deserialize<SessionsRemoteFileMetadata>(JsonElementResult.GetProperty("value")[0].GetProperty("properties").GetRawText())!;
177+
return JsonSerializer.Deserialize<SessionsRemoteFileMetadata>(stringContent)!;
179178
}
180179

181180
/// <summary>
182-
/// Downloads a file from the current Session ID.
181+
/// Downloads a file from the `/mnt/data` directory of the current session.
183182
/// </summary>
184-
/// <param name="remoteFilePath"> The path to download the file from, relative to `/mnt/data`. </param>
185-
/// <param name="localFilePath"> The path to save the downloaded file to. If not provided won't save it in the disk.</param>
186-
/// <returns> The data of the downloaded file as byte array. </returns>
187-
[KernelFunction, Description("Downloads a file from the current Session ID.")]
183+
/// <param name="remoteFileName">The name of the remote file to download, relative to `/mnt/data`.</param>
184+
/// <param name="localFilePath">The path to save the downloaded file to. If not provided won't save it in the disk.</param>
185+
/// <returns>The data of the downloaded file as byte array.</returns>
186+
[KernelFunction, Description("Downloads a file from the `/mnt/data` directory of the current session.")]
188187
public async Task<byte[]> DownloadFileAsync(
189-
[Description("The path to download the file from, relative to `/mnt/data`.")] string remoteFilePath,
188+
[Description("The name of the remote file to download, relative to `/mnt/data`.")] string remoteFileName,
190189
[Description("The path to save the downloaded file to. If not provided won't save it in the disk.")] string? localFilePath = null)
191190
{
192-
Verify.NotNullOrWhiteSpace(remoteFilePath, nameof(remoteFilePath));
191+
Verify.NotNullOrWhiteSpace(remoteFileName, nameof(remoteFileName));
193192

194-
this._logger.LogTrace("Downloading file: {RemoteFilePath} to {LocalFilePath}", remoteFilePath, localFilePath);
193+
this._logger.LogTrace("Downloading file: {RemoteFilePath} to {LocalFilePath}", remoteFileName, localFilePath);
195194

196195
using var httpClient = this._httpClientFactory.CreateClient();
197196
await this.AddHeadersAsync(httpClient).ConfigureAwait(false);
198197

199-
var response = await httpClient.GetAsync(new Uri($"{this._poolManagementEndpoint}python/downloadFile?identifier={this._settings.SessionId}&filename={remoteFilePath}&api-version={ApiVersion}")).ConfigureAwait(false);
198+
var response = await httpClient.GetAsync(new Uri($"{this._poolManagementEndpoint}/files/{Uri.EscapeDataString(remoteFileName)}/content?identifier={this._settings.SessionId}&api-version={ApiVersion}")).ConfigureAwait(false);
200199
if (!response.IsSuccessStatusCode)
201200
{
202201
var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -221,10 +220,10 @@ public async Task<byte[]> DownloadFileAsync(
221220
}
222221

223222
/// <summary>
224-
/// Lists all files in the provided session id pool.
223+
/// Lists all entities: files or directories in the `/mnt/data` directory of the current session.
225224
/// </summary>
226-
/// <returns> The list of files in the session. </returns>
227-
[KernelFunction, Description("Lists all files in the provided session id pool.")]
225+
/// <returns>The list of files in the session.</returns>
226+
[KernelFunction, Description("Lists all entities: files or directories in the `/mnt/data` directory of the current session.")]
228227
public async Task<IReadOnlyList<SessionsRemoteFileMetadata>> ListFilesAsync()
229228
{
230229
this._logger.LogTrace("Listing files for Session ID: {SessionId}", this._settings.SessionId);
@@ -243,14 +242,7 @@ public async Task<IReadOnlyList<SessionsRemoteFileMetadata>> ListFilesAsync()
243242

244243
var files = jsonElementResult.GetProperty("value");
245244

246-
var result = new SessionsRemoteFileMetadata[files.GetArrayLength()];
247-
248-
for (var i = 0; i < result.Length; i++)
249-
{
250-
result[i] = JsonSerializer.Deserialize<SessionsRemoteFileMetadata>(files[i].GetProperty("properties").GetRawText())!;
251-
}
252-
253-
return result;
245+
return files.Deserialize<SessionsRemoteFileMetadata[]>()!;
254246
}
255247

256248
private static Uri GetBaseEndpoint(Uri endpoint)

0 commit comments

Comments
 (0)