diff --git a/ai-cli.sln b/ai-cli.sln index 69cd7b70..9e02bcfb 100644 --- a/ai-cli.sln +++ b/ai-cli.sln @@ -34,6 +34,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution NuGet.config = NuGet.config EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClientsCLI", "src\clients.cli\ClientsCLI.csproj", "{A136A55F-C27B-4FC9-82ED-84A3790BFC3C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{BA7F8DB9-2789-4410-99B5-AC38F85697FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -176,6 +180,30 @@ Global {306A3CD6-91C2-450B-9995-79701CE63FE2}.Release|x64.Build.0 = Release|Any CPU {306A3CD6-91C2-450B-9995-79701CE63FE2}.Release|x86.ActiveCfg = Release|Any CPU {306A3CD6-91C2-450B-9995-79701CE63FE2}.Release|x86.Build.0 = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|x64.Build.0 = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Debug|x86.Build.0 = Debug|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|Any CPU.Build.0 = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|x64.ActiveCfg = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|x64.Build.0 = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|x86.ActiveCfg = Release|Any CPU + {A136A55F-C27B-4FC9-82ED-84A3790BFC3C}.Release|x86.Build.0 = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|x64.Build.0 = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Debug|x86.Build.0 = Debug|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|Any CPU.Build.0 = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|x64.ActiveCfg = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|x64.Build.0 = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|x86.ActiveCfg = Release|Any CPU + {BA7F8DB9-2789-4410-99B5-AC38F85697FE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -189,6 +217,7 @@ Global {39876475-2D98-40CF-8B08-CD423A5EB4E8} = {C8AFF891-D6AA-4B8F-BC21-10404DF4B355} {9499C018-FA08-4133-93B3-FC0F3863A6CC} = {C8AFF891-D6AA-4B8F-BC21-10404DF4B355} {CED7C805-0435-4BF7-A42F-9F3BBF14A18F} = {644B75F1-C768-4DB3-BAF2-C69A1F36DD28} + {BA7F8DB9-2789-4410-99B5-AC38F85697FE} = {C8AFF891-D6AA-4B8F-BC21-10404DF4B355} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {002655B1-E1E1-4F2A-8D53-C9CD55136AE2} diff --git a/src/ai/Program_AI.cs b/src/ai/Program_AI.cs index b83c2324..05f06e2d 100644 --- a/src/ai/Program_AI.cs +++ b/src/ai/Program_AI.cs @@ -7,10 +7,11 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using Azure.AI.CLI.Clients.AzPython; +using Azure.AI.CLI.Common.Clients; using Azure.AI.Details.Common.CLI.Telemetry; using Azure.AI.Details.Common.CLI.Telemetry.Events; - namespace Azure.AI.Details.Common.CLI { public class AiProgram @@ -78,6 +79,17 @@ public AiProgramData() _telemetry = new Lazy( () => TelemetryHelpers.InstantiateFromConfig(this), System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); + + var environmentVariables = LegacyAzCli.GetUserAgentEnv(); + + LoginManager = new AzConsoleLoginManager( + () => Values?.GetOrDefault("init.service.interactive", true) ?? true, + environmentVariables); + + var azCliClient = new AzCliClient(environmentVariables); + SubscriptionsClient = azCliClient; + CognitiveServicesClient = azCliClient; + SearchClient = azCliClient; } #region name data @@ -182,7 +194,7 @@ public bool DispatchParseCommand(INamedValueTokens tokens, ICommandValues values var command = values.GetCommand(null) ?? tokens.PeekNextToken(); var root = command.Split('.').FirstOrDefault(); - return root switch + return root switch { "help" => HelpCommandParser.ParseCommand(tokens, values), "init" => InitCommandParser.ParseCommand(tokens, values), @@ -262,5 +274,11 @@ public bool DisplayKnownErrors(ICommandValues values, Exception ex) public IEventLoggerHelpers EventLoggerHelpers => new AiEventLoggerHelpers(); public ITelemetry Telemetry => _telemetry.Value; + + public ILoginManager LoginManager { get; } + public ISubscriptionsClient SubscriptionsClient { get; } + public ICognitiveServicesClient CognitiveServicesClient { get; } + public ISearchClient SearchClient { get; } + public ICommandValues Values { get; } = new CommandValues(); } } diff --git a/src/ai/ai-cli.csproj b/src/ai/ai-cli.csproj index c97f9384..30efda9d 100644 --- a/src/ai/ai-cli.csproj +++ b/src/ai/ai-cli.csproj @@ -133,6 +133,7 @@ + diff --git a/src/ai/commands/init_command.cs b/src/ai/commands/init_command.cs index d7e68618..42436bfe 100644 --- a/src/ai/commands/init_command.cs +++ b/src/ai/commands/init_command.cs @@ -139,7 +139,7 @@ private async Task DoInitRootVerifyConfigFileAsync(bool interactive, string file if (openai != null && search != null) { bool? useSaved = await DoInitRootConfirmVerifiedProjectResources( - interactive, subscription, projectName, hubName, openai.Value, search.Value); + interactive, subscription, projectName, hubName, openai, search); detail = useSaved.HasValue ? useSaved == true ? "saved_config" : "something_else" @@ -186,18 +186,19 @@ private async Task DoInitRootVerifyConfigFileAsync(bool interactive, string file ConsoleHelpers.WriteLineWithHighlight("\n `ATTACHED SERVICES AND RESOURCES`\n"); - var message = " Validating..."; - Console.Write(message); + string indent = " "; + using var cw = new ConsoleTempWriter($"{indent}Validating..."); var (hubName, openai, search) = await AiSdkConsoleGui.VerifyResourceConnections(_values, validated?.Id, groupName, projectName); if (openai != null && search != null) { - Console.Write($"\r{new string(' ', message.Length)}\r"); return (hubName, openai, search); } else { - ConsoleHelpers.WriteLineWithHighlight($"\r{message} `#e_;WARNING: Configuration could not be validated!`"); + cw.Clear(); + Console.Write(indent); + ConsoleHelpers.WriteLineWithHighlight($"`#e_;WARNING: Configuration could not be validated!`"); Console.WriteLine(); return (null, null, null); } @@ -292,20 +293,20 @@ private async ValueTask DoInitRootMenuPick() int selected = -1; var outcome = Program.Telemetry.Wrap(() => - { - selected = ListBoxPicker.PickIndexOf(choices.Select(e => e.DisplayName).ToArray()); - if (selected < 0) { - Console.WriteLine($"\r{label}: CANCELED (no selection)"); - return Outcome.Canceled; - } + selected = ListBoxPicker.PickIndexOf(choices.Select(e => e.DisplayName).ToArray()); + if (selected < 0) + { + Console.WriteLine($"\r{label}: CANCELED (no selection)"); + return Outcome.Canceled; + } - Console.Write($"\r{label.Trim()}: {choices.ElementAtOrDefault(selected)?.DisplayName}\n"); - _values.Reset("telemetry.init.run_type", choices.ElementAtOrDefault(selected)?.Metadata); + Console.Write($"\r{label.Trim()}: {choices.ElementAtOrDefault(selected)?.DisplayName}\n"); + _values.Reset("telemetry.init.run_type", choices.ElementAtOrDefault(selected)?.Metadata); - return Outcome.Success; - }, - (outcome, ex, timeTaken) => choices.ElementAtOrDefault(selected)?.Metadata == "standalone" + return Outcome.Success; + }, + (outcome, ex, timeTaken) => choices.ElementAtOrDefault(selected)?.Metadata == "standalone" ? null : new InitTelemetryEvent(InitStage.Choice) { @@ -337,11 +338,11 @@ private async Task DoInitStandaloneResources(bool interactive) var choices = new[] { - new { DisplayName = "Azure AI Services (v2)", Value = "init-root-cognitiveservices-ai-services-kind-create-or-select", Metadata = "aiservices" }, + new { DisplayName = "Azure AI Services (v2)", Value = "init-root-cognitiveservices-ai-services-kind-create-or-select", Metadata = "aiservices" }, new { DisplayName = "Azure AI Services (v1)", Value = "init-root-cognitiveservices-cognitiveservices-kind-create-or-select", Metadata ="cognitiveservices" }, new { DisplayName = "Azure OpenAI", Value = "init-root-openai-create-or-select", Metadata = "openai" }, new { DisplayName = "Azure Search", Value = "init-root-search-create-or-select", Metadata = "search" }, - new { DisplayName = "Azure Speech", Value = "init-root-speech-create-or-select", Metadata = "speech" } + new { DisplayName = "Azure Speech", Value = "init-root-speech-create-or-select", Metadata = "speech" } }; int picked = -1; @@ -535,7 +536,7 @@ private async Task DoInitOpenAi(bool interactive, bool skipChat = false, bool al var regionFilter = _values.GetOrEmpty("init.service.resource.region.name"); var groupFilter = _values.GetOrEmpty("init.service.resource.group.name"); var resourceFilter = _values.GetOrEmpty("init.service.cognitiveservices.resource.name"); - var kind = _values.GetOrDefault("init.service.cognitiveservices.resource.kind", "OpenAI;AIServices"); + var kind = _values.GetOrDefault("init.service.cognitiveservices.resource.kind", "AIServices;OpenAI"); var sku = _values.GetOrDefault("init.service.cognitiveservices.resource.sku", Program.CognitiveServiceResourceSku); var yes = _values.GetOrDefault("init.service.cognitiveservices.terms.agree", false); diff --git a/src/clients.cli/AzCliClient.cs b/src/clients.cli/AzCliClient.cs new file mode 100644 index 00000000..ec4202b8 --- /dev/null +++ b/src/clients.cli/AzCliClient.cs @@ -0,0 +1,743 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.CLI.Common.Clients; +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.CLI.Common.Clients.Models.Utils; +using Azure.AI.Details.Common.CLI; +using Azure.AI.Details.Common.CLI.AzCli; + + +namespace Azure.AI.CLI.Clients.AzPython +{ + /// + /// A client for Azure subscriptions that uses the AZ CLI + /// + public class AzCliClient : ISubscriptionsClient, ICognitiveServicesClient, ISearchClient + { + private static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions() + { + NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.AllowNamedFloatingPointLiterals, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + // for support JPath expressions for property deserialization + new JsonPathConverterFactory(), + // for deserializing strings into an Enum + new JsonStringEnumConverter(), + // for deserializing strings into a bool or bool? + new StringToBoolJsonConverter(), + new StringToNullableBoolJsonConverter(), + } + }; + + private IDictionary _cliEnv; + + /// + /// Creates a new instance + /// + /// The user agent string to add to all HTTP/HTTPS requests + public AzCliClient(IDictionary? cliEnvironmentVariables = null) + { + _cliEnv = cliEnvironmentVariables ?? new Dictionary(); + } + + /// + public Task> GetAllSubscriptionsAsync(CancellationToken token) + { + return ParseArrayAsync( + () => ProcessHelpers.RunShellCommandAsync( + "az", + "account list" + + " --refresh" + + " --output json" + + " --query \"[?state=='Enabled']\"", + _cliEnv), + token); + } + + /// + public async Task> GetSubscriptionAsync(string subscriptionId, CancellationToken token) + { + try + { + ValidateString(subscriptionId, nameof(subscriptionId)); + + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"account show" + + $" --subscription {subscriptionId}" + + $" --output json", + _cliEnv); + if (cmdOut.HasError) + { + // special case: check if the error indicates the subscription does not exist + string err = cmdOut.StdError ?? string.Empty; + if (err.Contains(subscriptionId) && err.Contains(" not found.")) + { + return ClientResult.From(null); + } + + return ClientResult.From(cmdOut, null); + } + else + { + return ClientResult.From(cmdOut, DeserializeJson(cmdOut.StdOutput)); + } + } + catch (Exception ex) + { + return ClientResult.FromException(ex); + } + } + + /// + public async Task> GetAllRegionsAsync(string subscriptionId, CancellationToken token) + { + try + { + ValidateString(subscriptionId, nameof(subscriptionId)); + + // 2 step process, first see the regions where we can have OpenAI resources, and query for all possible regions + // and filter that list. The second call is needed to get more details about the regions + + var csRegionsOutput = await ParseArrayAsync( + () => ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account list-skus" + + $" --output json" + + $" --kind {Program.CognitiveServiceResourceKind ?? "CognitiveServices"}" + + $" --query \"[].locations[0]\"", + _cliEnv), + token); + + if (!csRegionsOutput.IsSuccess) + { + return new ClientResult() + { + ErrorDetails = csRegionsOutput.ErrorDetails, + Exception = csRegionsOutput.Exception, + Outcome = csRegionsOutput.Outcome, + Value = Array.Empty() + }; + } + + // For now just ignore errors + HashSet openAiRegions = new HashSet( + csRegionsOutput.Value ?? Array.Empty(), + StringComparer.OrdinalIgnoreCase); + + var allRegionsOutput = await ParseArrayAsync( + () => ProcessHelpers.RunShellCommandAsync( + "az", + "account list-locations" + + " --output json", + _cliEnv), + token); + + if (!allRegionsOutput.IsSuccess) + { + return allRegionsOutput; + } + + return ClientResult.From(allRegionsOutput.Value + .Where(r => openAiRegions.Contains(r.Name)) + .ToArray()); + } + catch (Exception ex) + { + return ClientResult.FromException(ex, Array.Empty()); + } + } + + /// + public Task> GetAllResourceGroupsAsync(string subscriptionId, CancellationToken token) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"group list" + + $" --output json" + + $" --subscription {subscriptionId}", + _cliEnv); + }, + token); + } + + /// + public Task> CreateResourceGroupAsync( + string subscriptionId, string regionName, string name, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(regionName, nameof(regionName)); + ValidateString(name, nameof(name)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"group create" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -l {regionName}" + + $" -n {name}", + _cliEnv); + }, + token); + } + + /// + public Task DeleteResourceGroupAsync(string subscriptionId, string resourceGroup, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"group delete" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" --name {resourceGroup}" + + $" --yes", + _cliEnv); + }, + token); + } + + /// + public Task> GetAllResourcesAsync( + string subscriptionId, CancellationToken token, ResourceKind? filter = null) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + + // TODO FIXME: The Azure rest endpoint doesn't seem to have an obvious way to filter by a specific kind of + // Cognitive services resource. All this filtering is done locally after we get the resources from the service + + // TODO FIXME: If we ask for a specific resource, I don't think we should be returning some other kind of resource + // but for compatibility with existing code, keeping this as is for now + var condPart = filter switch + { + ResourceKind.OpenAI => "? kind == 'OpenAI' || kind == 'AIServices'", + ResourceKind.Vision => "? kind == 'ComputerVision' || kind == 'CognitiveServices' || kind == 'AIServices'", + ResourceKind.Speech => "? kind == 'SpeechServices' || kind == 'CognitiveServices' || kind == 'AIServices'", + ResourceKind.AIServices => "? kind == 'AIServices'", + ResourceKind.CognitiveServices => "? kind == 'CognitiveServices'", + + null => null, + _ => $"? kind == '{filter}' || kind == 'CognitiveServices' || kind == 'AIServices'", + }; + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account list" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" --query \"[{condPart}]\"", _cliEnv); + }, + token); + } + + /// + public async Task> GetResourceFromNameAsync( + string subscriptionId, string resourceGroup, string name, CancellationToken token) + { + try + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(name, nameof(name)); + + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account show" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {name}", + _cliEnv); + if (cmdOut.HasError) + { + // special case: check if the error indicates the subscription does not exist + string err = cmdOut.StdError ?? string.Empty; + if (err.Contains(name) && err.Contains("ResourceNotFound")) + { + return ClientResult.From(null); + } + + return ClientResult.From(cmdOut, null); + } + else + { + return ClientResult.From(cmdOut, DeserializeJson(cmdOut.StdOutput)); + } + } + catch (Exception ex) + { + return ClientResult.FromException(ex, (CognitiveServicesResourceInfo?)null); + } + } + + /// + public Task> CreateResourceAsync( + ResourceKind kind, string subscriptionId, string resourceGroup, string region, string name, string sku, CancellationToken token) + { + return ParseAsync( + () => + { + if (!IsCognitiveServicesResourceKind(kind)) + { + throw new ArgumentException($"'{kind}' is not a supported Cognitive Services resource kind", nameof(kind)); + } + + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(region, nameof(region)); + ValidateString(name, nameof(name)); + ValidateString(sku, nameof(sku)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account create" + + $" --output json" + + $" --kind {kind.AsJsonString()}" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -l {region}" + + $" -n {name}" + + $" --custom-domain {name}" + + $" --sku {sku}", + _cliEnv); + }, + token); + } + + /// + public Task DeleteResourceAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account delete" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}", + _cliEnv); + }, + token); + } + + /// + public Task> GetAllDeploymentsAsync( + string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account deployment list" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}", + _cliEnv); + }, + token); + } + + /// + public Task> GetAllModelsAsync( + string subscriptionId, string regionName, CancellationToken token) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(regionName, nameof(regionName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices model list" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -l {regionName}", + _cliEnv); + }, + token); + } + + /// + public Task> GetAllModelUsageAsync( + string subscriptionId, string regionName, CancellationToken token) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(regionName, nameof(regionName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices usage list" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -l {regionName}", + _cliEnv); + }, + token); + } + + /// + public Task> CreateDeploymentAsync( + string subscriptionId, string resourceGroup, string resourceName, + string deploymentName, string modelName, string modelVersion, + string modelFormat, string scaleCapacity, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + ValidateString(deploymentName, nameof(deploymentName)); + ValidateString(modelName, nameof(modelName)); + ValidateString(modelVersion, nameof(modelVersion)); + ValidateString(modelFormat, nameof(modelFormat)); + ValidateString(scaleCapacity, nameof(scaleCapacity)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account deployment create" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}" + + $" --deployment-name {deploymentName}" + + $" --model-name {modelName}" + + $" --model-version {modelVersion}" + + $" --model-format {modelFormat}" + + $" --sku-capacity {scaleCapacity}" + + $" --sku-name \"Standard\"", + _cliEnv); + }, + token); + } + + public Task DeleteDeploymentAsync(string subscriptionId, string resourceGroup, string resourceName, string deploymentName, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + ValidateString(deploymentName, nameof(deploymentName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account deployment delete" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}" + + $" --deployment-name {deploymentName}", + _cliEnv); + }, + token); + } + + /// + public Task> GetResourceKeysFromNameAsync( + string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account keys list" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}", + _cliEnv); + }, + json => + { + using var doc = JsonDocument.Parse(json ?? "{}"); + return ( + doc.RootElement.GetPropertyStringOrEmpty("key1"), + doc.RootElement.GetPropertyStringOrEmpty("key2") + ); + }, + () => (string.Empty, string.Empty), + token); + } + + Task> ISearchClient.GetAllAsync( + string subscriptionId, string? regionName, CancellationToken token) + { + return ParseArrayAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"resource list" + + $" --output json" + + $" --subscription {subscriptionId}" + + (!string.IsNullOrWhiteSpace(regionName) ? $" -l {regionName}" : string.Empty) + + $" --resource-type Microsoft.Search/searchServices", + _cliEnv); + }, + token); + } + + async Task> ISearchClient.GetFromNameAsync( + string subscriptionId, string resourceGroup, string name, CancellationToken token) + { + try + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(name, nameof(name)); + + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"search service show" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {name}", + _cliEnv); + if (cmdOut.HasError) + { + // special case: check if the error indicates the subscription does not exist + string err = cmdOut.StdError ?? string.Empty; + if (err.Contains(name) && err.Contains("ResourceNotFound")) + { + return ClientResult.From(null); + } + + return ClientResult.From(cmdOut, null); + } + else + { + return ClientResult.From(cmdOut, DeserializeJson(cmdOut.StdOutput)); + } + } + catch (Exception ex) + { + return ClientResult.FromException(ex); + } + } + + Task> ISearchClient.CreateAsync( + string subscriptionId, string resourceGroup, string regionName, string name, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(regionName, nameof(regionName)); + ValidateString(name, nameof(name)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"search service create" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -l {regionName}" + + $" -n {name}" + + $" --sku \"Standard\"", + _cliEnv); + }, + token); + } + + Task ISearchClient.DeleteAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"search service delete" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}" + + $" --yes", + _cliEnv); + }, + token); + } + + Task> ISearchClient.GetKeysAsync( + string subscriptionId, string resourceGroup, string name, CancellationToken token) + { + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(name, nameof(name)); + + return ProcessHelpers.RunShellCommandAsync( + "az", + $"search admin-key show" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" --service-name {name}", + _cliEnv); + }, + json => + { + using var doc = JsonDocument.Parse(json ?? "{}"); + return ( + doc.GetPropertyStringOrEmpty("primaryKey"), + doc.GetPropertyStringOrEmpty("secondaryKey") + ); + }, + () => (string.Empty, string.Empty), + token); + } + + /// + public void Dispose() + { + // TODO implement? + } + + private static void ValidateString(string str, string name) + { + if (string.IsNullOrWhiteSpace(str)) + { + throw new ArgumentException($"{name} is null, empty, or only whitespace"); + } + } + + private static bool IsCognitiveServicesResourceKind(ResourceKind resourceKind) => + resourceKind switch + { + ResourceKind.AIServices => true, + ResourceKind.CognitiveServices => true, + ResourceKind.OpenAI => true, + ResourceKind.Speech => true, + ResourceKind.Vision => true, + _ => false + }; + + private static TValue? DeserializeJson(string? json) + => DeserializeJson(json, JSON_OPTIONS); + + private static TValue? DeserializeJson(string? json, JsonSerializerOptions options) + { + if (string.IsNullOrWhiteSpace(json)) + { + return default; + } + + return JsonSerializer.Deserialize(json, options); + } + + private Task> ParseArrayAsync( + Func> func, + CancellationToken token, + Func? @default = null) + { + @default ??= Array.Empty; + + return ParseAsync( + func, + DeserializeJson, + @default, + token); + } + + private Task> ParseAsync( + Func> func, + CancellationToken token) + { + return ParseAsync( + func, + DeserializeJson, + () => default!, + token); + } + + private async Task> ParseAsync( + Func> func, + Func conv, + Func @default, + CancellationToken token) + { + try + { + ProcessOutput cmdOut = await func(); + if (cmdOut.HasError) + { + return ClientResult.From(cmdOut, @default()); + } + else + { + return ClientResult.From(cmdOut, conv(cmdOut.StdOutput) ?? @default()); + } + } + catch (Exception ex) + { + return ClientResult.FromException(ex, @default()); + } + } + + private async Task ParseAsync( + Func> func, + CancellationToken token) + { + try + { + ProcessOutput cmdOut = await func(); + return ClientResult.From(cmdOut); + } + catch (Exception ex) + { + return ClientResult.FromException(ex); + } + } + } +} diff --git a/src/clients.cli/AzConsoleLoginManager.cs b/src/clients.cli/AzConsoleLoginManager.cs new file mode 100644 index 00000000..0278280c --- /dev/null +++ b/src/clients.cli/AzConsoleLoginManager.cs @@ -0,0 +1,139 @@ +using Azure.AI.CLI.Common.Clients; +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI; +using Azure.AI.Details.Common.CLI.AzCli; +using Newtonsoft.Json.Linq; + +namespace Azure.AI.CLI.Clients.AzPython +{ + /// + /// Does login via a console using the AZ CLI + /// + public class AzConsoleLoginManager : ILoginManager + { + private readonly Func _getAllowInteractive; + private readonly IDictionary _cliEnv; + private readonly TimeSpan _minValidity; + private AuthenticationToken? _authToken; + + /// + /// Creates a new instance of + /// + /// A function that returns whether interactive login is allowed + /// Environment variables to use when running the AZ CLI + /// The minimum validity an auth token should have before renewing it + public AzConsoleLoginManager(Func getAllowInteractiveLogin, IDictionary? environmentVariables = null, TimeSpan? minValidity = null) + { + _getAllowInteractive = getAllowInteractiveLogin ?? throw new ArgumentNullException(nameof(getAllowInteractiveLogin)); + _cliEnv = environmentVariables ?? new Dictionary(); + _minValidity = minValidity ?? TimeSpan.FromMinutes(5); + } + + /// + public bool CanAttemptLogin + { + get + { + try { return _getAllowInteractive(); } + catch (Exception) { return false; } + } + } + + /// + public async Task LoginAsync(LoginOptions options, CancellationToken token) + { + try + { + var showDeviceCodeMessage = (string message) => + { + if (message.Contains("device") && message.Contains("code")) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(message); + Console.WriteLine(); + Console.ResetColor(); + } + }; + + var stdErrHandler = options.Mode == LoginMode.UseDeviceCode ? showDeviceCodeMessage : null; + var deviceCodePart = options.Mode == LoginMode.UseDeviceCode ? "--use-device-code" : ""; + var queryPart = $"--query \"[?state=='Enabled'].{{Name:name,Id:id,IsDefault:isDefault,UserName:user.name}}\""; + + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"login --output json {queryPart} {deviceCodePart}", + _cliEnv, + stdErrHandler: stdErrHandler); + if (cmdOut.HasError) + { + return ClientResult.From(cmdOut); + } + + return new ClientResult + { + Outcome = ClientOutcome.Success + }; + } + catch (Exception ex) + { + return ClientResult.FromException(ex); + } + } + + /// + public async Task> GetOrRenewAuthToken(CancellationToken token) + { + try + { + if (_authToken?.Expires > (DateTimeOffset.Now + _minValidity)) + { + return new ClientResult() + { + Outcome = ClientOutcome.Success, + Value = _authToken.Value + }; + } + + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + "account get-access-token --output json", + _cliEnv); + if (cmdOut.HasError) + { + return ClientResult.From(cmdOut, default(AuthenticationToken)); + } + + var json = JObject.Parse(cmdOut.StdOutput); + var authToken = new AuthenticationToken() + { + Expires = DateTimeOffset.FromUnixTimeSeconds(json?["expires_on"]?.Value() ?? 0), + Token = json?["accessToken"]?.Value() ?? string.Empty + }; + + if (authToken.Expires <= (DateTimeOffset.Now + _minValidity) + && string.IsNullOrWhiteSpace(authToken.Token)) + { + return new ClientResult() + { + Outcome = ClientOutcome.Failed, + ErrorDetails = "Could not parse auth token and/or renewed auth token was invalid", + Value = default + }; + } + + _authToken = authToken; + return ClientResult.From(authToken); + } + catch (Exception ex) + { + return ClientResult.FromException(ex, default(AuthenticationToken)); + } + } + + public void Dispose() + { + // TODO implement? + } + } +} diff --git a/src/clients.cli/ClientsCLI.csproj b/src/clients.cli/ClientsCLI.csproj new file mode 100644 index 00000000..14535106 --- /dev/null +++ b/src/clients.cli/ClientsCLI.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + Azure.AI.CLI.Clients.AzPython + Azure.AI.CLI.Clients.AzPython + + + + + + + diff --git a/src/common/Clients/ICognitiveServicesClient.cs b/src/common/Clients/ICognitiveServicesClient.cs new file mode 100644 index 00000000..1e551065 --- /dev/null +++ b/src/common/Clients/ICognitiveServicesClient.cs @@ -0,0 +1,134 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients +{ + /// + /// Client for working with Cognitive Services resources (e.g. AI services resource, speech resource, chat/embedding/evaluation AI model deployments, etc..) + /// + public interface ICognitiveServicesClient : IDisposable + { + /// + /// Retrieves all Cognitive Services resources + /// + /// The ID of the subscription + /// Cancellation token to use + /// (Optional) The kind of resource to filter to + /// Asynchronous task that gets all resources + Task> GetAllResourcesAsync(string subscriptionId, CancellationToken token, ResourceKind? filter = null); + + /// + /// Retrieves a specific Cognitive Services resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the resource to retrieve + /// Cancellation token to use + /// Asynchronous task that retrieves resource information. Will return a null + /// if the resource could not be found + Task> GetResourceFromNameAsync(string subscriptionId, string resourceGroup, string name, CancellationToken token); + + /// + /// Creates a new Cognitive Services resource + /// + /// The kind of resource to create + /// The ID of the subscription + /// The name of the resource group + /// The short name of the region (e.g. westus) + /// The name of the resource to create + /// The SKU of the resource (e.g. F0 for free resources) + /// Cancellation token to use + /// Asynchronous task that creates a new resource + Task> CreateResourceAsync(ResourceKind kind, string subscriptionId, string resourceGroup, string regionName, string name, string sku, CancellationToken token); + + /// + /// Deletes a specific Cognitive Services resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the resource to delete + /// Cancellation token to use + /// Asynchronous task that deletes the resource + Task DeleteResourceAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token); + + /// + /// Retrieves all Cognitive Services model deployments + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the Cognitive Services resource + /// Cancellation token to use + /// Asynchronous task that + Task> GetAllDeploymentsAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token); + + /// + /// Retrieves all available Cognitive Services models + /// + /// The ID of the subscription + /// The short name of the region (e.g. westus) + /// Cancellation token to use + /// Asynchronous task that gets all models + Task> GetAllModelsAsync(string subscriptionId, string regionName, CancellationToken token); + + /// + /// Retrieves the usage of all available Cognitive Services models + /// + /// The ID of the subscription + /// The short name of the region (e.g. westus) + /// Cancellation token to use + /// Asynchronous task that gets all model usage + Task> GetAllModelUsageAsync(string subscriptionId, string regionName, CancellationToken token); + + /// + /// Creates a new Cognitive Services model deployment + /// + /// The subscription identifier + /// The name of the resource group this deployment belongs to + /// The name of the resource this deployment is for + /// The name to use for the new deployment + /// The model name (e.g. gpt-35-turbo) + /// The model version (e.g. 0613) + /// The model format (e.g. OpenAI) + /// //FIXME TODO ???? + /// Cancellation token to use + /// Asynchronous task that creates a new model deployment + Task> CreateDeploymentAsync( + string subscriptionId, + string resourceGroup, + string resourceName, + string deploymentName, + string modelName, + string modelVersion, + string modelFormat, + string scaleCapacity, + CancellationToken token); + + /// + /// Deletes a specific Cognitive Services model deployment + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the Cognitive Services resource + /// The name of the deployment to delete + /// Cancellation token to use + /// Asynchronous task that deletes the deployment + Task DeleteDeploymentAsync( + string subscriptionId, + string resourceGroup, + string resourceName, + string deploymentName, + CancellationToken token); + + /// + /// Retrieves the keys to use for a specific Cognitive Services resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the Cognitive Services resource + /// Cancellation token to use + /// Asynchronous task that gets the resource keys + Task> GetResourceKeysFromNameAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token); + } +} diff --git a/src/common/Clients/ILoginManager.cs b/src/common/Clients/ILoginManager.cs new file mode 100644 index 00000000..86139051 --- /dev/null +++ b/src/common/Clients/ILoginManager.cs @@ -0,0 +1,66 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients +{ + /// + /// The type of login to attempt + /// + public enum LoginMode + { + /// + /// A window will appear with a browser page where the user can log in. Not all OSs support this + /// + UseWebPage, + + /// + /// Show the user a device code and URI. The user then needs to open a browser tab somewhere (could be a different + /// device), navigate to that URI, enter the device code, and then sign in. Once the browser tab is closed, the login + /// process will be complete + /// + UseDeviceCode, + } + + /// + /// Options for logging in + /// + public struct LoginOptions + { + /// + /// The type of login to attempt + /// + public LoginMode Mode { get; set; } + + //public string? TenantId { get; set; } + //public string? UserName { get; set; } + } + + /// + /// Manages the login process for a client + /// + public interface ILoginManager : IDisposable + { + /// + /// Whether or not we can attempt to log in + /// + /// True or false + public bool CanAttemptLogin { get; } + + /// + /// Logs the client in + /// + /// The options to use for the login + /// The cancellation token to use + /// Asynchronous task that completes once the login has completed (or failed) + Task LoginAsync(LoginOptions options, CancellationToken token); + + /// + /// Gets an authentication token to use for sending HTTP REST requests + /// + /// The cancellation token to use + /// Asynchronous task that returns the authentication token + Task> GetOrRenewAuthToken(CancellationToken token); + } +} diff --git a/src/common/Clients/ISearchClient.cs b/src/common/Clients/ISearchClient.cs new file mode 100644 index 00000000..96236343 --- /dev/null +++ b/src/common/Clients/ISearchClient.cs @@ -0,0 +1,64 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients +{ + /// + /// A client for Cognitive search resources + /// + public interface ISearchClient : IDisposable + { + /// + /// Retrieves all search resources, optionally filtering to a specific region + /// + /// The ID of the subscription + /// (Optional) The short name of the region to filter to. Set to null if unneeded + /// Cancellation token to use + /// Asynchronous task that gets all search resources + Task> GetAllAsync(string subscriptionId, string? regionName, CancellationToken token); + + /// + /// Retrieves a specific search resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the resource to retrieve + /// Cancellation token to use + /// Asynchronous task that retrieves resource information. Will return a null + /// if the resource could not be found + Task> GetFromNameAsync(string subscriptionId, string resourceGroup, string name, CancellationToken token); + + /// + /// Creates a new search resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The short name of the region (e.g. westus) + /// The name of the resource to create + /// Cancellation token to use + /// Asynchronous task that creates a new resource + Task> CreateAsync(string subscriptionId, string resourceGroup, string regionName, string name, CancellationToken token); + + /// + /// Deletes a search resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the resource to delete + /// Cancellation token to use + /// Asynchronous task that deletes the resource + Task DeleteAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token); + + /// + /// Retrieves the keys to use for accessing a search resource + /// + /// The ID of the subscription + /// The name of the resource group + /// The name of the resource to retrieve keys for + /// Cancellation token to use + /// Asynchronous task that retrieves the keys + Task> GetKeysAsync(string subscriptionId, string resourceGroup, string name, CancellationToken token); + } +} diff --git a/src/common/Clients/ISubscriptionsClient.cs b/src/common/Clients/ISubscriptionsClient.cs new file mode 100644 index 00000000..07d950a9 --- /dev/null +++ b/src/common/Clients/ISubscriptionsClient.cs @@ -0,0 +1,66 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients +{ + /// + /// A client to retrieve (and optionally create) Azure subscriptions, regions, or resource groups + /// + public interface ISubscriptionsClient : IDisposable + { + /// + /// Gets all subscriptions for the current tenant + /// + /// The cancellation token to use + /// Asynchronous task that gets all subscriptions + Task> GetAllSubscriptionsAsync(CancellationToken token); + + /// + /// Gets details about a particular subscription + /// + /// The ID of the subscription to retrieve + /// The cancellation token to use + /// Asynchronous task that retrieves the subscription information. Will return a null + /// if the subscription does not exist + Task> GetSubscriptionAsync(string subscriptionId, CancellationToken token); + + /// + /// Gets all available Azure regions for the specified subscription + /// + /// The ID of the subscription + /// The cancellation token to use + /// Asynchronous task that returns all regions. Will throw exceptions on failure + Task> GetAllRegionsAsync(string subscriptionId, CancellationToken token); + + /// + /// Gets all resource groups for the specified subscription + /// + /// The ID of the subscription + /// The cancellation token to use + /// Asynchronous task that returns all resource groups. Will throw exceptions on errors + Task> GetAllResourceGroupsAsync(string subscriptionId, CancellationToken token); + + /// + /// Creates a new resource group + /// + /// The ID of the subscription + /// The short region name to create the resource group in (e.g. westcentralus) + /// The name of the resource group + /// The cancellation token to use + /// Asynchronous task that returns the details of the created resource group. Will throw exceptions on errors (e.g. failed + /// to create resource group) + Task> CreateResourceGroupAsync(string subscriptionId, string regionName, string name, CancellationToken token); + + /// + /// Deletes a resource group + /// + /// The ID of the subscription + /// The name of the resource group to delete + /// The cancellation token to use + /// Asynchronous task that deletes the resource group. If this resource group doesn't exist, it will return + /// an error outcome + Task DeleteResourceGroupAsync(string subscriptionId, string resourceGroup, CancellationToken token); + } +} diff --git a/src/common/Clients/Models/AccountRegionLocationInfo.cs b/src/common/Clients/Models/AccountRegionLocationInfo.cs new file mode 100644 index 00000000..a7a15916 --- /dev/null +++ b/src/common/Clients/Models/AccountRegionLocationInfo.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Azure; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about an Azure regions + /// + public readonly struct AccountRegionLocationInfo + { + /// + /// The identifier for this region (e.g. /subscriptions/{subscriptionId}/locations/eastus + /// + public string Id { get; init; } + + /// + /// The short name for the region (e.g. eastus) + /// + public string Name { get; init; } + + /// + /// The display name for the region (e.g. East US) + /// + public string DisplayName { get; init; } + + /// + /// The display name that includes the country (e.g. (US) East US) + /// + public string RegionalDisplayName { get; init; } + } +} diff --git a/src/common/Clients/Models/AuthenticationToken.cs b/src/common/Clients/Models/AuthenticationToken.cs new file mode 100644 index 00000000..fd4984e9 --- /dev/null +++ b/src/common/Clients/Models/AuthenticationToken.cs @@ -0,0 +1,20 @@ +#nullable enable + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// An Azure auth token + /// + public readonly struct AuthenticationToken + { + /// + /// The token value + /// + public string Token { get; init; } + + /// + /// When this token expires + /// + public DateTimeOffset Expires { get; init; } + } +} diff --git a/src/common/Clients/Models/ClientResult.cs b/src/common/Clients/Models/ClientResult.cs new file mode 100644 index 00000000..dbe230c4 --- /dev/null +++ b/src/common/Clients/Models/ClientResult.cs @@ -0,0 +1,315 @@ +#nullable enable + +using Azure.AI.Details.Common.CLI; + +namespace Azure.AI.CLI.Common.Clients.Models +{ + /// + /// Enumerates the possible outcomes of a client operation + /// + public enum ClientOutcome + { + /// + /// The operation failed + /// + Failed = int.MinValue, + + /// + /// The operation timed out + /// + TimedOut = -3, + + /// + /// The operation was canceled + /// + Canceled = -2, + + /// + /// The operation requires login. The user should run `az login` and try again. This + /// can happen if the login was required, and we failed to log in as well + /// + LoginNeeded = -1, + + /// + /// The operation status was unknown. This is the default value + /// + Unknown = 0, + + /// + /// The operation was successful + /// + Success, + } + + /// + /// Represents the result of a client operation + /// + public interface IClientResult + { + /// + /// The outcome of the operation + /// + ClientOutcome Outcome { get; } + + /// + /// (Optional) Details about the error that occurred + /// + string? ErrorDetails { get; } + + /// + /// (Optional) The exception that occurred + /// + Exception? Exception { get; } + + /// + /// Indicates if the operation was successful + /// + bool IsSuccess => !IsError; + + /// + /// Indicates if the operation failed + /// + bool IsError { get; } + + /// + /// Throws an exception if the operation failed + /// + /// (Optional) The message to include in the exception message + void ThrowOnFail(string? message = null); + } + + /// + /// Represents the result of a client operation with a value + /// + /// The type of the value + public interface IClientResult : IClientResult + { + /// + /// The value of the operation + /// + public TValue Value { get; } + } + + /// + /// Represents the result of a client operation + /// + public readonly partial struct ClientResult : IClientResult + { + /// + public ClientOutcome Outcome { get; init; } + + /// + public string? ErrorDetails { get; init; } + + /// + public Exception? Exception { get; init; } + + /// + public bool IsSuccess => !IsError; + + /// + public bool IsError => CheckIsError(Outcome, ErrorDetails, Exception); + + /// + public void ThrowOnFail(string? message = null) => ThrowOnFail(Outcome, message, ErrorDetails, Exception); + + /// + /// Converts the result to a string + /// + /// A string representation of the result + public override string ToString() => ToString(this); + } + + /// + /// Represents the result of a client operation with a value + /// + /// The type of the value + public readonly struct ClientResult : IClientResult + { + /// + public ClientOutcome Outcome { get; init; } + + /// + public TValue Value { get; init; } + + /// + public string? ErrorDetails { get; init; } + + /// + public Exception? Exception { get; init; } + + /// + public bool IsSuccess => !IsError; + + /// + public bool IsError => ClientResult.CheckIsError(Outcome, ErrorDetails, Exception); + + /// + public void ThrowOnFail(string? message = null) => ClientResult.ThrowOnFail(Outcome, message, ErrorDetails, Exception); + + /// + /// Converts the result to a string + /// + /// A string representation of the result + public override string ToString() => ClientResult.ToString(this); + } + + #region helper internal methods + + public readonly partial struct ClientResult + { + internal static bool CheckIsError(ClientOutcome Outcome, string? ErrorDetails, Exception? Exception) + => Outcome != ClientOutcome.Success + || Exception != null + || !string.IsNullOrWhiteSpace(ErrorDetails); + + internal static Exception GenerateException(ClientOutcome outcome, string? message = null, string? errorDetails = null, Exception? ex = null) + => outcome switch + { + ClientOutcome.Canceled => new OperationCanceledException(CreateExceptionMessage("CANCELED", message, errorDetails), ex), + ClientOutcome.Failed or ClientOutcome.Unknown => new ApplicationException(CreateExceptionMessage("FAILED", message, errorDetails), ex), + ClientOutcome.LoginNeeded => new ApplicationException(CreateExceptionMessage("LOGIN REQUIRED", message, errorDetails), ex), + ClientOutcome.TimedOut => new TimeoutException(CreateExceptionMessage("TIMED OUT", message, errorDetails), ex), + _ => new ApplicationException(CreateExceptionMessage("FAILED", message, errorDetails), ex), + }; + + internal static void ThrowOnFail(ClientOutcome outcome, string? message, string? errorDetails, Exception? ex) + { + if (ClientOutcome.Success == outcome) + { + return; + } + + throw GenerateException(outcome, message, errorDetails, ex); + } + + private static string CreateExceptionMessage(string typeStr, string? messageStr, string? detailsStr) + { + var message = messageStr.AsSpan().Trim(); + var details = detailsStr.AsSpan().Trim(); + + int bufferLen = Math.Min(2048, (typeStr.Length + messageStr?.Length + detailsStr?.Length + 20) ?? 0); + Span buffer = stackalloc char[bufferLen]; + var current = buffer; + + int len = 0; + + len += StringHelpers.AppendOrTruncate(ref current, typeStr); + if (!message.IsEmpty || !details.IsEmpty) + { + len += StringHelpers.AppendOrTruncate(ref current, " - "); + } + + if (!message.IsEmpty) + { + len += StringHelpers.AppendOrTruncate(ref current, message); + if (!details.IsEmpty) + { + len += StringHelpers.AppendOrTruncate(ref current, ". "); + } + } + + if (details != null) + { + len += StringHelpers.AppendOrTruncate(ref current, "Details: "); + len += StringHelpers.AppendOrTruncate(ref current, details); + } + + return new string(buffer.Slice(0, len)); + } + + internal static string ToString(T result) where T : IClientResult + { + string str = result.Outcome.ToString(); + if (result.ErrorDetails != null) + { + str += $". ErrorDetails: {result.ErrorDetails}"; + } + + if (result.Exception != null) + { + str += $". Exception: {result.Exception.Message}"; + } + + return str; + } + + internal static ClientResult From(ProcessOutput output) + { + ClientOutcome outcome = ClientOutcome.Success; + if (HasLoginError(output.StdError)) + { + outcome = ClientOutcome.LoginNeeded; + } + else if (output.HasError) + { + outcome = ClientOutcome.Failed; + } + + return new ClientResult + { + ErrorDetails = string.IsNullOrWhiteSpace(output.StdError) ? null : output.StdError, + Exception = outcome != ClientOutcome.Success + ? new ApplicationException($"Process failed with exit code {output.ExitCode}. Details: {output.StdError}") + : null, + Outcome = outcome, + }; + } + + internal static ClientResult From(ProcessOutput output, TValue value) + { + ClientOutcome outcome = ClientOutcome.Success; + if (HasLoginError(output.StdError)) + { + outcome = ClientOutcome.LoginNeeded; + } + else if (output.HasError) + { + outcome = ClientOutcome.Failed; + } + + return new ClientResult + { + ErrorDetails = string.IsNullOrWhiteSpace(output.StdError) ? null : output.StdError, + Exception = outcome != ClientOutcome.Success + ? new ApplicationException($"Process failed with exit code {output.ExitCode}. Details: {output.StdError}") + : null, + Outcome = outcome, + Value = value + }; + } + + internal static ClientResult From(TValue result) + => new ClientResult() + { + Outcome = ClientOutcome.Success, + Value = result + }; + + internal static ClientResult FromException(Exception ex) + => new ClientResult() + { + Outcome = ClientOutcome.Failed, + Exception = ex, + ErrorDetails = ex.Message + }; + + internal static ClientResult FromException(Exception ex) + => FromException(ex, default(TValue)!); + + internal static ClientResult FromException(Exception ex, TValue result) + => new ClientResult() + { + Outcome = ClientOutcome.Failed, + Exception = ex, + ErrorDetails = ex.Message, + Value = result + }; + + private static bool HasLoginError(string? errorMessage) + => errorMessage != null + && (errorMessage.Split('\'', '"').Contains("az login") || errorMessage.Contains("refresh token")); + } + + #endregion +} diff --git a/src/common/Clients/Models/CognitiveSearchResourceInfo.cs b/src/common/Clients/Models/CognitiveSearchResourceInfo.cs new file mode 100644 index 00000000..ce8146a1 --- /dev/null +++ b/src/common/Clients/Models/CognitiveSearchResourceInfo.cs @@ -0,0 +1,27 @@ +#nullable enable + +using System.Text.Json.Serialization; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Represents a Cognitive Search resource + /// + public class CognitiveSearchResourceInfo : ResourceInfoBase + { + /// + [JsonIgnore] + public override ResourceKind Kind + { + get => ResourceKind.Search; + init => _ = value; // do nothing + } + + /// + /// The endpoint for this search resource + /// + public string Endpoint => + // TODO: Need to find official way of getting this + $"https://{Name}.search.windows.net"; + } +} diff --git a/src/common/Clients/Models/CognitiveServicesAiResourceInfo.cs b/src/common/Clients/Models/CognitiveServicesAiResourceInfo.cs new file mode 100644 index 00000000..a11cad1e --- /dev/null +++ b/src/common/Clients/Models/CognitiveServicesAiResourceInfo.cs @@ -0,0 +1,26 @@ +#nullable enable + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about a Cognitive Services AI resource (aka one with AI model deployments) + /// + /// This is usually only used for AIServices, or OpenAI resource kind + public class CognitiveServicesAiResourceInfo : CognitiveServicesResourceInfo + { + /// + /// The deployment information for the chat model + /// + public CognitiveServicesDeploymentInfo? ChatDeployment { get; set; } + + /// + /// The deployment information for the embeddings model + /// + public CognitiveServicesDeploymentInfo? EmbeddingsDeployment { get; set; } + + /// + /// The deployment information for the evaluation model + /// + public CognitiveServicesDeploymentInfo? EvaluationDeployment { get; set; } + } +} diff --git a/src/common/Clients/Models/CognitiveServicesDeploymentInfo.cs b/src/common/Clients/Models/CognitiveServicesDeploymentInfo.cs new file mode 100644 index 00000000..e8a0df16 --- /dev/null +++ b/src/common/Clients/Models/CognitiveServicesDeploymentInfo.cs @@ -0,0 +1,51 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models.Utils; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about an AI model deployment + /// + public readonly struct CognitiveServicesDeploymentInfo + { + /// + /// The unique identifier for this deployment (e.g. /subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CognitiveServices/accounts/{resourceName}/deployments/{deploymentName}) + /// + public string Id { get; init; } + + /// + /// The name of the deployment + /// + public string Name { get; init; } + + /// + /// The name of the resource group this deployment is in + /// + public string ResourceGroup { get; init; } + + /// + /// The name of the underlying model for this deployment (e.g. gpt-4) + /// + [JsonPathPropertyName("properties.model.name")] + public string ModelName { get; init; } + + /// + /// The format of the underlying model for this deployment (e.g. OpenAI) + /// + [JsonPathPropertyName("properties.model.format")] + public string ModelFormat { get; init; } + + /// + /// Whether this deployment is capable of chat completion + /// + [JsonPathPropertyName("properties.capabilities.chatCompletion")] + public bool ChatCompletionCapable { get; init; } + + /// + /// Whether this deployment is capable of embeddings + /// + [JsonPathPropertyName("properties.capabilities.embeddings")] + public bool EmbeddingsCapable { get; init; } + } +} diff --git a/src/common/Clients/Models/CognitiveServicesModelInfo.cs b/src/common/Clients/Models/CognitiveServicesModelInfo.cs new file mode 100644 index 00000000..a80a8451 --- /dev/null +++ b/src/common/Clients/Models/CognitiveServicesModelInfo.cs @@ -0,0 +1,101 @@ +#nullable enable + +using System.Text.Json.Serialization; +using Azure.AI.CLI.Common.Clients.Models.Utils; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Represents a Cognitive Services model + /// + public readonly struct CognitiveServicesModelInfo + { + /// + /// The kind of the model (e.g. OpenAI, or AIServices) + /// + public string Kind { get; init; } + + /// + /// The name of the model (e.g. gpt-4) + /// + [JsonPathPropertyName("model.name")] + public string Name { get; init; } + + /// + /// The model version (e.g. 1106-Preview) + /// + [JsonPathPropertyName("model.version")] + public string Version { get; init; } + + /// + /// The model format (e.g. OpenAI) + /// + [JsonPathPropertyName("model.format")] + public string Format { get; init; } + + /// + /// The name of the SKU (e.g. S0) + /// + public string SkuName { get; init; } + + /// + /// Whether the model is capable of chat completions + /// + [JsonPathPropertyName("model.capabilities.chatCompletion")] + public bool IsChatCapable { get; init; } + + /// + /// Whether the model is capable of embeddings + /// + [JsonPathPropertyName("model.capabilities.embeddings")] + public bool IsEmbeddingsCapable { get; init; } + + /// + /// Whether the model is capable of image generation + /// + [JsonPathPropertyName("model.capabilities.imageGenerations")] + public bool IsImageCapable { get; init; } + + /// + /// The default capacity for this model (e.g. 10) + /// + [JsonIgnore] + public int DefaultCapacity => ModelSkus + ?.Select(sku => sku.DefaultCapacity) + .FirstOrDefault(v => v > 0) + ?? 0; + + /// + /// Whether the model is deprecated + /// + [JsonIgnore] + public bool IsDeprecated + { + get + { + var now = DateTimeOffset.Now; + return now < InferenceDeprecation + && (ModelSkus?.Any(sku => now < sku.DeprecationDate) == true); + } + } + + #region internal properties and models + + [JsonInclude] + [JsonPathPropertyName("model.deprecation.inference")] + internal DateTimeOffset? InferenceDeprecation { get; init; } + + [JsonInclude] + [JsonPathPropertyName("model.skus")] + internal IReadOnlyList? ModelSkus { get; init; } + + internal readonly struct ModelSku + { + [JsonPathPropertyName("capacity.default")] + public int? DefaultCapacity { get; init; } + public DateTimeOffset? DeprecationDate { get; init; } + } + + #endregion + } +} diff --git a/src/common/Clients/Models/CognitiveServicesResourceInfo.cs b/src/common/Clients/Models/CognitiveServicesResourceInfo.cs new file mode 100644 index 00000000..b33a37af --- /dev/null +++ b/src/common/Clients/Models/CognitiveServicesResourceInfo.cs @@ -0,0 +1,27 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models.Utils; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about a Cognitive services resource + /// + public class CognitiveServicesResourceInfo : ResourceInfoBase + { + private static readonly IReadOnlyDictionary EMPTY = new Dictionary(); + + /// + /// (Optional) The URI of the endpoint for this resource (e.g. https://{name}.cognitiveservices.azure.com/) + /// + [JsonPathPropertyName("properties.endpoint")] + public string? Endpoint { get; init; } + + /// + /// Any additional endpoints associated with this resource. Will be empty if there are no additional + /// endpoints + /// + [JsonPathPropertyName("properties.endpoints")] + public IReadOnlyDictionary Endpoints { get; init; } = EMPTY; + } +} diff --git a/src/common/Clients/Models/CognitiveServicesUsageInfo.cs b/src/common/Clients/Models/CognitiveServicesUsageInfo.cs new file mode 100644 index 00000000..8b96bb1a --- /dev/null +++ b/src/common/Clients/Models/CognitiveServicesUsageInfo.cs @@ -0,0 +1,46 @@ +#nullable enable + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about a Cognitive Services model usage + /// + public readonly struct CognitiveServicesUsageInfo + { + /// + /// The current value of the usage (please refer to to see the unit for this) + /// + public float CurrentValue { get; init; } + + /// + /// The maximum allowed usage (please refer to to see the unit for this) + /// + public float Limit { get; init; } + + /// + /// The name of this usage + /// + public UsageName Name { get; init; } + + /// + /// The unit the , and values are in (e.g. Count) + /// + public string Unit { get; init; } + + /// + /// Information about a usage name + /// + public readonly struct UsageName + { + /// + /// A human readable localized name (e.g. Tokens Per Minute (thousands) - GPT-4) + /// + public string LocalizedValue { get; init; } + + /// + /// The name of this usage (e.g. OpenAI.Standard.gpt-4) + /// + public string Value { get; init; } + } + } +} diff --git a/src/common/Clients/Models/ResourceGroupInfo.cs b/src/common/Clients/Models/ResourceGroupInfo.cs new file mode 100644 index 00000000..4a87a600 --- /dev/null +++ b/src/common/Clients/Models/ResourceGroupInfo.cs @@ -0,0 +1,28 @@ +#nullable enable + +using System.Text.Json.Serialization; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about an Azure resource group + /// + public readonly struct ResourceGroupInfo + { + /// + /// The identifier for this resource group (e.g. /subscriptions/{subscriptionId}/resourceGroups/{name}) + /// + public string Id { get; init; } + + /// + /// The name of this resource group + /// + public string Name { get; init; } + + /// + /// The short name of the region this resource group is in (e.g. westcentralus) + /// + [JsonPropertyName("location")] + public string Region { get; init; } + } +} diff --git a/src/common/Clients/Models/ResourceInfoBase.cs b/src/common/Clients/Models/ResourceInfoBase.cs new file mode 100644 index 00000000..af0a8f87 --- /dev/null +++ b/src/common/Clients/Models/ResourceInfoBase.cs @@ -0,0 +1,53 @@ +#nullable enable + +using System.Text.Json.Serialization; +using Azure.AI.CLI.Common.Clients.Models.Utils; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Base class for information about Azure resource + /// + public abstract class ResourceInfoBase + { + private string? _key; + + /// + /// The kind of resource + /// + [JsonConverter(typeof(ResourceKindJsonConverter))] + public virtual ResourceKind Kind { get; init; } + + /// + /// The unique identifier for this resource (e.g. /subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CognitiveServices/accounts/{name}) + /// + /// The providers part of the ID will be different depending on the type of resource + public string Id { get; init; } = string.Empty; + + /// + /// The name of this resource + /// + public string Name { get; init; } = string.Empty; + + /// + /// The name of the resource group this subscription belongs to + /// + // TODO FIXME: rename to ResourceGroup + [JsonPropertyName("resourceGroup")] + public string Group { get; init; } = string.Empty; + + /// + /// The short name of the region this resource is in + /// + [JsonPropertyName("location")] + // TODO FIXME: rename to Region + public string RegionLocation { get; init; } = string.Empty; + + [JsonIgnore] + public string Key + { + get => _key ?? string.Empty; + set => _key = value; + } + } +} diff --git a/src/common/Clients/Models/ResourceKind.cs b/src/common/Clients/Models/ResourceKind.cs new file mode 100644 index 00000000..0f85211b --- /dev/null +++ b/src/common/Clients/Models/ResourceKind.cs @@ -0,0 +1,61 @@ +#nullable enable + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Possible supported kinds of Azure resources + /// + public enum ResourceKind + { + /// + /// Unknown kind + /// + Unknown, + + /// + /// An AI hub resource (this is basically way to collect an AI resource of some kind [e.g. AIServices], with insights, key vault, and other related + /// resources) + /// + Hub, + + /// + /// An AI project. An AI hub resource can have one or more AI projects associated with it + /// + Project, + + /// + /// An AI services resource (OpenAI, speech, vision, etc...) + /// + AIServices, + + /// + /// A "legacy" AI resource (Speech, vision, etc...). Note this has no OpenAI support + /// + CognitiveServices, + + /// + /// A "legacy" Azure OpenAI resource. This does only OpenAI + /// + OpenAI, + + /// + /// A "legacy" speech resource. This does only speech + /// + Speech, + + /// + /// A "legacy" vision resource. This does only vision + /// + Vision, + + /// + /// A search resource + /// + Search, + + /// + /// A "legacy" face subscription + /// + Face, + } +} diff --git a/src/common/Clients/Models/SubscriptionInfo.cs b/src/common/Clients/Models/SubscriptionInfo.cs new file mode 100644 index 00000000..ad80c41e --- /dev/null +++ b/src/common/Clients/Models/SubscriptionInfo.cs @@ -0,0 +1,33 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients.Models.Utils; + +namespace Azure.AI.Details.Common.CLI.AzCli +{ + /// + /// Information about an Azure subscription + /// + public readonly struct SubscriptionInfo + { + /// + /// The unique identifier for this subscription (usually a GUID like string) + /// + public string Id { get; init; } + + /// + /// The name of this subscription (e.g. Contoso subscription) + /// + public string Name { get; init; } + + /// + /// The user name of the currently logged in user (e.g. john.doe@contoso.com) + /// + [JsonPathPropertyName("user.name")] + public string UserName { get; init; } + + /// + /// True if this is the default subscription + /// + public bool IsDefault { get; init; } + } +} diff --git a/src/common/Clients/Models/Utils/JPathJsonConverter.cs b/src/common/Clients/Models/Utils/JPathJsonConverter.cs new file mode 100644 index 00000000..d8d306d4 --- /dev/null +++ b/src/common/Clients/Models/Utils/JPathJsonConverter.cs @@ -0,0 +1,317 @@ +#nullable enable + +using System.Globalization; +using System.Reflection; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Runtime.CompilerServices; + +namespace Azure.AI.CLI.Common.Clients.Models.Utils +{ + /// + /// An attribute to specify the JSON path to use when deserializing a property or field + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, Inherited = true)] + public class JsonPathPropertyNameAttribute : JsonAttribute + { + /// + /// Creates a new instance + /// + /// The JSON path to use when deserializing + public JsonPathPropertyNameAttribute(string jsonPath) + { + JsonPath = jsonPath; + } + + /// + /// The JSON path to use when deserializing + /// + public string JsonPath { get; set; } + } + + /// + /// A converter to enable the use of a JPath when deserializing JSON + /// + /// + /// To use this correctly you'll need to to the following: + /// + /// Add a attribute to each property or field with the Json path expression you want + /// Create a instance and add a new instance of this to its converter. DO **NOT** SET THIS IN A as doing so will cause an infinite loop + /// + /// + public class JsonPathConverterFactory : JsonConverterFactory + { + private static readonly BindingFlags ALL_INSTANCE_MEMBERS = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// + public override bool CanConvert(Type typeToConvert) + { + // TODO ralphe: Should we cache to avoid reflection on every call? + var nonNullableType = IsNullable(typeToConvert) + ? typeToConvert.GenericTypeArguments[0] + : typeToConvert; + + bool hasJPathMembers = GetJPathMembers(typeToConvert).Any(); + return hasJPathMembers; + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var closedType = typeof(JsonPathJsonConverter<>).MakeGenericType([typeToConvert]); + var converter = (JsonConverter?)Activator.CreateInstance(closedType); + return converter; + } + + /// + /// Implementation of a converter that uses default serialization first, and then tries to deserialize JSON path + /// properties + /// + /// The type that contains properties or fields marked with + public class JsonPathJsonConverter : JsonConverter + { + /// + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // 1. Clone the reader (Utf8JsonReader is a struct) + Utf8JsonReader clonedReader = reader; + + // 2. Use the cloned options (removing this converter to prevent infinite loop) to deserialize the type normally + var original = options ?? JsonSerializerOptions.Default; + options = new JsonSerializerOptions(original); + options.Converters.Clear(); + foreach (var conv in original.Converters + .Where(c => c is not JsonPathConverterFactory + && !IsFromGeneric(c.GetType(), typeof(JsonPathJsonConverter<>)))) + { + options.Converters.Add(conv); + } + + T? instance = JsonSerializer.Deserialize(ref reader, options); + + // 3. Populate any JPath expressions + if (instance != null) + { + using var doc = JsonDocument.ParseValue(ref clonedReader); + + // TODO FIXME Force to be boxed so setters work on structs. Not the most efficient, but it is + // the fastest way for now. + object? boxed = instance; + + foreach (var member in GetJPathMembers(typeToConvert)) + { + JsonElement? match = GetJPath(doc.RootElement, member.Path); + if (match != null) + { + object? value = JsonSerializer.Deserialize((JsonElement)match, member.Type, original); + if (value != null) + { + member.Setter(boxed, value); + } + } + } + + instance = (T?)boxed; + } + + return instance; + } + + /// + /// This method is not supported + /// + /// The writer to use + /// The value to write + /// The options to use + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + } + + private static bool IsNullable(Type t) + => t.IsGenericType && t.IsConstructedGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>); + + private static IEnumerable GetJPathMembers(Type type) => + type.GetProperties(ALL_INSTANCE_MEMBERS) + .Where(p => p.GetCustomAttribute() != null) + .Select(p => new Entry( + p.Name, + p.GetCustomAttribute()?.JsonPath, + p.PropertyType, + p.SetValue)) + .Concat( + type.GetFields(ALL_INSTANCE_MEMBERS) + .Where(p => p.GetCustomAttribute() != null) + .Select(f => new Entry( + f.Name, + f.GetCustomAttribute()?.JsonPath, + f.FieldType, + f.SetValue))); + + private static bool IsFromGeneric(Type type, Type genericType) + { + return type.IsGenericType + && ( + (type.IsConstructedGenericType && type.GetGenericTypeDefinition() == genericType) + || + (!type.IsConstructedGenericType && genericType.IsAssignableFrom(type)) + ); + } + + private static JsonElement? GetJPath(JsonElement node, string? jpath) + { + var parts = ParseJPath(jpath); + if (!parts.Any()) + { + return null; + } + + // NOTE: + // ============================================ + // Unfortunately, System.Text.Json does not yet support Json path expressions. As a workaround for now, + // we will use some basic implementation of that + + JsonElement? match = node; + foreach (var part in ParseJPath(jpath)) + { + switch (part.Type) + { + default: throw new NotSupportedException("Don't know to handle " + part.Type); + + case JPathEntryType.Property: + if (match?.TryGetProperty(part.Name, out var inner) == true) + { + match = inner; + } + else + { + match = null; + } + break; + + case JPathEntryType.ArrayIndex: + match = match?.EnumerateArray().Cast().ElementAtOrDefault(part.Index); + break; + } + + if (match == null) + { + break; + } + } + + return match; + } + + /// + /// Parses the JSON path expression. Right now only property and array index are supported + /// + /// The JSON path to parse + /// The parts of the JSON path + /// The JSON path is invalid + private static IEnumerable ParseJPath(string? jPath) + { + if (string.IsNullOrWhiteSpace(jPath)) + { + yield break; + } + + // For now we just support . and [] + int start = 0; + bool isArray = false; + int i; + + for (i = 0; i < jPath.Length; i++) + { + char c = jPath[i]; + switch (c) + { + case '.': + if (isArray) + { + throw new ArgumentException("Unexpected property name in JPath"); + } + + var span = jPath.AsSpan(start, i - start); + if (span.IsEmpty) + { + throw new ArgumentException("Cannot have an empty property name in JPath"); + } + + yield return new JPathEntry { Name = new string(span), Type = JPathEntryType.Property }; + start = i + 1; + break; + + case '[': + if (isArray) + { + throw new ArgumentException("Cannot have nested arrays in JPath"); + } + + // anything before this? + span = jPath.AsSpan(start, i - start); + if (!span.IsEmpty) + { + yield return new JPathEntry { Name = new string(span), Type = JPathEntryType.Property }; + } + + start = i + 1; + isArray = true; + break; + + case ']': + if (!isArray) + { + throw new ArgumentException("Unexpected end of array in JPath"); + } + + span = jPath.AsSpan(start, i - start).Trim(); + ReadOnlySpan x = span; + if (!int.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out int index)) + { + throw new ArgumentException("Invalid index in array in JPath"); + } + + yield return new JPathEntry { Type = JPathEntryType.ArrayIndex, Index = index }; + start = i + 1; + isArray = false; + break; + + default: + break; + } + } + + if (i > start) + { + if (isArray) + { + throw new ArgumentException("Unterminated array index in JPath"); + } + + yield return new JPathEntry { Type = JPathEntryType.Property, Name = jPath.Substring(start, i - start) }; + } + } + + private delegate void Setter(ref T? instance, object? value); + + private record Entry(string Name, string? Path, Type Type, Action Setter); + + private enum JPathEntryType + { + Property, + ArrayIndex, + } + + private struct JPathEntry + { + public JPathEntry() { } + + public JPathEntryType Type; + public string Name = string.Empty; + public int Index = -1; + } + } +} diff --git a/src/common/Clients/Models/Utils/JsonConverterArgsAttribute.cs b/src/common/Clients/Models/Utils/JsonConverterArgsAttribute.cs new file mode 100644 index 00000000..d9fe5f8a --- /dev/null +++ b/src/common/Clients/Models/Utils/JsonConverterArgsAttribute.cs @@ -0,0 +1,37 @@ +#nullable enable + +using System.Text.Json.Serialization; + +namespace Azure.AI.CLI.Common.Clients.Models.Utils +{ + /// + /// Attribute that can be used to create a instance with a constructor that takes some arguments + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Interface, AllowMultiple = false)] + public class JsonConverterArgsAttribute : JsonConverterAttribute + { + private readonly object?[]? _args; + + /// + /// Creates a new instance + /// + /// The type of the converter to create + /// The arguments to pass to the converter constructor + public JsonConverterArgsAttribute(Type converterType, params object?[]? args) : base(converterType) + { + _args = args; + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (ConverterType != null) + { + var instance = (JsonConverter?)Activator.CreateInstance(ConverterType, _args); + return instance; + } + + return null; + } + } +} diff --git a/src/common/Clients/Models/Utils/ResourceKindHelpers.cs b/src/common/Clients/Models/Utils/ResourceKindHelpers.cs new file mode 100644 index 00000000..0ba19c5f --- /dev/null +++ b/src/common/Clients/Models/Utils/ResourceKindHelpers.cs @@ -0,0 +1,83 @@ +#nullable enable + +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients.Models.Utils +{ + /// + /// Helper methods to work with + /// + public static class ResourceKindHelpers + { + private static readonly IReadOnlyDictionary STRING_TO_KIND = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "SpeechServices", ResourceKind.Speech }, + { "ComputerVision", ResourceKind.Vision } + }; + + private static readonly IReadOnlyDictionary KIND_TO_STRING = + STRING_TO_KIND.ToDictionary(e => e.Value, e => e.Key); + + /// + /// Parses a string value of a resource kind from an Azure response + /// + /// The string value to parse + /// The equivalent , or if there is no mapping currently + public static ResourceKind? ParseString(string? strValue) + { + if (strValue == null) + { + return null; + } + + ResourceKind kind; + if (STRING_TO_KIND.TryGetValue(strValue, out kind)) + { + return kind; + } + + if (Enum.TryParse(strValue, true, out kind)) + { + return kind; + } + + return null; + } + + /// + /// Parses the first value from a string separated by the specified chars + /// + /// The string value (e.g. "OpenAI; AIServices") + /// The separators (e.g. ';') + /// The value of the first parsed + public static ResourceKind? ParseString(string? strValue, params char[] separator) + { + return strValue + ?.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(ParseString) + .FirstOrDefault(k => k != null); + } + + /// + /// Gets the equivalent JSON string value for the resource kind for sending to Azure + /// + /// The to convert to a string + /// The equivalent string. If unknown, an empty string will be returned + public static string AsJsonString(this ResourceKind kind) + { + if (KIND_TO_STRING.TryGetValue(kind, out string? strValue)) + { + return strValue; + } + else + { + return kind switch + { + ResourceKind.Unknown => string.Empty, + _ => kind.ToString() + }; + } + } + } +} diff --git a/src/common/Clients/Models/Utils/ResourceKindJsonConverter.cs b/src/common/Clients/Models/Utils/ResourceKindJsonConverter.cs new file mode 100644 index 00000000..9f2ca065 --- /dev/null +++ b/src/common/Clients/Models/Utils/ResourceKindJsonConverter.cs @@ -0,0 +1,45 @@ +#nullable enable + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Common.Clients.Models.Utils +{ + /// + /// Converter used when serializing or deserializing JSON to normalize the resource kind into our defined list. Anything + /// not in the enumeration above (or with a custom override) will be set to + /// + public class ResourceKindJsonConverter : JsonConverter + { + private readonly ResourceKind _defaultKind; + + /// + /// Creates a new instance + /// + public ResourceKindJsonConverter() : this(ResourceKind.Unknown) + { } + + /// + /// Creates a new instance with the kind to use should parsing the resource kind fail + /// + /// The fallback to use + public ResourceKindJsonConverter(ResourceKind fallback) + { + _defaultKind = fallback; + } + + /// + public override ResourceKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? strValue = reader.GetString(); + return ResourceKindHelpers.ParseString(strValue) ?? _defaultKind; + } + + /// + public override void Write(Utf8JsonWriter writer, ResourceKind value, JsonSerializerOptions options) + { + writer.WriteStringValue(ResourceKindHelpers.AsJsonString(value)); + } + } +} diff --git a/src/common/Clients/Models/Utils/StringToBoolJsonConverter.cs b/src/common/Clients/Models/Utils/StringToBoolJsonConverter.cs new file mode 100644 index 00000000..76768472 --- /dev/null +++ b/src/common/Clients/Models/Utils/StringToBoolJsonConverter.cs @@ -0,0 +1,99 @@ +#nullable enable + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.AI.CLI.Common.Clients.Models.Utils +{ + /// + /// Json converter that supports a boolean value. Unlike the default behavior of System.Text.Json, this supports deserializing + /// a value from a string + /// + public class StringToBoolJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => JsonStringToBoolHelpers.ReadBool(ref reader, options) ?? false; + + /// + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + => JsonStringToBoolHelpers.WriteBool(writer, value, options); + } + + /// + /// Json converter that supports a nullable boolean. Unlike the default behavior of System.Text.Json, this supports + /// deserializing a value from a string + /// + public class StringToNullableBoolJsonConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => JsonStringToBoolHelpers.ReadBool(ref reader, options); + + /// + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + => JsonStringToBoolHelpers.WriteBool(writer, value, options); + } + + /// + /// Helper methods to read and write a boolean to/from strings + /// + internal static class JsonStringToBoolHelpers + { + /// + /// Helper method to read a boolean. This supports parsing string values as booleans + /// + /// The reader to read from. The reader must be at a position where a boolean can be read (e.g. a boolean token)/// + /// The options to use when deserializing. This is used to determine how to parse the boolean value + /// The boolean value read from the reader, or null if the value was null + /// + /// Thrown if the reader is not at a position where a boolean can be read, or the string value could not be parsed into boolean + /// + internal static bool? ReadBool(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + string? str = reader.GetString(); + if (bool.TryParse(str, out var parsed)) + { + return parsed; + } + + throw new JsonException($"Can't parse '{str}' into a boolean"); + default: + throw new JsonException("Cannot parse into a boolean"); + } + } + + /// + /// Helper method to write a boolean + /// + /// The writer to write to + /// The value to write + /// The options to use when serializing. This is used to determine how to write the boolean value + internal static void WriteBool(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteBooleanValue(value.Value); + } + else + { + writer.WriteNullValue(); + } + } + } +} diff --git a/src/common/IProgramData.cs b/src/common/IProgramData.cs index 504f8386..a8c1124b 100644 --- a/src/common/IProgramData.cs +++ b/src/common/IProgramData.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // +using Azure.AI.CLI.Common.Clients; using Azure.AI.Details.Common.CLI.Telemetry; namespace Azure.AI.Details.Common.CLI @@ -51,5 +52,11 @@ public interface IProgramData IEventLoggerHelpers EventLoggerHelpers { get; } ITelemetry Telemetry { get; } + + ILoginManager LoginManager { get; } + ISubscriptionsClient SubscriptionsClient { get; } + ICognitiveServicesClient CognitiveServicesClient { get; } + ISearchClient SearchClient { get; } + ICommandValues Values { get; } } } diff --git a/src/common/Program.cs b/src/common/Program.cs index 582c2deb..2127044d 100644 --- a/src/common/Program.cs +++ b/src/common/Program.cs @@ -5,10 +5,9 @@ using System.Reflection; using System.Text; -using System.Text.Json; +using Azure.AI.CLI.Common.Clients; using Azure.AI.Details.Common.CLI.Telemetry; using Azure.AI.Details.Common.CLI.Telemetry.Events; -using System.Threading.Tasks; namespace Azure.AI.Details.Common.CLI { @@ -36,7 +35,8 @@ public static int Main(IProgramData data, string[] mainArgs) Environment.Exit(1); }; - ICommandValues values = new CommandValues(); + var values = _data.Values; + INamedValueTokens tokens = new CmdLineTokenSource(mainArgs, values); int exitCode = ParseCommand(tokens, values); @@ -480,6 +480,13 @@ private static bool RunCommand(ICommandValues values) public static IEventLoggerHelpers EventLoggerHelpers => _data?.EventLoggerHelpers!; - public static ITelemetry Telemetry => _data?.Telemetry!; + public static ITelemetry Telemetry => _data?.Telemetry ?? NoOpTelemetry.Instance; + public static ILoginManager LoginManager => _data.LoginManager; + public static ISubscriptionsClient SubscriptionClient => _data.SubscriptionsClient; + public static ICognitiveServicesClient CognitiveServicesClient => _data.CognitiveServicesClient; + public static ISearchClient SearchClient => _data.SearchClient; + + // TODO FIXME: This is a temporary hack to make it easier to track where this is used + public static CancellationToken CancelToken { get; } = CancellationToken.None; } } diff --git a/src/common/Telemetry/ITelemetry.cs b/src/common/Telemetry/ITelemetry.cs index 5e54fba7..2a7573d8 100644 --- a/src/common/Telemetry/ITelemetry.cs +++ b/src/common/Telemetry/ITelemetry.cs @@ -28,9 +28,12 @@ public interface ITelemetry : IAsyncDisposable /// /// Helper methods to make working with telemetry easier /// - [DebuggerStepThrough] + [DebuggerNonUserCode] public static class TelemetryExtensions { + // NOTE: To make stepping through the code easier, we've added DebuggerNonUserCode attributes to these methods + // so that the debugger will not step into them + #region delegates /// @@ -66,6 +69,7 @@ public delegate ITelemetryEvent CreateTelemetryEvent( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static Task WrapAsync( this ITelemetry telemetry, Func doWorkAsync, @@ -73,7 +77,7 @@ public static Task WrapAsync( { return WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { await doWorkAsync().ConfigureAwait(false); return (Outcome.Success, true); @@ -88,6 +92,7 @@ public static Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task WrapAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -95,7 +100,7 @@ public static async Task WrapAsync( { var (outcome, _) = await WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { var outcome = await doWorkAsync() .ConfigureAwait(false); @@ -115,6 +120,7 @@ public static async Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task WrapAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -122,7 +128,7 @@ public static async Task WrapAsync( { var (_, result) = await WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { var result = await doWorkAsync().ConfigureAwait(false); return (Outcome.Success, result); @@ -141,6 +147,7 @@ public static async Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task<(Outcome, TResult)> WrapResultAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -179,6 +186,7 @@ public static async Task WrapAsync( /// The telemetry instance to use /// The work to do /// The method to call to create the telemetry event + [DebuggerNonUserCode] public static void Wrap( this ITelemetry telemetry, Action doWork, @@ -186,7 +194,7 @@ public static void Wrap( { var (outcome, _) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { doWork(); return (Outcome.Success, true); @@ -201,6 +209,7 @@ public static void Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned outcome from the work + [DebuggerNonUserCode] public static Outcome Wrap( this ITelemetry telemetry, Func doWork, @@ -208,7 +217,7 @@ public static Outcome Wrap( { var (outcome, _) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { var outcome = doWork(); return (outcome, true); @@ -226,6 +235,7 @@ public static Outcome Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned result from the work + [DebuggerNonUserCode] public static TResult Wrap( this ITelemetry telemetry, Func doWork, @@ -233,7 +243,7 @@ public static TResult Wrap( { var (_, result) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { var result = doWork(); return (Outcome.Success, result); @@ -251,6 +261,7 @@ public static TResult Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned outcome, and result from the work + [DebuggerNonUserCode] public static (Outcome, TResult) WrapResult( this ITelemetry telemetry, Func<(Outcome, TResult)> doWork, @@ -283,9 +294,11 @@ public static (Outcome, TResult) WrapResult( #region helper methods + [DebuggerNonUserCode] private static CreateTelemetryEvent Wrap(CreateTelemetryEvent creator) => (Outcome outcome, TResult? _, Exception? ex, TimeSpan duration) => creator(outcome, ex, duration); + [DebuggerNonUserCode] private static Exception FlattenException(Exception ex) { // special case for AggregateExceptions that wrap only a single exception. This can happen for @@ -300,6 +313,7 @@ private static Exception FlattenException(Exception ex) } } + [DebuggerNonUserCode] private static Outcome ToOutcome(Exception ex) => ex switch { @@ -309,6 +323,7 @@ private static Outcome ToOutcome(Exception ex) => }; + [DebuggerNonUserCode] private static void SendEvent(ITelemetry telemetry, Func creator) { if (telemetry != null) diff --git a/src/common/common.csproj b/src/common/common.csproj index 8542ed1e..56fc769e 100644 --- a/src/common/common.csproj +++ b/src/common/common.csproj @@ -1,10 +1,10 @@ - + net8.0 enable Azure.AI.CLI.Common - Azure.AI.Details.Common.CLI + Azure.AI.CLI.Common enable @@ -16,7 +16,8 @@ - + + diff --git a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui.cs b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui.cs index 970fdd97..54415c19 100644 --- a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui.cs +++ b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui.cs @@ -3,32 +3,52 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Linq; -using System.Threading.Tasks; -using System.Collections.Generic; -using Azure.AI.Details.Common.CLI.ConsoleGui; using System.Text.Json; -using System.IO; +using System.Text.Json.Serialization; namespace Azure.AI.Details.Common.CLI { public struct AiHubResourceInfo { - public string Id; - public string Group; - public string Name; - public string RegionLocation; + [JsonPropertyName("id")] + public string Id { get; init; } + + [JsonPropertyName("resource_group")] + public string Group { get; init; } + + [JsonPropertyName("name")] + public string Name { get; init; } + + [JsonPropertyName("location")] + public string RegionLocation { get; init; } + + [JsonPropertyName("display_name")] + public string DisplayName { get; init; } + + public override string ToString() => $"{DisplayName ?? Name} ({RegionLocation})"; } public struct AiHubProjectInfo { - public string Id; - public string Group; - public string Name; - public string DisplayName; - public string RegionLocation; - public string HubId; + [JsonPropertyName("id")] + public string Id { get; init; } + + [JsonPropertyName("resource_group")] + public string Group { get; init; } + + [JsonPropertyName("name")] + public string Name { get; init; } + + [JsonPropertyName("display_name")] + public string DisplayName { get; init; } + + [JsonPropertyName("location")] + public string RegionLocation { get; init; } + + [JsonPropertyName("workspace_hub")] + public string HubId { get; init; } + + public override string ToString() => $"{DisplayName} ({RegionLocation})"; } public partial class AiSdkConsoleGui @@ -43,7 +63,7 @@ public partial class AiSdkConsoleGui if (project == null) return (null, null, null); var hub = project?.GetPropertyStringOrNull("workspace_hub"); - var hubName = hub.Split('/').Last(); + var hubName = hub?.Split('/').LastOrDefault(); var json = PythonSDKWrapper.ListConnections(values, subscription, groupName, projectName); if (string.IsNullOrEmpty(json)) return (null, null, null); @@ -71,17 +91,19 @@ public partial class AiSdkConsoleGui var openaiEndpoint = openaiConnection.GetPropertyStringOrNull("target"); if (string.IsNullOrEmpty(openaiEndpoint)) return null; - var responseOpenAi = await AzCli.ListCognitiveServicesResources(subscription, "OpenAI"); - var responseOpenAiOk = !string.IsNullOrEmpty(responseOpenAi.Output.StdOutput) && string.IsNullOrEmpty(responseOpenAi.Output.StdError); - if (!responseOpenAiOk) return null; + var responseOpenAi = await Program.CognitiveServicesClient.GetAllResourcesAsync(subscription, Program.CancelToken, AzCli.ResourceKind.OpenAI); + if (responseOpenAi.IsError) + { + return null; + } Func match = (a, b) => { return a == b || - a.Replace(".openai.azure.com/", ".cognitiveservices.azure.com/") == b || - b.Replace(".openai.azure.com/", ".cognitiveservices.azure.com/") == a; + a?.Replace(".openai.azure.com/", ".cognitiveservices.azure.com/") == b || + b?.Replace(".openai.azure.com/", ".cognitiveservices.azure.com/") == a; }; - var matchOpenAiEndpoint = responseOpenAi.Payload.Where(x => match(x.Endpoint, openaiEndpoint)).ToList(); + var matchOpenAiEndpoint = responseOpenAi.Value.Where(x => match(x.Endpoint, openaiEndpoint)).ToList(); if (matchOpenAiEndpoint.Count() != 1) return null; return matchOpenAiEndpoint.First(); @@ -95,11 +117,10 @@ public partial class AiSdkConsoleGui var searchEndpoint = searchConnection.GetPropertyStringOrNull("target"); if (string.IsNullOrEmpty(searchEndpoint)) return null; - var responseSearch = await AzCli.ListSearchResources(subscription, null); - var responseSearchOk = !string.IsNullOrEmpty(responseSearch.Output.StdOutput) && string.IsNullOrEmpty(responseSearch.Output.StdError); - if (!responseSearchOk) return null; + var responseSearch = await Program.SearchClient.GetAllAsync(subscription, null, Program.CancelToken); + if (responseSearch.IsError) { return null; } - var matchSearchEndpoint = responseSearch.Payload.Where(x => x.Endpoint == searchEndpoint).ToList(); + var matchSearchEndpoint = responseSearch.Value.Where(x => x.Endpoint == searchEndpoint).ToList(); if (matchSearchEndpoint.Count() != 1) return null; return matchSearchEndpoint.First(); diff --git a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreateAiHubResource.cs b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreateAiHubResource.cs index f80abcc7..5d85ecac 100644 --- a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreateAiHubResource.cs +++ b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreateAiHubResource.cs @@ -39,27 +39,23 @@ public static async Task CreateAiHubResource(ICommandValues v var json = PythonSDKWrapper.ListResources(values, subscription); if (Program.Debug) Console.WriteLine(json); - var parsed = !string.IsNullOrEmpty(json) ? JsonDocument.Parse(json) : default; - var items = parsed?.GetPropertyArrayOrEmpty("resources") ?? Array.Empty(); + var items = JsonHelpers.DeserializePropertyValueOrDefault>(json, "resources") + ?.OrderBy(res => res.DisplayName + " " + res.Name) + .ThenBy(res => res.RegionLocation) + .ToArray() + ?? Array.Empty(); var choices = new List(); - foreach (var item in items) - { - var name = item.GetPropertyStringOrNull("name"); - var location = item.GetPropertyStringOrNull("location"); - var displayName = item.GetPropertyStringOrNull("display_name"); - - choices.Add(string.IsNullOrEmpty(displayName) - ? $"{name} ({location})" - : $"{displayName} ({location})"); - } - if (allowCreate) { - choices.Insert(0, "(Create w/ integrated Open AI + AI Services)"); - choices.Insert(1, "(Create w/ standalone Open AI resource)"); + choices.Add("(Create w/ integrated Open AI + AI Services)"); + choices.Add("(Create w/ standalone Open AI resource)"); } + choices.AddRange(items + .Select(item => + $"{(string.IsNullOrEmpty(item.DisplayName) ? item.Name : item.DisplayName)} ({item.RegionLocation})")); + if (choices.Count == 0) { throw new ApplicationException($"CANCELED: No resources found"); @@ -73,9 +69,9 @@ public static async Task CreateAiHubResource(ICommandValues v } Console.WriteLine($"\rName: {choices[picked]}"); - var resource = allowCreate - ? (picked >= 2 ? items.ToArray()[picked - 2] : default(JsonElement?)) - : items.ToArray()[picked]; + AiHubResourceInfo resource = allowCreate + ? (picked >= 2 ? items[picked - 2] : default) + : items[picked]; var byoServices = allowCreate && picked == 1; if (byoServices) @@ -108,7 +104,7 @@ public static async Task CreateAiHubResource(ICommandValues v return (FinishPickOrCreateAiHubResource(values, resource), createNewHub); } - private static async Task TryCreateAiHubResourceInteractive(ICommandValues values, string subscription) + private static async Task TryCreateAiHubResourceInteractive(ICommandValues values, string subscription) { var locationName = values.GetOrEmpty("service.resource.region.name"); var groupName = ResourceGroupNameToken.Data().GetOrDefault(values); @@ -124,25 +120,17 @@ public static async Task CreateAiHubResource(ICommandValues v return await TryCreateAiHubResourceInteractive(values, subscription, locationName, groupName, displayName, description, openAiResourceId, openAiResourceKind, smartName, smartNameKind); } - private static AiHubResourceInfo FinishPickOrCreateAiHubResource(ICommandValues values, JsonElement? resource) + private static AiHubResourceInfo FinishPickOrCreateAiHubResource(ICommandValues values, AiHubResourceInfo resource) { - var aiHubResource = new AiHubResourceInfo - { - Id = resource?.GetPropertyStringOrNull("id"), - Group = resource?.GetPropertyStringOrNull("resource_group"), - Name = resource?.GetPropertyStringOrNull("name"), - RegionLocation = resource?.GetPropertyStringOrNull("location"), - }; - - ResourceIdToken.Data().Set(values, aiHubResource.Id); - ResourceNameToken.Data().Set(values, aiHubResource.Name); - ResourceGroupNameToken.Data().Set(values, aiHubResource.Group); - RegionLocationToken.Data().Set(values, aiHubResource.RegionLocation); - - return aiHubResource; + ResourceIdToken.Data().Set(values, resource.Id); + ResourceNameToken.Data().Set(values, resource.Name); + ResourceGroupNameToken.Data().Set(values, resource.Group); + RegionLocationToken.Data().Set(values, resource.RegionLocation); + + return resource; } - private static async Task TryCreateAiHubResourceInteractive(ICommandValues values, string subscription, string locationName, string groupName, string displayName, string description, string openAiResourceId, string openAiResourceKind, string smartName = null, string smartNameKind = null) + private static async Task TryCreateAiHubResourceInteractive(ICommandValues values, string subscription, string locationName, string groupName, string displayName, string description, string openAiResourceId, string openAiResourceKind, string smartName = null, string smartNameKind = null) { var sectionHeader = $"\n`CREATE AZURE AI RESOURCE`"; ConsoleHelpers.WriteLineWithHighlight(sectionHeader); @@ -150,7 +138,7 @@ private static AiHubResourceInfo FinishPickOrCreateAiHubResource(ICommandValues var groupOk = !string.IsNullOrEmpty(groupName); if (!groupOk) { - var location = await AzCliConsoleGui.PickRegionLocationAsync(true, locationName, false); + var location = await AzCliConsoleGui.PickRegionLocationAsync(true, subscription, locationName, false); locationName = location.Name; } @@ -177,8 +165,7 @@ private static AiHubResourceInfo FinishPickOrCreateAiHubResource(ICommandValues Console.WriteLine("\r*** CREATED *** "); - var parsed = !string.IsNullOrEmpty(json) ? JsonDocument.Parse(json) : default; - return parsed?.GetPropertyElementOrNull("resource"); + return JsonHelpers.DeserializePropertyValueOrDefault(json, "resource"); } } } diff --git a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreate_AiHubProject.cs b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreate_AiHubProject.cs index 212d6caa..5c699446 100644 --- a/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreate_AiHubProject.cs +++ b/src/common/details/ai_python_generative_sdk/AiSdkConsoleGui_PickOrCreate_AiHubProject.cs @@ -62,8 +62,8 @@ public static async Task ConfigAiHubProject( } else if (!string.IsNullOrEmpty(openai?.Name)) { - var (chatDeployment, embeddingsDeployment, evaluationDeployment, keys) = await AzCliConsoleGui.PickOrCreateAndConfigCognitiveServicesOpenAiKindResourceDeployments(values, "AZURE OPENAI RESOURCE", true, subscription, openai.Value); - openAiEndpoint = openai.Value.Endpoint; + var (chatDeployment, embeddingsDeployment, evaluationDeployment, keys) = await AzCliConsoleGui.PickOrCreateAndConfigCognitiveServicesOpenAiKindResourceDeployments(values, "AZURE OPENAI RESOURCE", true, subscription, openai); + openAiEndpoint = openai.Endpoint; openAiKey = keys.Key1; } else @@ -75,9 +75,9 @@ public static async Task ConfigAiHubProject( if (!string.IsNullOrEmpty(search?.Name)) { - var keys = await AzCliConsoleGui.LoadSearchResourceKeys(subscription, search.Value); - ConfigSetHelpers.ConfigSearchResource(search.Value.Endpoint, keys.Key1); - searchEndpoint = search.Value.Endpoint; + var keys = await AzCliConsoleGui.LoadSearchResourceKeys(subscription, search); + ConfigSetHelpers.ConfigSearchResource(search.Endpoint, keys.Key1); + searchEndpoint = search.Endpoint; searchKey = keys.Key1; } else @@ -130,8 +130,7 @@ public static AiHubProjectInfo PickOrCreateAiHubProject(ICommandValues values, s public static AiHubProjectInfo CreateAiHubProject(ICommandValues values, string subscription, string resourceId) { - var project = TryCreateAiHubProjectInteractive(values, subscription, resourceId); - return AiHubProjectInfoFromToken(values, project); + return TryCreateAiHubProjectInteractive(values, subscription, resourceId); } private static AiHubProjectInfo PickOrCreateAiHubProject(bool allowCreate, ICommandValues values, string subscription, string resourceId, out bool createNew) @@ -142,36 +141,23 @@ private static AiHubProjectInfo PickOrCreateAiHubProject(bool allowCreate, IComm var json = PythonSDKWrapper.ListProjects(values, subscription); if (Program.Debug) Console.WriteLine(json); - var parsed = !string.IsNullOrEmpty(json) ? JsonDocument.Parse(json) : default; - var items = parsed?.GetPropertyArrayOrEmpty("projects") ?? Array.Empty(); + var items = JsonHelpers.DeserializePropertyValueOrDefault>(json, "projects") + ?.Where(proj => string.IsNullOrEmpty(resourceId) || proj.HubId == resourceId) + .OrderBy(item => item.DisplayName + " " + item.Name) + .ThenBy(item => item.RegionLocation) + .ToArray() + ?? Array.Empty(); var choices = new List(); - var itemJsonElements = new List(); - foreach (var item in items) - { - if (item.TryGetProperty("workspace_hub", out var workspaceHubElement)) - { - var hub = workspaceHubElement.GetString(); - var hubOk = string.IsNullOrEmpty(resourceId) || hub == resourceId; - if (!hubOk) continue; - - itemJsonElements.Add(item); - - var name = item.GetProperty("name").GetString(); - var location = item.GetProperty("location").GetString(); - var displayName = item.GetProperty("display_name").GetString(); - - choices.Add(string.IsNullOrEmpty(displayName) - ? $"{name} ({location})" - : $"{displayName} ({location})"); - } - } - if (allowCreate) { - choices.Insert(0, "(Create new)"); + choices.Add("(Create new)"); } + choices.AddRange(items + .Select(proj => + $"{(string.IsNullOrEmpty(proj.DisplayName) ? proj.Name : proj.DisplayName)} ({proj.RegionLocation})")); + if (choices.Count == 0) { throw new ApplicationException($"CANCELED: No projects found"); @@ -185,20 +171,27 @@ private static AiHubProjectInfo PickOrCreateAiHubProject(bool allowCreate, IComm } Console.WriteLine($"\rName: {choices[picked]}"); - var project = allowCreate - ? (picked > 0 ? itemJsonElements[picked - 1] : default(JsonElement?)) - : itemJsonElements[picked]; - createNew = allowCreate && picked == 0; - if (createNew) + createNew = false; + AiHubProjectInfo project; + if (allowCreate && picked == 0) { + createNew = true; project = TryCreateAiHubProjectInteractive(values, subscription, resourceId); } + else if (allowCreate && picked > 0) + { + project = items[picked - 1]; + } + else + { + project = items[picked]; + } - return AiHubProjectInfoFromToken(values, project); + return project; } - private static JsonElement? TryCreateAiHubProjectInteractive(ICommandValues values, string subscription, string resourceId) + private static AiHubProjectInfo TryCreateAiHubProjectInteractive(ICommandValues values, string subscription, string resourceId) { var group = ResourceGroupNameToken.Data().GetOrDefault(values); var location = RegionLocationToken.Data().GetOrDefault(values, ""); @@ -211,22 +204,7 @@ private static AiHubProjectInfo PickOrCreateAiHubProject(bool allowCreate, IComm return TryCreateAiHubProjectInteractive(values, subscription, resourceId, group, location, ref displayName, ref description, smartName, smartNameKind); } - private static AiHubProjectInfo AiHubProjectInfoFromToken(ICommandValues values, JsonElement? project) - { - var aiHubProject = new AiHubProjectInfo - { - Id = project?.GetPropertyStringOrNull("id"), - Group = project?.GetPropertyStringOrNull("resource_group"), - Name = project?.GetPropertyStringOrNull("name"), - DisplayName = project?.GetPropertyStringOrNull("display_name"), - RegionLocation = project?.GetPropertyStringOrNull("location"), - HubId = project?.GetPropertyStringOrNull("workspace_hub"), - }; - - return aiHubProject; - } - - private static JsonElement? TryCreateAiHubProjectInteractive(ICommandValues values, string subscription, string resourceId, string group, string location, ref string displayName, ref string description, string smartName = null, string smartNameKind = null) + private static AiHubProjectInfo TryCreateAiHubProjectInteractive(ICommandValues values, string subscription, string resourceId, string group, string location, ref string displayName, ref string description, string smartName = null, string smartNameKind = null) { ConsoleHelpers.WriteLineWithHighlight($"\n`CREATE AZURE AI PROJECT`"); @@ -245,8 +223,7 @@ private static AiHubProjectInfo AiHubProjectInfoFromToken(ICommandValues values, Console.WriteLine("\r*** CREATED *** "); - var parsed = !string.IsNullOrEmpty(json) ? JsonDocument.Parse(json) : null; - return parsed?.GetPropertyElementOrNull("projects"); + return JsonHelpers.DeserializePropertyValueOrDefault(json, "project"); } public static void GetOrCreateAiHubProjectConnections(ICommandValues values, bool create, string subscription, string groupName, string projectName, string openAiEndpoint, string openAiKey, string searchEndpoint, string searchKey) diff --git a/src/common/details/ai_python_generative_sdk/python_sdk_wrapper.cs b/src/common/details/ai_python_generative_sdk/python_sdk_wrapper.cs index c2645699..3e756daf 100644 --- a/src/common/details/ai_python_generative_sdk/python_sdk_wrapper.cs +++ b/src/common/details/ai_python_generative_sdk/python_sdk_wrapper.cs @@ -210,17 +210,21 @@ private static string DoGetConnectionViaPython(ICommandValues values, string sub private static void CreateResourceGroup(ICommandValues values, string subscription, string location, string group) { + // TODO FIXME: This method should be made async + var quiet = values.GetOrDefault("x.quiet", false); var message = $"Creating resource group '{group}'..."; if (!quiet) Console.WriteLine(message); - var response = AzCli.CreateResourceGroup(subscription, location, group).Result; - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) + var response = Program.SubscriptionClient.CreateResourceGroupAsync(subscription, location, group, Program.CancelToken) + .Result; + + if (response.IsError) { values.AddThrowError( "ERROR:", "Creating resource group", - "OUTPUT:", response.Output.StdError); + "OUTPUT:", response.ErrorDetails); } if (!quiet) Console.WriteLine($"{message} Done!"); diff --git a/src/common/details/azcli/AzCli.cs b/src/common/details/azcli/AzCli.cs index 4b2a5668..9f6695a7 100644 --- a/src/common/details/azcli/AzCli.cs +++ b/src/common/details/azcli/AzCli.cs @@ -3,49 +3,10 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Azure.AI.Details.Common.CLI; -using System.Collections.Generic; - namespace Azure.AI.Details.Common.CLI { - public class AzCli + namespace AzCli { - public struct SubscriptionInfo - { - public string Id; - public string Name; - public string UserName; - public bool IsDefault; - } - - public struct AccountRegionLocationInfo - { - public string Name; - public string DisplayName; - public string RegionalDisplayName; - } - - public struct ResourceGroupInfo - { - public string Id; - public string Name; - public string RegionLocation; - } - - public struct CognitiveServicesResourceInfo - { - public string Id; - public string Group; - public string Name; - public string Kind; - public string RegionLocation; - public string Endpoint; - } - public struct CognitiveServicesResourceInfoEx { public string Id; @@ -61,71 +22,12 @@ public struct CognitiveServicesResourceInfoEx public string EvaluationDeployment; } - public struct CognitiveServicesSpeechResourceInfo - { - public string Id; - public string Group; - public string Name; - public string Kind; - public string RegionLocation; - public string Endpoint; - - public string Key; - } - - public struct CognitiveServicesVisionResourceInfo - { - public string Id; - public string Group; - public string Name; - public string Kind; - public string RegionLocation; - public string Endpoint; - - public string Key; - } - - public struct CognitiveServicesKeyInfo + public struct ResourceKeyInfo { public string Key1; public string Key2; } - public struct CognitiveServicesDeploymentInfo - { - public string Name { get; set; } - public string ModelFormat { get; set; } - public string ModelName { get; set; } - public bool ChatCompletionCapable { get; set; } - public bool EmbeddingsCapable { get; set; } - } - - public struct CognitiveServicesModelInfo - { - public string Name { get; set; } - public string Format { get; set; } - public string Version { get; set; } - public string DefaultCapacity { get; set; } - public bool ChatCompletionCapable { get; set; } - public bool EmbeddingsCapable { get; set; } - } - - public struct CognitiveServicesUsageInfo - { - public string Name { get; set; } - public string Current { get; set; } - public string Limit { get; set; } - } - - public struct CognitiveSearchResourceInfo - { - public string Id; - public string Group; - public string Name; - public string RegionLocation; - public string Endpoint; - } - public struct CognitiveSearchResourceInfoEx { public string Id; @@ -135,416 +37,20 @@ public struct CognitiveSearchResourceInfoEx public string Endpoint; public string Key; } + } - public struct CognitiveSearchKeyInfo - { - public string Key1; - public string Key2; - } - - private static Dictionary GetUserAgentEnv() + public static class LegacyAzCli + { + public static Dictionary GetUserAgentEnv() { var dict = new Dictionary(); dict.Add("AZURE_HTTP_USER_AGENT", Program.TelemetryUserAgent); return dict; } - - public static async Task> Login(bool useDeviceCode = false) - { - var showDeviceCodeMessage = (string message) => { - if (message.Contains("device") && message.Contains("code")) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(message); - Console.WriteLine(); - Console.ResetColor(); - } - }; - - var stdErrHandler = useDeviceCode ? showDeviceCodeMessage : null; - var deviceCodePart = useDeviceCode ? "--use-device-code" : ""; - var queryPart = $"--query \"[?state=='Enabled'].{{Name:name,Id:id,IsDefault:isDefault,UserName:user.name}}\""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"login --output json {queryPart} {deviceCodePart}", GetUserAgentEnv(), null, stdErrHandler); - var accounts = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new SubscriptionInfo[accounts.GetArrayLength()]; - - var i = 0; - foreach (var account in accounts.EnumerateArray()) - { - x.Payload[i].Id = account.GetPropertyStringOrEmpty("Id"); - x.Payload[i].Name = account.GetPropertyStringOrEmpty("Name"); - x.Payload[i].IsDefault = account.GetPropertyBool("IsDefault", false); - x.Payload[i].UserName = account.GetPropertyStringOrEmpty("UserName"); - i++; - } - - return x; - } - - public static async Task> ListAccounts() - { - var parsed = await ProcessHelpers.ParseShellCommandJson("az", "account list --refresh --output json --query \"[?state=='Enabled'].{Name:name,Id:id,IsDefault:isDefault,UserName:user.name}\"", GetUserAgentEnv()); - var accounts = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new SubscriptionInfo[accounts.GetArrayLength()]; - - var i = 0; - foreach (var account in accounts.EnumerateArray()) - { - x.Payload[i].Id = account.GetPropertyStringOrEmpty("Id"); - x.Payload[i].Name = account.GetPropertyStringOrEmpty("Name"); - x.Payload[i].IsDefault = account.GetPropertyBool("IsDefault", false); - x.Payload[i].UserName = account.GetPropertyStringOrEmpty("UserName"); - i++; - } - - return x; - } - - public static async Task> SetAccount(string subscriptionId) - { - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"account set --output json --subscription {subscriptionId}", GetUserAgentEnv()); - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = subscriptionId; - - return x; - } - - public static async Task> ListAccountRegionLocations() - { - var supportedRegions = await ListSupportedResourceRegions(); - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", "account list-locations --output json --query \"[].{Name:name,RegionalDisplayName:regionalDisplayName,DisplayName:displayName}\"", GetUserAgentEnv()); - var regionLocations = parsed.Payload; - - var list = new List(); - foreach (var regionLocation in regionLocations.EnumerateArray()) - { - if (supportedRegions.Count == 0 || supportedRegions.Contains(regionLocation.GetPropertyStringOrEmpty("Name").ToLower())) - { - list.Add(new AccountRegionLocationInfo() - { - Name = regionLocation.GetPropertyStringOrEmpty("Name"), - DisplayName = regionLocation.GetPropertyStringOrEmpty("DisplayName"), - RegionalDisplayName = regionLocation.GetPropertyStringOrEmpty("RegionalDisplayName") - }); - } - } - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = list.ToArray(); - - return x; - } - - public static async Task> ListResourceGroups(string subscriptionId = null, string regionLocation = null) - { - var cmdPart = "group list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - var queryPart1 = $"--query \"["; - var queryPart2 = regionLocation != null ? $"? location=='{regionLocation}'" : ""; - var queryPart3 = $"].{{Id:id,Name:name,Location:location}}\""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} {queryPart1}{queryPart2}{queryPart3}", GetUserAgentEnv()); - var groups = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new ResourceGroupInfo[groups.GetArrayLength()]; - - var i = 0; - foreach (var resource in groups.EnumerateArray()) - { - x.Payload[i].Id = resource.GetPropertyStringOrEmpty("Id"); - x.Payload[i].Name = resource.GetPropertyStringOrEmpty("Name"); - x.Payload[i].RegionLocation = resource.GetPropertyStringOrEmpty("Location"); - i++; - } - - return x; - } - - public static async Task> ListCognitiveServicesResources(string subscriptionId = null, string kinds = null) - { - var cmdPart = "cognitiveservices account list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - var groupPart = "--resource-group \"\""; - - var lookForKind = kinds.Split(';').First(); - var condPart= lookForKind switch - { - "OpenAI" => "? kind == 'OpenAI' || kind == 'AIServices'", - "ComputerVision" => "? kind == 'ComputerVision' || kind == 'CognitiveServices' || kind == 'AIServices'", - "SpeechServices" => "? kind == 'SpeechServices' || kind == 'CognitiveServices' || kind == 'AIServices'", - - "AIServices" => $"? kind == '{lookForKind}'", - "CognitiveServices" => $"? kind == '{lookForKind}'", - - _ => kinds != null ? $"? kind == '{lookForKind}' || kind == 'CognitiveServices' || kind == 'AIServices'" : null - }; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} {groupPart} --query \"[{condPart}].{{Id:id,Name:name,Location: location,Kind:kind,Group:resourceGroup,Endpoint:properties.endpoint}}\"", GetUserAgentEnv()); - var resources = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesResourceInfo[resources.GetArrayLength()]; - - var i = 0; - foreach (var resource in resources.EnumerateArray()) - { - x.Payload[i].Id = resource.GetPropertyStringOrEmpty("Id"); - x.Payload[i].Group = resource.GetPropertyStringOrEmpty("Group"); - x.Payload[i].Name = resource.GetPropertyStringOrEmpty("Name"); - x.Payload[i].Kind = resource.GetPropertyStringOrEmpty("Kind"); - x.Payload[i].RegionLocation = resource.GetPropertyStringOrEmpty("Location"); - x.Payload[i].Endpoint = resource.GetPropertyStringOrEmpty("Endpoint"); - i++; - } - - return x; - } - - public static async Task> ListCognitiveServicesDeployments(string subscriptionId = null, string group = null, string resourceName = null, string modelFormat = null) - { - var cmdPart = "cognitiveservices account deployment list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -g {group} -n {resourceName} --query \"[].{{Name:name,Location: location,Group:resourceGroup,Endpoint:properties.endpoint,Model:properties.model.name,Format:properties.model.format,ChatCompletionCapable:properties.capabilities.chatCompletion,EmbeddingsCapable:properties.capabilities.embeddings}}\"", GetUserAgentEnv()); - var deployments = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesDeploymentInfo[deployments.GetArrayLength()]; - - var i = 0; - foreach (var deployment in deployments.EnumerateArray()) - { - x.Payload[i].Name = deployment.GetPropertyStringOrEmpty("Name"); - x.Payload[i].ModelFormat = deployment.GetPropertyStringOrEmpty("Format"); - x.Payload[i].ModelName = deployment.GetPropertyStringOrEmpty("Model"); - x.Payload[i].ChatCompletionCapable = deployment.GetPropertyStringOrEmpty("ChatCompletionCapable") == "true"; - x.Payload[i].EmbeddingsCapable = deployment.GetPropertyStringOrEmpty("EmbeddingsCapable") == "true"; - i++; - } - - return x; - } - - public static async Task> ListCognitiveServicesModels(string subscriptionId = null, string regionLocation = null) - { - var cmdPart = "cognitiveservices model list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -l {regionLocation} --query \"[].{{Name:model.name,Format:model.format,Version:model.version,DefaultCapacity:model.skus[0].capacity.default,ChatCompletionCapable:model.capabilities.chatCompletion,EmbeddingsCapable:model.capabilities.embeddings}}\"", GetUserAgentEnv()); - var models = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesModelInfo[models.GetArrayLength()]; - - var i = 0; - foreach (var model in models.EnumerateArray()) - { - x.Payload[i].Name = model.GetPropertyStringOrEmpty("Name"); - x.Payload[i].Format = model.GetPropertyStringOrEmpty("Format"); - x.Payload[i].Version = model.GetPropertyStringOrEmpty("Version"); - x.Payload[i].DefaultCapacity = model.GetPropertyStringOrEmpty("DefaultCapacity"); - x.Payload[i].ChatCompletionCapable = model.GetPropertyStringOrEmpty("ChatCompletionCapable") == "true"; - x.Payload[i].EmbeddingsCapable = model.GetPropertyStringOrEmpty("EmbeddingsCapable") == "true"; - i++; - } - - return x; - } - - public static async Task> ListCognitiveServicesUsage(string subscriptionId = null, string regionLocation = null) - { - var cmdPart = "cognitiveservices usage list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -l {regionLocation} --query \"[].{{Name:name.value,Current:currentValue,Limit:limit}}\"", GetUserAgentEnv()); - var models = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesUsageInfo[models.GetArrayLength()]; - - var i = 0; - foreach (var model in models.EnumerateArray()) - { - x.Payload[i].Name = model.GetPropertyStringOrEmpty("Name"); - x.Payload[i].Current = model.GetPropertyStringOrEmpty("Current"); - x.Payload[i].Limit = model.GetPropertyStringOrEmpty("Limit"); - i++; - } - - return x; - } - - public static async Task> CreateCognitiveServicesResource(string subscriptionId, string group, string regionLocation, string name, string kinds = "AIServices", string sku = "F0") - { - var cmdPart = "cognitiveservices account create"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var createKind = kinds.Split(';').Last(); - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} --kind {createKind} --location {regionLocation} --sku {sku} -g {group} -n {name} --custom-domain {name}", GetUserAgentEnv()); - var resource = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesResourceInfo() - { - Id = resource.GetPropertyStringOrEmpty("id"), - Group = resource.GetPropertyStringOrEmpty("resourceGroup"), - Name = resource.GetPropertyStringOrEmpty("name"), - Kind = resource.GetPropertyStringOrEmpty("kind"), - RegionLocation = resource.GetPropertyStringOrEmpty("location"), - Endpoint = resource.GetPropertyElementOrNull("properties")?.GetPropertyStringOrEmpty("endpoint") - }; - - return x; - } - public static async Task> CreateResourceGroup(string subscriptionId, string regionLocation, string name) + public static async Task SetAccount(string subscriptionId) { - var cmdPart = "group create"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -l {regionLocation} -n {name}", GetUserAgentEnv()); - var resource = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new ResourceGroupInfo() - { - Id = resource.GetPropertyStringOrEmpty("id"), - Name = resource.GetPropertyStringOrEmpty("name"), - RegionLocation = resource.GetPropertyStringOrEmpty("location") - }; - - return x; + await ProcessHelpers.RunShellCommandAsync("az", $"account set --output json --subscription {subscriptionId}", GetUserAgentEnv()); } - - public static async Task> CreateCognitiveServicesDeployment(string subscriptionId, string group, string resourceName, string deploymentName, string modelName, string modelVersion, string modelFormat, string scaleCapacity) - { - var cmdPart = "cognitiveservices account deployment create"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -g {group} -n {resourceName} --deployment-name {deploymentName} --model-name {modelName} --model-version {modelVersion} --model-format {modelFormat} --sku-capacity {scaleCapacity} --sku-name \"Standard\"", GetUserAgentEnv()); - var resource = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesDeploymentInfo() - { - Name = resource.GetPropertyStringOrEmpty("name"), - ModelFormat = resource.GetPropertyStringOrEmpty("kind"), - ModelName = modelName, - ChatCompletionCapable = resource.GetPropertyElementOrNull("properties")?.GetPropertyElementOrNull("capabilities")?.GetPropertyBool("chatCompletion", false) ?? false, - EmbeddingsCapable = resource.GetPropertyElementOrNull("properties")?.GetPropertyElementOrNull("capabilities")?.GetPropertyBool("embeddings", false) ?? false - }; - - return x; - } - - public static async Task> ListCognitiveServicesKeys(string subscriptionId, string group, string name) - { - var cmdPart = "cognitiveservices account keys list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -g {group} -n {name}", GetUserAgentEnv()); - var keys = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveServicesKeyInfo() - { - Key1 = keys.GetPropertyStringOrEmpty("key1"), - Key2 = keys.GetPropertyStringOrEmpty("key2") - }; - - return x; - } - - public static async Task> CreateSearchResource(string subscriptionId, string group, string regionLocation, string name, string sku = "Standard") - { - var cmdPart = "search service create"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -g {group} -l {regionLocation} -n {name} --sku {sku}", GetUserAgentEnv()); - var resource = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveSearchResourceInfo() - { - Id = resource.GetPropertyStringOrEmpty("id"), - Name = name, - Group = resource.GetPropertyStringOrEmpty("resourceGroup"), - RegionLocation = resource.GetPropertyStringOrEmpty("location"), - Endpoint = $"https://{name}.search.windows.net" // TODO: Need to find official way of getting this - }; - - return x; - } - - public static async Task> ListSearchAdminKeys(string subscriptionId, string group, string name) - { - var cmdPart = "search admin-key show"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} -g {group} --service-name {name}", GetUserAgentEnv()); - var keys = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveSearchKeyInfo() - { - Key1 = keys.GetPropertyStringOrEmpty("primaryKey"), - Key2 = keys.GetPropertyStringOrEmpty("secondaryKey") - }; - - return x; - } - - public static async Task> ListSearchResources(string subscriptionId, string regionLocation) - { - var cmdPart = "resource list"; - var subPart = subscriptionId != null ? $"--subscription {subscriptionId}" : ""; - var groupPart = "--resource-group \"\""; - - var queryPart1 = string.IsNullOrEmpty(regionLocation) ? "" : $"--location {regionLocation}"; - var queryPart2 = "--query \"[].{Name:name,Id:id,Group:resourceGroup,Location:location}\""; - - var parsed = await ProcessHelpers.ParseShellCommandJson("az", $"{cmdPart} --output json {subPart} {groupPart} {queryPart1} {queryPart2} --resource-type Microsoft.Search/searchServices", GetUserAgentEnv()); - var groups = parsed.Payload; - - var x = new ParsedJsonProcessOutput(parsed.Output); - x.Payload = new CognitiveSearchResourceInfo[groups.GetArrayLength()]; - - var i = 0; - foreach (var resource in groups.EnumerateArray()) - { - x.Payload[i].Id = resource.GetPropertyStringOrEmpty("Id"); - x.Payload[i].Name = resource.GetPropertyStringOrEmpty("Name"); - x.Payload[i].Group = resource.GetPropertyStringOrEmpty("Group"); - x.Payload[i].RegionLocation = resource.GetPropertyStringOrEmpty("Location"); - x.Payload[i].Endpoint = $"https://{x.Payload[i].Name}.search.windows.net"; // TODO: Need to find official way of getting this - i++; - } - - return x; - } - - private static async Task> ListSupportedResourceRegions() - { - // TODO: What kind should we use here? - var process2 = await ProcessHelpers.ParseShellCommandJson("az", $"cognitiveservices account list-skus --output json --kind {Program.CognitiveServiceResourceKind} --query \"[].{{Name:locations[0]}}\"", GetUserAgentEnv()); - var supportedRegions = new List(); - foreach (var regionLocation in process2.Payload.EnumerateArray()) - { - supportedRegions.Add(regionLocation.GetPropertyStringOrEmpty("Name").ToLower()); - } - - return supportedRegions; - } - - } } diff --git a/src/common/details/azcli/AzCliConsoleGui.cs b/src/common/details/azcli/AzCliConsoleGui.cs index 2777782d..2b154901 100644 --- a/src/common/details/azcli/AzCliConsoleGui.cs +++ b/src/common/details/azcli/AzCliConsoleGui.cs @@ -18,32 +18,30 @@ namespace Azure.AI.Details.Common.CLI { public partial class AzCliConsoleGui { - public static async Task LoadSearchResourceKeys(string subscriptionId, AzCli.CognitiveSearchResourceInfo resource) + public static async Task LoadSearchResourceKeys(string subscriptionId, AzCli.CognitiveSearchResourceInfo resource) { ConsoleHelpers.WriteLineWithHighlight($"\n`AI SEARCH RESOURCE KEYS`"); Console.Write("Keys: *** Loading ***"); - var keys = await AzCli.ListSearchAdminKeys(subscriptionId, resource.Group, resource.Name); + var response = await Program.SearchClient.GetKeysAsync(subscriptionId, resource.Group, resource.Name, Program.CancelToken); + var keys = new AzCli.ResourceKeyInfo(); + (keys.Key1, keys.Key2) = response.Value; Console.Write("\r"); - Console.WriteLine($"Key1: {keys.Payload.Key1.Substring(0, 4)}****************************"); - Console.WriteLine($"Key2: {keys.Payload.Key2.Substring(0, 4)}****************************"); - return keys.Payload; + Console.WriteLine($"Key1: {keys.Key1.Substring(0, 4)}****************************"); + Console.WriteLine($"Key2: {keys.Key2.Substring(0, 4)}****************************"); + return keys; } - public static async Task PickOrCreateCognitiveSearchResource(bool allowSkip, string subscription, string location, string groupName, string smartName = null, string smartNameKind = null) + public static async Task PickOrCreateCognitiveSearchResource(bool allowSkip, string subscription, string location, string groupName, string? smartName = null, string? smartNameKind = null) { ConsoleHelpers.WriteLineWithHighlight($"\n`AI SEARCH RESOURCE`"); Console.Write("\rName: *** Loading choices ***"); - var response = await AzCli.ListSearchResources(subscription, location); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - var output = response.Output.StdError.Replace("\n", "\n "); - throw new ApplicationException($"ERROR: Listing search resources\n {output}"); - } + var response = await Program.SearchClient.GetAllAsync(subscription, location, Program.CancelToken); + response.ThrowOnFail("Loading search resources"); - var resources = response.Payload.OrderBy(x => x.Name).ToList(); + var resources = response.Value.OrderBy(x => x.Name).ToList(); var choices = resources.Select(x => $"{x.Name} ({x.RegionLocation})").ToList(); choices.Insert(0, "(Create new)"); @@ -76,7 +74,7 @@ public partial class AzCliConsoleGui return resource; } - private static async Task TryCreateSearchInteractive(string subscription, string locationName, string groupName, string smartName = null, string smartNameKind = null) + private static async Task TryCreateSearchInteractive(string subscription, string locationName, string groupName, string? smartName = null, string? smartNameKind = null) { var sectionHeader = "\n`CREATE SEARCH RESOURCE`"; ConsoleHelpers.WriteLineWithHighlight(sectionHeader); @@ -84,7 +82,7 @@ public partial class AzCliConsoleGui var groupOk = !string.IsNullOrEmpty(groupName); if (!groupOk) { - var location = await AzCliConsoleGui.PickRegionLocationAsync(true, locationName, false); + var location = await AzCliConsoleGui.PickRegionLocationAsync(true, subscription, locationName, false); locationName = location.Name; } @@ -105,17 +103,13 @@ public partial class AzCliConsoleGui var name = NamePickerHelper.DemandPickOrEnterName("Name: ", "search", smartName, smartNameKind, AzCliConsoleGui.GetSubscriptionUserName(subscription)); Console.Write("*** CREATING ***"); - var response = await AzCli.CreateSearchResource(subscription, groupName, locationName, name); + var response = await Program.SearchClient.CreateAsync(subscription, groupName, locationName, name ?? string.Empty, Program.CancelToken); Console.Write("\r"); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - var output = response.Output.StdError.Replace("\n", "\n "); - throw new ApplicationException($"ERROR: Creating resource:\n\n {output}"); - } + response.ThrowOnFail("Creating search resource"); Console.WriteLine("\r*** CREATED *** "); - return response.Payload; + return response.Value; } } } diff --git a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs index 4616b047..233be047 100644 --- a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // +using System.Globalization; using Azure.AI.Details.Common.CLI.ConsoleGui; namespace Azure.AI.Details.Common.CLI @@ -40,13 +41,14 @@ public partial class AzCliConsoleGui { var allowCreateDeployment = !string.IsNullOrEmpty(allowCreateDeploymentOption); - var listDeploymentsFunc = async () => await AzCli.ListCognitiveServicesDeployments(subscriptionId, groupName, resourceName, "OpenAI"); - var response = await LoginHelpers.GetResponseOnLogin(interactive, "deployment", listDeploymentsFunc); + var listDeploymentsFunc = () => Program.CognitiveServicesClient.GetAllDeploymentsAsync(subscriptionId, groupName, resourceName, Program.CancelToken); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listDeploymentsFunc, Program.CancelToken); + response.ThrowOnFail("Loading Cognitive Services deployments"); var lookForChatCompletionCapable = deploymentExtra.ToLower() == "chat" || deploymentExtra.ToLower() == "evaluation"; var lookForEmbeddingCapable = deploymentExtra.ToLower() == "embeddings"; - var deployments = response.Payload + var deployments = response.Value .Where(x => MatchDeploymentFilter(x, deploymentFilter)) .Where(x => !lookForChatCompletionCapable || x.ChatCompletionCapable) .Where(x => !lookForEmbeddingCapable || x.EmbeddingsCapable) @@ -113,7 +115,7 @@ public partial class AzCliConsoleGui var modelFormat = "OpenAI"; var modelVersion = deployableModel?.Version; - var scaleCapacity = deployableModel?.DefaultCapacity; + var scaleCapacity = deployableModel?.DefaultCapacity ?? 0; Console.Write("\rName: "); if (!string.IsNullOrEmpty(deploymentName)) @@ -147,49 +149,40 @@ public partial class AzCliConsoleGui if (string.IsNullOrEmpty(deploymentName)) return null; Console.Write("*** CREATING ***"); - var response = await AzCli.CreateCognitiveServicesDeployment(subscriptionId, groupName, resourceName, deploymentName, modelName, modelVersion, modelFormat, scaleCapacity); + var response = await Program.CognitiveServicesClient.CreateDeploymentAsync(subscriptionId, groupName, resourceName, deploymentName, modelName, modelVersion, modelFormat, scaleCapacity.ToString(CultureInfo.InvariantCulture), Program.CancelToken); Console.Write("\r"); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - throw new ApplicationException($"ERROR: Creating deployment: {response.Output.StdError}"); - } + response.ThrowOnFail("Creating deployment"); Console.WriteLine("\r*** CREATED *** "); - return response.Payload; + return response.Value; } private static async Task FindDeployableModel(bool interactive, string deploymentExtra, string subscriptionId, string resourceRegionLocation, string modelFilter) { Console.Write("\rModel: *** Loading choices ***"); - var models = await AzCli.ListCognitiveServicesModels(subscriptionId, resourceRegionLocation); - var usage = await AzCli.ListCognitiveServicesUsage(subscriptionId, resourceRegionLocation); + var models = await Program.CognitiveServicesClient.GetAllModelsAsync(subscriptionId, resourceRegionLocation, Program.CancelToken); + var usage = await Program.CognitiveServicesClient.GetAllModelUsageAsync(subscriptionId, resourceRegionLocation, Program.CancelToken); - if (string.IsNullOrEmpty(models.Output.StdOutput) && !string.IsNullOrEmpty(models.Output.StdError)) - { - throw new ApplicationException($"ERROR: Loading models\n{models.Output.StdError}"); - } - else if (string.IsNullOrEmpty(usage.Output.StdOutput) && !string.IsNullOrEmpty(usage.Output.StdError)) - { - throw new ApplicationException($"ERROR: Loading model usage\n{usage.Output.StdError}"); - } + models.ThrowOnFail("Loading models"); + usage.ThrowOnFail("Loading model usage"); var lookForChatCompletionCapable = deploymentExtra.ToLower() == "chat" || deploymentExtra.ToLower() == "evaluation"; var lookForEmbeddingCapable = deploymentExtra.ToLower() == "embeddings"; - var capableModels = models.Payload - .Where(x => !lookForChatCompletionCapable || x.ChatCompletionCapable) - .Where(x => !lookForEmbeddingCapable || x.EmbeddingsCapable) + var capableModels = models.Value + .Where(x => !lookForChatCompletionCapable || x.IsChatCapable) + .Where(x => !lookForEmbeddingCapable || x.IsEmbeddingsCapable) .ToArray(); Console.Write("\rModel: "); - var deployableModels = FilterModelsByUsage(capableModels, usage.Payload); + var deployableModels = FilterModelsByUsage(capableModels, usage.Value); var exactMatch = modelFilter != null && deployableModels.Count(x => ExactMatchModel(x, modelFilter)) == 1; if (exactMatch) deployableModels = deployableModels.Where(x => ExactMatchModel(x, modelFilter)).ToArray(); if (deployableModels.Count() == 0) { - ConsoleHelpers.WriteLineError(models.Payload.Count() > 0 + ConsoleHelpers.WriteLineError(models.Value.Count() > 0 ? $"*** No matching {deploymentExtra} capable models with capacity found ***" : "*** No deployable models found ***"); return null; @@ -241,7 +234,6 @@ private static void DisplayNameAndVersion(AzCli.CognitiveServicesModelInfo deplo Console.WriteLine($"{deployableModel.Name}-{deployableModel.Version}"); } - private static bool ExactMatchModel(AzCli.CognitiveServicesModelInfo model, string modelFilter) { var displayName = model.Name + "-" + model.Version; @@ -265,18 +257,11 @@ private static AzCli.CognitiveServicesModelInfo[] FilterModelsByUsage(AzCli.Cogn var filteredKeep = new List(); foreach (var model in models) { - if (!double.TryParse(model.DefaultCapacity, out var defaultCapacityValue)) - { - defaultCapacityValue = 1; - } + var defaultCapacityValue = (float)model.DefaultCapacity; - var checkUsage = usage.Where(x => x.Name.EndsWith(model.Name)); - var current = checkUsage.Count() > 0 - ? checkUsage.Sum(x => double.TryParse(x.Current, out var value) ? value : 0) - : 0; - var limit = checkUsage.Count() > 0 - ? checkUsage.Sum(x => double.TryParse(x.Limit, out var value) ? value : 0) - : 1; + var checkUsage = usage.Where(x => x.Name.Value.EndsWith(model.Name)); + var current = checkUsage.Sum(x => x.CurrentValue); + var limit = checkUsage.Sum(x => x.Limit); var available = limit - current; if (available <= 0) continue; @@ -290,15 +275,7 @@ private static AzCli.CognitiveServicesModelInfo[] FilterModelsByUsage(AzCli.Cogn var newDefault = available - 1; if (newDefault < 1) newDefault = 1; - filteredKeep.Add(new AzCli.CognitiveServicesModelInfo() - { - Name = model.Name, - Version = model.Version, - Format = model.Format, - DefaultCapacity = newDefault.ToString(), - ChatCompletionCapable = model.ChatCompletionCapable, - EmbeddingsCapable = model.EmbeddingsCapable - }); + filteredKeep.Add(model); } if (filteredKeep.Count() <= models.Count()) diff --git a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs index 518c0783..8f6d670c 100644 --- a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs @@ -13,12 +13,14 @@ using System.Collections.Generic; using System.Net; using Azure.AI.Details.Common.CLI.ConsoleGui; +using Azure.AI.Details.Common.CLI.AzCli; +using Azure.AI.CLI.Common.Clients.Models.Utils; namespace Azure.AI.Details.Common.CLI { public partial class AzCliConsoleGui { - public static async Task PickOrCreateCognitiveResource(string sectionHeader, bool interactive, string subscriptionId = null, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = "F0", bool agreeTerms = false) + public static async Task PickOrCreateCognitiveResource(string sectionHeader, bool interactive, string subscriptionId, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = "F0", bool agreeTerms = false) { ConsoleHelpers.WriteLineWithHighlight($"\n`{sectionHeader}`"); @@ -27,7 +29,7 @@ public partial class AzCliConsoleGui : interactive ? "(Create new)" : null; var resource = await FindCognitiveServicesResource(interactive, subscriptionId, regionFilter, groupFilter, resourceFilter, kinds, createNewItem); - if (resource != null && resource.Value.Name == null) + if (resource != null && resource.Name == null) { resource = await TryCreateCognitiveServicesResource(sectionHeader, interactive, subscriptionId, regionFilter, groupFilter, resourceFilter, kinds, sku, agreeTerms); } @@ -37,30 +39,37 @@ public partial class AzCliConsoleGui throw new ApplicationException($"CANCELED: No resource selected"); } - return resource.Value; + return resource; } - public static async Task LoadCognitiveServicesResourceKeys(string sectionHeader, string subscriptionId, AzCli.CognitiveServicesResourceInfo resource) + public static async Task LoadCognitiveServicesResourceKeys(string sectionHeader, string subscriptionId, AzCli.CognitiveServicesResourceInfo resource) { ConsoleHelpers.WriteLineWithHighlight($"\n`{sectionHeader} KEYS`"); Console.Write("Keys: *** Loading ***"); - var keys = await AzCli.ListCognitiveServicesKeys(subscriptionId, resource.Group, resource.Name); + var keys = await Program.CognitiveServicesClient.GetResourceKeysFromNameAsync(subscriptionId, resource.Group, resource.Name, Program.CancelToken); + + var result = new ResourceKeyInfo(); + (result.Key1, result.Key2) = keys.Value; Console.Write("\r"); - Console.WriteLine($"Key1: {keys.Payload.Key1.Substring(0, 4)}****************************"); - Console.WriteLine($"Key2: {keys.Payload.Key2.Substring(0, 4)}****************************"); - return keys.Payload; + Console.WriteLine($"Key1: {result.Key1.Substring(0, 4)}****************************"); + Console.WriteLine($"Key2: {result.Key2.Substring(0, 4)}****************************"); + return result; } - public static async Task FindCognitiveServicesResource(bool interactive, string subscriptionId = null, string regionLocationFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string allowCreateResourceOption = null) + public static async Task FindCognitiveServicesResource(bool interactive, string subscriptionId, string regionLocationFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string allowCreateResourceOption = null) { var allowCreateResource = !string.IsNullOrEmpty(allowCreateResourceOption); - var listResourcesFunc = async () => await AzCli.ListCognitiveServicesResources(subscriptionId, kinds); - var response = await LoginHelpers.GetResponseOnLogin(interactive, "resource", listResourcesFunc); + // TODO FIXME: Switch to ResourceKind as flags instead of passing strings? + ResourceKind? kind = ResourceKindHelpers.ParseString(kinds, ';'); - var resources = response.Payload + var listResourcesFunc = () => Program.CognitiveServicesClient.GetAllResourcesAsync(subscriptionId, Program.CancelToken, kind); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listResourcesFunc, Program.CancelToken); + response.ThrowOnFail("Loading Cognitive Services resources"); + + var resources = response.Value .Where(x => MatchResourceFilter(x, regionLocationFilter, groupFilter, resourceFilter)) .OrderBy(x => x.Name + x.RegionLocation) .ToList(); @@ -100,28 +109,28 @@ public partial class AzCliConsoleGui : null; } - public static async Task TryCreateCognitiveServicesResource(string sectionHeader, bool interactive, string subscriptionId = null, string regionLocationFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = "F0", bool agreeTerms = false) + public static async Task TryCreateCognitiveServicesResource(string sectionHeader, bool interactive, string subscriptionId, string regionLocationFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = "F0", bool agreeTerms = false) { ConsoleHelpers.WriteLineWithHighlight("\n`RESOURCE GROUP`"); - var regionLocation = !string.IsNullOrEmpty(regionLocationFilter) ? await FindRegionAsync(interactive, regionLocationFilter, true) : new AzCli.AccountRegionLocationInfo(); + var regionLocation = !string.IsNullOrEmpty(regionLocationFilter) ? await FindRegionAsync(interactive, subscriptionId, regionLocationFilter, true) : new AzCli.AccountRegionLocationInfo(); if (regionLocation == null) return null; var (group, createdNew) = await PickOrCreateResourceGroup(interactive, subscriptionId, regionLocation?.Name, groupFilter); - var createKind = kinds.Split(';').Last(); + var createKind = ResourceKindHelpers.ParseString(kinds.Split(';').LastOrDefault()) ?? ResourceKind.Unknown; ConsoleHelpers.WriteLineWithHighlight($"\n`CREATE {sectionHeader}`"); - Console.WriteLine($"Region: {group.RegionLocation}"); + Console.WriteLine($"Region: {group.Region}"); Console.WriteLine($"Group: {group.Name}"); Console.WriteLine($"Kind: {createKind}"); var smartName = group.Name; var smartNameKind = "rg"; - var nameOutKind = createKind?.ToLower() switch + var nameOutKind = createKind switch { - "aiservices" => "ais", - "cognitiveservices" => "cs", - _ => createKind.ToLower() + ResourceKind.AIServices => "ais", + ResourceKind.CognitiveServices => "cs", + _ => createKind.ToString().ToLowerInvariant() }; var name = string.IsNullOrEmpty(resourceFilter) @@ -132,16 +141,13 @@ public partial class AzCliConsoleGui if (!agreeTerms && !CheckAgreeTerms(createKind)) return null; Console.Write("*** CREATING ***"); - var response = await AzCli.CreateCognitiveServicesResource(subscriptionId, group.Name, group.RegionLocation, name, createKind, sku); - + var response = await Program.CognitiveServicesClient.CreateResourceAsync( + createKind, subscriptionId, group.Name, group.Region, name, sku, Program.CancelToken); Console.Write("\r"); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - throw new ApplicationException($"ERROR: Creating Cognitive Services resource: {response.Output.StdError}"); - } + response.ThrowOnFail("Creating Cognitive Services resource"); Console.WriteLine("\r*** CREATED *** "); - return response.Payload; + return response.Value; } private static AzCli.CognitiveServicesResourceInfo? ListBoxPickCognitiveServicesResource(AzCli.CognitiveServicesResourceInfo[] resources, string p0) @@ -160,7 +166,7 @@ public partial class AzCliConsoleGui if (hasP0 && picked == 0) { Console.WriteLine(p0); - return new AzCli.CognitiveServicesResourceInfo(); + return new AzCli.CognitiveServicesResourceInfo() { Name = null! }; // Explicitly set name to null since this is how we detect "new" vs "canceled" (latter returns null) } if (hasP0) picked--; @@ -209,12 +215,12 @@ private static void DisplayName(AzCli.CognitiveServicesResourceInfo resource) Console.WriteLine(new string(' ', 20)); } - private static bool CheckAgreeTerms(string createKind) + private static bool CheckAgreeTerms(ResourceKind createKind) { - var checkAttestation = createKind.ToLower() switch + var checkAttestation = createKind switch { - "cognitiveservices" => true, - "face" => true, + ResourceKind.CognitiveServices => true, + ResourceKind.Vision => true, _ => false }; diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CogSearchResource.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CogSearchResource.cs index dc81182d..9a5f6687 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CogSearchResource.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CogSearchResource.cs @@ -23,17 +23,17 @@ public partial class AzCliConsoleGui var resource = await AzCliConsoleGui.PickOrCreateCognitiveSearchResource(allowSkip, subscription, location, groupName, smartName, smartNameKind); if (resource == null) return null; - var keys = await AzCliConsoleGui.LoadSearchResourceKeys(subscription, resource.Value); + var keys = await AzCliConsoleGui.LoadSearchResourceKeys(subscription, resource); - ConfigSetHelpers.ConfigSearchResource(resource.Value.Endpoint, keys.Key1); + ConfigSetHelpers.ConfigSearchResource(resource.Endpoint, keys.Key1); return new AzCli.CognitiveSearchResourceInfoEx { - Id = resource.Value.Id, - Group = resource.Value.Group, - Name = resource.Value.Name, - RegionLocation = resource.Value.RegionLocation, - Endpoint = resource.Value.Endpoint, + Id = resource.Id, + Group = resource.Group, + Name = resource.Name, + RegionLocation = resource.RegionLocation, + Endpoint = resource.Endpoint, Key = keys.Key1, }; } diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_AiServicesKind.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_AiServicesKind.cs index 55f8b706..5eadb064 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_AiServicesKind.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_AiServicesKind.cs @@ -23,7 +23,7 @@ public partial class AzCliConsoleGui kinds ??= "AIServices"; var sectionHeader = "AI SERVICES"; - var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, regionFilter) : new AzCli.AccountRegionLocationInfo(); + var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, subscriptionId, regionFilter) : new AzCli.AccountRegionLocationInfo(); var resource = await AzCliConsoleGui.PickOrCreateCognitiveResource(sectionHeader, interactive, subscriptionId, regionLocation.Name, groupFilter, resourceFilter, kinds, sku, yes); var (chatDeployment, embeddingsDeployment, evaluationDeployment, keys) = await PickOrCreateAndConfigCognitiveServicesOpenAiKindResourceDeployments(values, sectionHeader, interactive, subscriptionId, resource); @@ -33,7 +33,7 @@ public partial class AzCliConsoleGui Id = resource.Id, Group = resource.Group, Name = resource.Name, - Kind = resource.Kind, + Kind = resource.Kind.ToString(), RegionLocation = resource.RegionLocation, Endpoint = resource.Endpoint, Key = keys.Key1, diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_CognitiveServicesKind.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_CognitiveServicesKind.cs index 528f79f4..daed88ba 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_CognitiveServicesKind.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_CognitiveServicesKind.cs @@ -3,16 +3,7 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Net; -using Azure.AI.Details.Common.CLI.ConsoleGui; +using Azure.AI.CLI.Common.Clients.Models; namespace Azure.AI.Details.Common.CLI { @@ -23,7 +14,7 @@ public partial class AzCliConsoleGui kinds ??= "CognitiveServices"; var sectionHeader = "AI SERVICES (v1)"; - var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, regionFilter) : new AzCli.AccountRegionLocationInfo(); + var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, subscriptionId, regionFilter) : new AzCli.AccountRegionLocationInfo(); var resource = await AzCliConsoleGui.PickOrCreateCognitiveResource(sectionHeader, interactive, subscriptionId, regionLocation.Name, groupFilter, resourceFilter, kinds, sku, yes); var keys = await AzCliConsoleGui.LoadCognitiveServicesResourceKeys(sectionHeader, subscriptionId, resource); @@ -35,7 +26,7 @@ public partial class AzCliConsoleGui Id = resource.Id, Group = resource.Group, Name = resource.Name, - Kind = resource.Kind, + Kind = resource.Kind.ToString(), RegionLocation = resource.RegionLocation, Endpoint = resource.Endpoint, Key = keys.Key1, diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs index 0da8e2eb..896e12d7 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs @@ -43,10 +43,10 @@ public partial class AzCliConsoleGui string embeddingsModelFilter = null, string evaluationsModelFilter = null) { - kinds ??= "OpenAI;AIServices"; + kinds ??= "AIServices;OpenAI"; var sectionHeader = "AZURE OPENAI RESOURCE"; - var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, regionFilter) : new AzCli.AccountRegionLocationInfo(); + var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, subscriptionId, regionFilter) : new AzCli.AccountRegionLocationInfo(); var resource = await AzCliConsoleGui.PickOrCreateCognitiveResource(sectionHeader, interactive, subscriptionId, regionLocation.Name, groupFilter, resourceFilter, kinds, sku, yes); var (chatDeployment, embeddingsDeployment, evaluationDeployment, keys) = await PickOrCreateAndConfigCognitiveServicesOpenAiKindResourceDeployments( @@ -73,7 +73,7 @@ public partial class AzCliConsoleGui Id = resource.Id, Group = resource.Group, Name = resource.Name, - Kind = resource.Kind, + Kind = resource.Kind.ToString(), RegionLocation = resource.RegionLocation, Endpoint = resource.Endpoint, Key = keys.Key1, @@ -83,7 +83,7 @@ public partial class AzCliConsoleGui }; } - public static async Task<(AzCli.CognitiveServicesDeploymentInfo?, AzCli.CognitiveServicesDeploymentInfo?, AzCli.CognitiveServicesDeploymentInfo?, AzCli.CognitiveServicesKeyInfo)> + public static async Task<(AzCli.CognitiveServicesDeploymentInfo?, AzCli.CognitiveServicesDeploymentInfo?, AzCli.CognitiveServicesDeploymentInfo?, AzCli.ResourceKeyInfo)> PickOrCreateAndConfigCognitiveServicesOpenAiKindResourceDeployments( INamedValues values, string sectionHeader, @@ -127,7 +127,7 @@ public partial class AzCliConsoleGui var keys = await AzCliConsoleGui.LoadCognitiveServicesResourceKeys(sectionHeader, subscriptionId, resource); - if (resource.Kind == "AIServices") + if (resource.Kind == AzCli.ResourceKind.AIServices) { ConfigSetHelpers.ConfigCognitiveServicesAIServicesKindResource(subscriptionId, resource.RegionLocation, resource.Endpoint, chatDeployment, embeddingsDeployment, evaluationDeployment, keys.Key1); } diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_SpeechKind.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_SpeechKind.cs index dd513ff8..bb9ff929 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_SpeechKind.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_SpeechKind.cs @@ -18,7 +18,7 @@ namespace Azure.AI.Details.Common.CLI { public partial class AzCliConsoleGui { - public static async Task PickOrCreateAndConfigCognitiveServicesSpeechServicesKindResource(bool interactive, string subscriptionId, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = null, bool yes = false) + public static async Task PickOrCreateAndConfigCognitiveServicesSpeechServicesKindResource(bool interactive, string subscriptionId, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = null, bool yes = false) { kinds ??= "SpeechServices"; var sectionHeader = "SPEECH RESOURCE"; @@ -29,19 +29,11 @@ public partial class AzCliConsoleGui var keys = await AzCliConsoleGui.LoadCognitiveServicesResourceKeys(sectionHeader, subscriptionId, resource); ConfigSetHelpers.ConfigSpeechResource(subscriptionId, resource.RegionLocation, resource.Endpoint, keys.Key1); - - return new AzCli.CognitiveServicesSpeechResourceInfo - { - Id = resource.Id, - Group = resource.Group, - Name = resource.Name, - Kind = resource.Kind, - RegionLocation = resource.RegionLocation, - Endpoint = resource.Endpoint, - Key = keys.Key1, - }; + resource.Key = keys.Key1; + return resource; } - public static async Task PickOrCreateAndConfigCognitiveServicesComputerVisionKindResource(bool interactive, string subscriptionId, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = null, bool yes = false) + + public static async Task PickOrCreateAndConfigCognitiveServicesComputerVisionKindResource(bool interactive, string subscriptionId, string regionFilter = null, string groupFilter = null, string resourceFilter = null, string kinds = null, string sku = null, bool yes = false) { kinds ??= "ComputerVision"; var sectionHeader = "VISION RESOURCE"; @@ -52,17 +44,8 @@ public partial class AzCliConsoleGui var keys = await AzCliConsoleGui.LoadCognitiveServicesResourceKeys(sectionHeader, subscriptionId, resource); ConfigSetHelpers.ConfigVisionResource(subscriptionId, resource.RegionLocation, resource.Endpoint, keys.Key1); - - return new AzCli.CognitiveServicesVisionResourceInfo - { - Id = resource.Id, - Group = resource.Group, - Name = resource.Name, - Kind = resource.Kind, - RegionLocation = resource.RegionLocation, - Endpoint = resource.Endpoint, - Key = keys.Key1, - }; + resource.Key = keys.Key1; + return resource; } } } diff --git a/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs b/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs index 9a0cf6d4..2716c625 100644 --- a/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs @@ -3,24 +3,15 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Net; using Azure.AI.Details.Common.CLI.ConsoleGui; namespace Azure.AI.Details.Common.CLI { public partial class AzCliConsoleGui { - public static async Task PickRegionLocationAsync(bool interactive, string regionFilter = null, bool allowAnyRegionOption = true) + public static async Task PickRegionLocationAsync(bool interactive, string subscriptionId, string? regionFilter = null, bool allowAnyRegionOption = true) { - var regionLocation = await FindRegionAsync(interactive, regionFilter, allowAnyRegionOption); + var regionLocation = await FindRegionAsync(interactive, subscriptionId, regionFilter, allowAnyRegionOption); if (regionLocation == null) { throw new ApplicationException($"CANCELED: No resource region/location selected."); @@ -28,36 +19,35 @@ public partial class AzCliConsoleGui return regionLocation.Value; } - public static async Task FindRegionAsync(bool interactive, string regionFilter = null, bool allowAnyRegionOption = false) + public static async Task FindRegionAsync(bool interactive, string subscriptionId, string? regionFilter = null, bool allowAnyRegionOption = false) { var p0 = allowAnyRegionOption ? "(Any region/location)" : null; var hasP0 = !string.IsNullOrEmpty(p0); Console.Write("\rRegion: *** Loading choices ***"); - var response = await AzCli.ListAccountRegionLocations(); + var allRegions = await Program.SubscriptionClient.GetAllRegionsAsync(subscriptionId, Program.CancelToken); Console.Write("\rRegion: "); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - throw new ApplicationException($"ERROR: Loading resource region/locations\n{response.Output.StdError}"); - } + allRegions.ThrowOnFail("resource region/locations"); - var regions = response.Payload + var regions = allRegions.Value .Where(x => MatchRegionLocationFilter(x, regionFilter)) .OrderBy(x => x.RegionalDisplayName) - .ToList(); + .ToArray(); - var exactMatch = regionFilter != null && regions.Count(x => ExactMatchRegionLocation(x, regionFilter)) == 1; - if (exactMatch) regions = regions.Where(x => ExactMatchRegionLocation(x, regionFilter)).ToList(); + var exactMatches = regionFilter == null + ? Array.Empty() + : regions.Where(x => ExactMatchRegionLocation(x, regionFilter)).ToArray(); + if (exactMatches.Length == 1) regions = regions.Where(x => ExactMatchRegionLocation(x, regionFilter)).ToArray(); if (regions.Count() == 0) { - ConsoleHelpers.WriteLineError(response.Payload.Count() > 0 + ConsoleHelpers.WriteLineError(allRegions.Value.Count() > 0 ? "*** No matching resource region/locations found ***" : "*** No resource region/locations found ***"); return null; } - else if (regions.Count() == 1 && (!interactive || exactMatch)) + else if (regions.Count() == 1 && (!interactive || exactMatches.Length == 1)) { var region = regions.First(); DisplayNameAndDisplayName(region); @@ -76,7 +66,7 @@ public partial class AzCliConsoleGui : null; } - private static AzCli.AccountRegionLocationInfo? ListBoxPickAccountRegionLocation(AzCli.AccountRegionLocationInfo[] items, string p0) + private static AzCli.AccountRegionLocationInfo? ListBoxPickAccountRegionLocation(AzCli.AccountRegionLocationInfo[] items, string? p0) { var list = items.Select(x => x.RegionalDisplayName).ToList(); @@ -102,26 +92,26 @@ public partial class AzCliConsoleGui return regionLocation; } - static bool ExactMatchRegionLocation(AzCli.AccountRegionLocationInfo regionLocation, string regionLocationFilter) + static bool ExactMatchRegionLocation(AzCli.AccountRegionLocationInfo regionLocation, string? regionLocationFilter) { - return regionLocation.Name.ToLower() == regionLocationFilter || regionLocation.DisplayName == regionLocationFilter || regionLocation.RegionalDisplayName == regionLocationFilter; + return regionLocation.Name.ToLowerInvariant() == regionLocationFilter || regionLocation.DisplayName == regionLocationFilter || regionLocation.RegionalDisplayName == regionLocationFilter; } - private static bool MatchRegionLocationFilter(AzCli.AccountRegionLocationInfo regionLocation, string regionLocationFilter) + private static bool MatchRegionLocationFilter(AzCli.AccountRegionLocationInfo regionLocation, string? regionLocationFilter) { if (regionLocationFilter == null || ExactMatchRegionLocation(regionLocation, regionLocationFilter)) { return true; } - var displayName = regionLocation.DisplayName.ToLower(); - var regionalName = regionLocation.RegionalDisplayName.ToLower(); + var displayName = regionLocation.DisplayName.ToLowerInvariant(); + var regionalName = regionLocation.RegionalDisplayName.ToLowerInvariant(); return displayName.Contains(regionLocationFilter) || StringHelpers.ContainsAllCharsInOrder(displayName, regionLocationFilter) || regionalName.Contains(regionLocationFilter) || StringHelpers.ContainsAllCharsInOrder(regionalName, regionLocationFilter); } - private static void DisplayRegionLocations(List regionLocations, string prefix) + private static void DisplayRegionLocations(IList regionLocations, string prefix) { foreach (var regionLocation in regionLocations) { diff --git a/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs b/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs index ba464571..14df822a 100644 --- a/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs @@ -13,12 +13,13 @@ using System.Collections.Generic; using System.Net; using Azure.AI.Details.Common.CLI.ConsoleGui; +using Azure.AI.CLI.Common.Clients.Models; namespace Azure.AI.Details.Common.CLI { public partial class AzCliConsoleGui { - public static async Task<(AzCli.ResourceGroupInfo, bool createdNew)> PickOrCreateResourceGroup(bool interactive, string subscriptionId = null, string regionFilter = null, string groupFilter = null) + public static async Task<(AzCli.ResourceGroupInfo, bool createdNew)> PickOrCreateResourceGroup(bool interactive, string subscriptionId, string? regionFilter = null, string? groupFilter = null) { var createdNew = false; var createNewItem = !string.IsNullOrEmpty(groupFilter) @@ -40,16 +41,18 @@ public partial class AzCliConsoleGui return (group.Value, createdNew); } - public static async Task FindGroupAsync(bool interactive, string subscription = null, string regionLocation = null, string groupFilter = null, string allowCreateGroupOption = null) + public static async Task FindGroupAsync(bool interactive, string subscription, string? regionLocation = null, string? groupFilter = null, string? allowCreateGroupOption = null) { var allowCreateGroup = !string.IsNullOrEmpty(allowCreateGroupOption); - var listResourcesFunc = async () => await AzCli.ListResourceGroups(subscription, regionLocation); - var response = await LoginHelpers.GetResponseOnLogin(interactive, "group", listResourcesFunc, "Group"); + var listResourcesFunc = () => Program.SubscriptionClient.GetAllResourceGroupsAsync(subscription, Program.CancelToken); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listResourcesFunc, Program.CancelToken, "Group"); + response.ThrowOnFail("Loading resource groups"); - var groups = response.Payload - .Where(x => MatchGroupFilter(x, groupFilter)) - .OrderBy(x => x.Name) + var groups = response.Value + .Where(rg => regionLocation == null || string.Equals(regionLocation, rg.Region, StringComparison.OrdinalIgnoreCase)) + .Where(rg => MatchGroupFilter(rg, groupFilter)) + .OrderBy(rg => rg.Name) .ToList(); var exactMatch = groupFilter != null && groups.Count(x => ExactMatchGroup(x, groupFilter)) == 1; @@ -59,7 +62,7 @@ public partial class AzCliConsoleGui { if (!allowCreateGroup) { - ConsoleHelpers.WriteLineError(response.Payload.Count() > 0 + ConsoleHelpers.WriteLineError(response.Value.Count() > 0 ? $"*** No matching resource groups found ***" : $"*** No resource groups found ***"); return null; @@ -87,11 +90,11 @@ public partial class AzCliConsoleGui return ListBoxPickResourceGroup(groups.ToArray(), allowCreateGroupOption); } - private static async Task TryCreateResourceGroup(bool interactive, string subscriptionId, string regionLocationFilter, string groupName) + private static async Task TryCreateResourceGroup(bool interactive, string subscriptionId, string? regionLocationFilter, string? groupName) { ConsoleHelpers.WriteLineWithHighlight("\n`CREATE RESOURCE GROUP`"); - var regionLocation = await FindRegionAsync(interactive, regionLocationFilter, false); + var regionLocation = await FindRegionAsync(interactive, subscriptionId, regionLocationFilter, false); if (regionLocation == null) return null; var name = string.IsNullOrEmpty(groupName) @@ -100,24 +103,19 @@ public partial class AzCliConsoleGui if (string.IsNullOrEmpty(name)) return null; Console.Write("*** CREATING ***"); - var response = await AzCli.CreateResourceGroup(subscriptionId, regionLocation.Value.Name, name); - - Console.Write("\r"); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) - { - throw new ApplicationException($"ERROR: Creating resource group.\n{response.Output.StdError}"); - } + var response = await Program.SubscriptionClient.CreateResourceGroupAsync(subscriptionId, regionLocation.Value.Name, name, Program.CancelToken); + response.ThrowOnFail("Creating resource group"); Console.WriteLine("\r*** CREATED *** "); - return response.Payload; + return response.Value; } - private static AzCli.ResourceGroupInfo? ListBoxPickResourceGroup(AzCli.ResourceGroupInfo[] groups, string p0) + private static AzCli.ResourceGroupInfo? ListBoxPickResourceGroup(AzCli.ResourceGroupInfo[] groups, string? p0) { var list = groups.Select(x => x.Name).ToList(); var hasP0 = !string.IsNullOrEmpty(p0); - if (hasP0) list.Insert(0, p0); + if (hasP0) list.Insert(0, p0!); var picked = ListBoxPicker.PickIndexOf(list.ToArray()); if (picked < 0) @@ -136,12 +134,12 @@ public partial class AzCliConsoleGui return groups[picked]; } - static bool ExactMatchGroup(AzCli.ResourceGroupInfo group, string groupFilter) + static bool ExactMatchGroup(AzCli.ResourceGroupInfo group, string? groupFilter) { return group.Id == groupFilter || group.Name.ToLower() == groupFilter; } - private static bool MatchGroupFilter(AzCli.ResourceGroupInfo group, string groupFilter) + private static bool MatchGroupFilter(AzCli.ResourceGroupInfo group, string? groupFilter) { if (groupFilter == null || ExactMatchGroup(group, groupFilter)) { @@ -163,7 +161,7 @@ private static void DisplayGroups(List groups, string p private static void DisplayNameAndRegionLocation(AzCli.ResourceGroupInfo group) { - Console.Write($"{group.Name} ({group.RegionLocation})"); + Console.Write($"{group.Name} ({group.Region})"); Console.WriteLine(new string(' ', 20)); } } diff --git a/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs b/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs index 492533b1..e54bb8bf 100644 --- a/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs @@ -3,15 +3,6 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Net; using Azure.AI.Details.Common.CLI.ConsoleGui; namespace Azure.AI.Details.Common.CLI @@ -47,65 +38,61 @@ public static async Task PickSubscriptionIdAsync(bool allowInteractiveLo throw new ApplicationException($"CANCELED: No subscription selected."); } - await AzCli.SetAccount(subscription.Value.Id); + await LegacyAzCli.SetAccount(subscription.Value.Id); return subscription.Value; } public static async Task ValidateSubscriptionAsync(bool allowInteractiveLogin, string subscriptionId) { - var allSubscriptions = await LoginHelpers.GetResponseOnLogin(allowInteractiveLogin, "subscription", AzCli.ListAccounts, " SUBSCRIPTION"); - var subscription = allSubscriptions - .Payload - .FirstOrDefault(subs => string.Equals(subs.Id, subscriptionId, StringComparison.OrdinalIgnoreCase)); + var getSubscription = () => Program.SubscriptionClient.GetSubscriptionAsync(subscriptionId, Program.CancelToken); + var result = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, getSubscription, Program.CancelToken, " SUBSCRIPTION", "*** Validating ***"); - bool found = !string.IsNullOrWhiteSpace(subscription.Id); + AzCli.SubscriptionInfo? subscription = result.Value; + + bool found = subscription != null; if (found) { - Console.WriteLine($"{subscription.Name} ({subscription.Id})"); - CacheSubscriptionUserName(subscription); + Console.WriteLine($"{subscription?.Name} ({subscription?.Id})"); + CacheSubscriptionUserName(subscription.Value); return subscription; } else { ConsoleHelpers.WriteLineWithHighlight($"`#e_;WARNING: Could not find subscription {subscriptionId}!`"); + Console.WriteLine(); return null; } } private static async Task FindSubscriptionAsync(bool allowInteractiveLogin, bool allowInteractivePickSubscription, string subscriptionFilter = null, string subscriptionLabel = "Subscription") { - Console.Write($"\r{subscriptionLabel}: *** Loading choices ***"); - var response = await AzCli.ListAccounts(); - - var noOutput = string.IsNullOrEmpty(response.Output.StdOutput); - var hasError = !string.IsNullOrEmpty(response.Output.StdError); - var hasErrorNotFound = hasError && (response.Output.StdError.Contains(" not ") || response.Output.StdError.Contains("No such file")); - - Console.Write($"\r{subscriptionLabel}: "); - if (noOutput && hasError && hasErrorNotFound) - { - throw new ApplicationException("*** Please install the Azure CLI - https://aka.ms/azcli ***\n\nNOTE: If it's already installed ensure it's in the system PATH and working (try: `az account list`)"); - } - else if (noOutput && hasError) - { - throw new ApplicationException($"*** ERROR: Loading subscriptions ***\n{response.Output.StdError}"); - } + var subsResult = await LoginHelpers.GetResponseOnLogin( + Program.LoginManager, + () => Program.SubscriptionClient.GetAllSubscriptionsAsync(Program.CancelToken), + Program.CancelToken, + subscriptionLabel); - var needLogin = response.Output.StdError != null && LoginHelpers.HasLoginError(response.Output.StdError); - if (needLogin) + if (subsResult.IsError) { - response = await LoginHelpers.AttemptLogin(allowInteractiveLogin, subscriptionLabel); + if ((subsResult.ErrorDetails?.Contains(" not ") | subsResult.ErrorDetails?.Contains("No such file")) == true) + { + throw new ApplicationException("*** Please install the Azure CLI - https://aka.ms/azcli ***\n\nNOTE: If it's already installed ensure it's in the system PATH and working (try: `az account list`)"); + } + else + { + subsResult.ThrowOnFail("Loading subscriptions"); + } } - var subscriptions = response.Payload + var subscriptions = subsResult.Value .Where(x => MatchSubscriptionFilter(x, subscriptionFilter)) .OrderBy(x => x.Name) .ToArray(); if (subscriptions.Count() == 0) { - string error = response.Payload.Count() > 0 + string error = subsResult.Value.Count() > 0 ? "No matching subscriptions found" : "No subscriptions found"; ConsoleHelpers.WriteLineError($"*** {error} ***"); diff --git a/src/common/details/console/ConsoleTempWriter.cs b/src/common/details/console/ConsoleTempWriter.cs new file mode 100644 index 00000000..0e02f28b --- /dev/null +++ b/src/common/details/console/ConsoleTempWriter.cs @@ -0,0 +1,57 @@ +namespace Azure.AI.Details.Common.CLI +{ + public struct ConsoleTempWriter : IDisposable + { + private int _tempCount; + + public ConsoleTempWriter() + { } + + public ConsoleTempWriter(string message) + { + WriteTemp(message ?? string.Empty); + } + + public void WriteTemp(string message) => Overwrite(message, Console.Write); + + public void AppendTemp(string message) + { + Console.Write(message); + _tempCount += message.Length; + } + + public void WriteErrorTemp(string message) => Overwrite(message, ConsoleHelpers.WriteError); + + public void Clear() => Overwrite(string.Empty, Console.Write); + + public void Dispose() + { + Overwrite(string.Empty, Console.Write); + } + + private void Overwrite(string message, Action writer) + { + try + { + // 1. Go back to the start of temp string (does not delete anything just moves the caret so to speak) + Console.Write(new string('\b', _tempCount)); + + // 2. Write out new message + writer(message); + + // 3. If the new message is shorter than the last, overwrite remaining old chars with space and then go back to + // end of new message + int delta = _tempCount - message.Length; + if (delta > 0) + { + Console.Write(new string(' ', delta)); + Console.Write(new string('\b', delta)); + } + } + finally + { + _tempCount = message?.Length ?? 0; + } + } + } +} diff --git a/src/common/details/helpers/file_helpers.cs b/src/common/details/helpers/file_helpers.cs index 977664f2..214eda26 100644 --- a/src/common/details/helpers/file_helpers.cs +++ b/src/common/details/helpers/file_helpers.cs @@ -1455,7 +1455,7 @@ private static string CheckDotDirectory(string checkPath, bool mustExist = true, private static string _dataPath = defaultDataPath; private static string _outputPath = ""; - private static readonly string dotDirectory = $".{Program.Name}/"; + private static readonly string dotDirectory = $".{Program.Name ?? "ai"}/"; private static ReaderWriterLockSlim _lockSlim = new ReaderWriterLockSlim(); private static Dictionary? _origDotXfileNamesDictionary; diff --git a/src/common/details/helpers/json_helpers.cs b/src/common/details/helpers/json_helpers.cs index 2299ea8f..7e55f475 100644 --- a/src/common/details/helpers/json_helpers.cs +++ b/src/common/details/helpers/json_helpers.cs @@ -290,6 +290,33 @@ public static string ContinueWithStringArrayMemberOrEmpty(string name, INamedVal #endregion + /// + /// Helper method to deserialize the value of a specific named property from a JSON string + /// + /// The type of the value to deserialize + /// The JSON string to deserialize + /// The name of the property to deserialize + /// The deserialized value, or the default value in the case of errors (e.g. null string, + /// property didn't exist, etc...) + public static TValue DeserializePropertyValueOrDefault(string json, string propertyName) + { + TValue value = default; + + try + { + using JsonDocument node = JsonDocument.Parse(json ?? "{}"); + if (node.RootElement.TryGetProperty(propertyName ?? string.Empty, out JsonElement property)) + { + value = property.Deserialize(); + } + } + catch (Exception) + { + } + + return value; + } + public static void PrintJson(string? text, string indent = " ", bool naked = false) { if (!string.IsNullOrWhiteSpace(text)) diff --git a/src/common/details/helpers/login_helpers.cs b/src/common/details/helpers/login_helpers.cs index bc00af19..fa0b8f8e 100644 --- a/src/common/details/helpers/login_helpers.cs +++ b/src/common/details/helpers/login_helpers.cs @@ -1,56 +1,54 @@ -// -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// +#nullable enable -using System.Runtime.CompilerServices; -using System.Text; +using Azure.AI.CLI.Common.Clients; +using Azure.AI.CLI.Common.Clients.Models; using Azure.AI.Details.Common.CLI.ConsoleGui; namespace Azure.AI.Details.Common.CLI { - public class LoginHelpers + public static class LoginHelpers { - public static async Task> GetResponseOnLogin(bool allowInteractiveLogin, string label, Func>> getResponse, string titleLabel = "Name") + public static async Task GetResponseOnLogin(ILoginManager loginManager, Func> getResult, CancellationToken token, string prompt = "Name", string loadingMessage = "*** Loading choices ***") + where TResult : IClientResult { - string message = $"\r{titleLabel}: *** Loading choices ***"; - Console.Write(message); + Console.Write($"{prompt}: "); - var response = await getResponse(); + using var writer = new ConsoleTempWriter(); + writer.WriteTemp(loadingMessage); - Console.Write($"\r{new string(' ', message.Length)}\r{titleLabel}: "); - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) + var result = await getResult(); + if (result.Outcome == ClientOutcome.LoginNeeded) { - if (LoginHelpers.HasLoginError(response.Output.StdError)) + var loginResponse = await AttemptLogin(loginManager, writer, token); + if (loginResponse.IsSuccess) { - var loginResponse = await LoginHelpers.AttemptLogin(allowInteractiveLogin, $"{label}s"); - if (!loginResponse.Equals(default(ParsedJsonProcessOutput))) - { - response = await getResponse(); - } + writer.WriteTemp(loadingMessage); + result = await getResult(); } - if (string.IsNullOrEmpty(response.Output.StdOutput) && !string.IsNullOrEmpty(response.Output.StdError)) + else { - throw new ApplicationException($"ERROR: Loading resource {label}s: {response.Output.StdError}"); + writer.Clear(); + ConsoleHelpers.WriteLineError("*** Please run `az login` and try again ***"); } } - return response; + + return result; } - public static async Task> AttemptLogin(bool allowInteractiveLogin, string label) + private static async Task AttemptLogin(ILoginManager loginManager, ConsoleTempWriter writer, CancellationToken token) { - bool cancelLogin = !allowInteractiveLogin; + bool cancelLogin = !loginManager.CanAttemptLogin; bool useDeviceCode = false; - if (allowInteractiveLogin) + if (loginManager.CanAttemptLogin) { - ConsoleHelpers.WriteError("*** WARNING: `az login` required ***"); - Console.Write(" "); + writer.WriteErrorTemp("*** WARNING: `az login` required ***"); + writer.AppendTemp(" "); var selection = 0; var choices = new List() { - "LAUNCH: `az login` (interactive device code)", - "CANCEL: `az login ...` (non-interactive)", - }; + "LAUNCH: `az login` (interactive device code)", + "CANCEL", + }; if (!OS.IsCodeSpaces()) { @@ -66,17 +64,17 @@ public static async Task> GetResponseOnLogin(bool if (cancelLogin) { - Console.Write($"\r{label}: "); - ConsoleHelpers.WriteLineError("*** Please run `az login` and try again ***"); - return default; + return new ClientResult() + { + Outcome = ClientOutcome.Canceled, + }; } - Console.Write($"\r{label}: *** Launching `az login` (interactive) ***"); - var response = await AzCli.Login(useDeviceCode); - Console.Write($"\r{label}: "); + writer.WriteTemp($"*** Launching `az login` ***"); + var response = await loginManager.LoginAsync( + new() { Mode = useDeviceCode ? LoginMode.UseDeviceCode : LoginMode.UseWebPage }, + token); return response; } - - public static bool HasLoginError(string errorMessage) => errorMessage.Split('\'', '"').Contains("az login") || errorMessage.Contains("refresh token"); } } diff --git a/src/common/details/helpers/process_helpers.cs b/src/common/details/helpers/process_helpers.cs index 0a20d039..7f9eac06 100644 --- a/src/common/details/helpers/process_helpers.cs +++ b/src/common/details/helpers/process_helpers.cs @@ -3,35 +3,26 @@ // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // -using System; -using System.Collections.Generic; +#nullable enable + using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Azure.AI.Details.Common.CLI; using System.Text; -using System.Text.Json; namespace Azure.AI.Details.Common.CLI { - public struct ProcessOutput - { - public string StdOutput; - public string StdError; - public string MergedOutput; - public int ExitCode; - } - - public struct ParsedJsonProcessOutput + public readonly struct ProcessOutput { - public ParsedJsonProcessOutput(ProcessOutput output) - { - Output = output; - } - - public ProcessOutput Output; - public T? Payload; + public string StdOutput { get; init; } + public string StdError { get; init; } + public string MergedOutput { get; init; } + public int ExitCode { get; init; } + + public bool HasError => ExitCode != 0 + || (!string.IsNullOrWhiteSpace(StdError) + // Special case to ignore errors when tests are running against the test-proxy + && !(StdError.Contains("`subjectAltName`") && StdError.Contains("`commonName`"))); } public class ProcessHelpers @@ -47,7 +38,7 @@ public class ProcessHelpers : null; } - public static Process? StartProcess(string fileName, string arguments, Dictionary? addToEnvironment = null, bool redirectOutput = true, bool redirectInput = false) + public static Process? StartProcess(string fileName, string arguments, IDictionary? addToEnvironment = null, bool redirectOutput = true, bool redirectInput = false) { var start = new ProcessStartInfo(fileName, arguments); start.UseShellExecute = false; @@ -113,7 +104,7 @@ public static async Task RunShellInteractiveAsync(Dictionary RunShellCommandAsync(string command, string arguments, Dictionary? addToEnvironment = null, Action? stdOutHandler = null, Action? stdErrHandler = null, Action? mergedOutputHandler = null, bool captureOutput = true) + public static async Task RunShellCommandAsync(string command, string arguments, IDictionary? addToEnvironment = null, Action? stdOutHandler = null, Action? stdErrHandler = null, Action? mergedOutputHandler = null, bool captureOutput = true) { SHELL_DEBUG_TRACE($"COMMAND: {command} {arguments} {DictionaryToString(addToEnvironment)}"); @@ -156,6 +147,14 @@ public static async Task RunShellCommandAsync(string command, str try { process = StartShellCommandProcess(command, arguments, addToEnvironment, redirectOutput); + if (process == null) + { + return new ProcessOutput() + { + ExitCode = -1, + StdError = "Process failed to start" + }; + } } catch (Exception processException) { @@ -167,30 +166,29 @@ public static async Task RunShellCommandAsync(string command, str }; } - if (process != null) + if (redirectOutput) { - if (redirectOutput) - { - process.OutputDataReceived += (sender, e) => stdOutReceived(e.Data); - process.ErrorDataReceived += (sender, e) => stdErrReceived(e.Data); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - } + process.OutputDataReceived += (sender, e) => stdOutReceived(e.Data); + process.ErrorDataReceived += (sender, e) => stdErrReceived(e.Data); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } - await process.WaitForExitAsync(); + await process.WaitForExitAsync(); - if (redirectOutput) - { - outDoneSignal.WaitOne(); - errDoneSignal.WaitOne(); - } + if (redirectOutput) + { + outDoneSignal.WaitOne(); + errDoneSignal.WaitOne(); } - var output = new ProcessOutput(); - output.StdOutput = sbOut.ToString().Trim(' ', '\r', '\n'); - output.StdError = sbErr.ToString().Trim(' ', '\r', '\n'); - output.MergedOutput = sbMerged.ToString().Trim(' ', '\r', '\n'); - output.ExitCode = process?.ExitCode ?? -1; + var output = new ProcessOutput() + { + StdOutput = sbOut.ToString().Trim(), + StdError = sbErr.ToString().Trim(), + MergedOutput = sbMerged.ToString().Trim(), + ExitCode = process.ExitCode, + }; if (!string.IsNullOrEmpty(output.StdOutput)) SHELL_DEBUG_TRACE($"---\nSTDOUT\n---\n{output.StdOutput}"); if (!string.IsNullOrEmpty(output.StdError)) SHELL_DEBUG_TRACE($"---\nSTDERR\n---\n{output.StdError}"); @@ -198,17 +196,7 @@ public static async Task RunShellCommandAsync(string command, str return output; } - public static async Task> ParseShellCommandJson(string command, string arguments, Dictionary? addToEnvironment = null, Action? stdOutHandler = null, Action? stdErrHandler = null) - { - var processOutput = await RunShellCommandAsync(command, arguments, addToEnvironment, stdOutHandler, stdErrHandler); - var stdOutput = processOutput.StdOutput; - - return !string.IsNullOrWhiteSpace(stdOutput) - ? new ParsedJsonProcessOutput(processOutput) { Payload = JsonDocument.Parse(stdOutput).RootElement } - : new ParsedJsonProcessOutput(processOutput); - } - - private static Process? StartShellCommandProcess(string command, string arguments, Dictionary? addToEnvironment = null, bool captureOutput = true) + private static Process? StartShellCommandProcess(string command, string arguments, IDictionary? addToEnvironment = null, bool captureOutput = true) { var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); return isWindows @@ -224,16 +212,19 @@ private static void SHELL_DEBUG_TRACE(string message,[CallerLineNumber] int line AI.DBG_TRACE_INFO(message, line, caller, file); } - private static string DictionaryToString(Dictionary? dictionary) + private static string DictionaryToString(IDictionary? dictionary) { + if (dictionary == null) + { + return string.Empty; + } + var kvps = new List(); - if (dictionary != null) + foreach (var kvp in dictionary) { - foreach (var kvp in dictionary) - { - kvps.Add($"{kvp.Key}={kvp.Value}"); - } + kvps.Add($"{kvp.Key}={kvp.Value}"); } + return string.Join(' ', kvps); } diff --git a/src/common/details/helpers/string_helpers.cs b/src/common/details/helpers/string_helpers.cs index 8f414fe4..20d436d9 100644 --- a/src/common/details/helpers/string_helpers.cs +++ b/src/common/details/helpers/string_helpers.cs @@ -99,6 +99,14 @@ public static bool UpdateNeeded(string[] current, string[] latest) || Int32.Parse(current[1]) < Int32.Parse(latest[1]) || Int32.Parse(current[2]) < Int32.Parse(latest[2]); } + + public static int AppendOrTruncate(ref Span buffer, ReadOnlySpan additional) + { + int toCopy = Math.Min(buffer.Length, additional.Length); + additional.CopyTo(buffer); + buffer = buffer.Slice(toCopy); + return toCopy; + } } public static class StringExtensions diff --git a/src/testing/adapters/recordingadapter/RecordedTestObserver.cs b/src/testing/adapters/recordingadapter/RecordedTestObserver.cs index 632c11c1..f9de1acb 100644 --- a/src/testing/adapters/recordingadapter/RecordedTestObserver.cs +++ b/src/testing/adapters/recordingadapter/RecordedTestObserver.cs @@ -6,7 +6,6 @@ using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; using System; -using System.Diagnostics; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -36,7 +35,7 @@ public void RecordStart(TestCase testCase) if (_mode != RecordedTestMode.Live) { - Environment.SetEnvironmentVariable("HTTPS_PROXY", "http://localhost:5004"); + Environment.SetEnvironmentVariable("HTTPS_PROXY", TestProxyClient.BaseUrl); foreach (var trait in testCase.Traits.Where((Trait t) => t.Name.Equals("_sanitize", StringComparison.OrdinalIgnoreCase))) { var sanitizeJson = trait.Value; @@ -80,7 +79,7 @@ private async Task AddSanitizer(string sanitizeJson) { foreach (var uri in currentElement.EnumerateArray()) { - await TestProxyClient.AddUriSinatizer(uri.GetProperty("regex").GetString(), uri.GetProperty("value").GetString()); + await TestProxyClient.AddUriSanitizer(uri.GetProperty("regex").GetString(), uri.GetProperty("value").GetString()); } } else if (sanitizeLine.TryGetProperty("body", out currentElement)) @@ -106,12 +105,12 @@ public void RecordEnd(TestCase testCase, TestOutcome outcome) case RecordedTestMode.Record: TestProxyClient.StopRecording(_id).Wait(); Environment.SetEnvironmentVariable("HTTPS_PROXY", null); - TestProxyClient.ClearSanatizers().Wait(); + TestProxyClient.ClearSanitizers().Wait(); break; case RecordedTestMode.Playback: TestProxyClient.StopPlayback(_id).Wait(); Environment.SetEnvironmentVariable("HTTPS_PROXY", null); - TestProxyClient.ClearSanatizers().Wait(); + TestProxyClient.ClearSanitizers().Wait(); break; case RecordedTestMode.Live: // Live test diff --git a/src/testing/adapters/recordingadapter/TestProxyClient.cs b/src/testing/adapters/recordingadapter/TestProxyClient.cs index f9dbfbb6..5f4d74fc 100644 --- a/src/testing/adapters/recordingadapter/TestProxyClient.cs +++ b/src/testing/adapters/recordingadapter/TestProxyClient.cs @@ -4,7 +4,6 @@ // using System; -using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -18,38 +17,39 @@ namespace YamlTestAdapter { public class TestProxyClient { - private const string _proxy = "http://localhost:5004"; - private static readonly HttpClient _httpClient = new HttpClient(new HttpClientHandler() { ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true }); - public enum SanatizeLocation + public enum SanitizeLocation { Header, Body, Uri } + public static string BaseUrl => Environment.GetEnvironmentVariable("TEST_PROXY_URL") + ?? "http://localhost:5004"; + /* - private static async Task Record() - { - var recordingId = await StartRecording(); - await SendRequest(recordingId, "record"); - await Task.Delay(TimeSpan.FromSeconds(2)); - await SendRequest(recordingId, "record"); - await StopRecording(recordingId); - } - - private static async Task Playback() - { - var recordingId = await StartPlayback(); - await SendRequest(recordingId, "playback"); - await SendRequest(recordingId, "playback"); - await StopPlayback(recordingId); - } -*/ + private static async Task Record() + { + var recordingId = await StartRecording(); + await SendRequest(recordingId, "record"); + await Task.Delay(TimeSpan.FromSeconds(2)); + await SendRequest(recordingId, "record"); + await StopRecording(recordingId); + } + + private static async Task Playback() + { + var recordingId = await StartPlayback(); + await SendRequest(recordingId, "playback"); + await SendRequest(recordingId, "playback"); + await StopPlayback(recordingId); + } + */ public static Process InvokeProxy() { @@ -72,7 +72,7 @@ public static async Task StartPlayback(string recordingFile) { Console.WriteLine($"StartPlayback {recordingFile}"); - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + "/playback/start"); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/playback/start"); var json = "{\"x-recording-file\":\"" + recordingFile + "\"}"; var content = new System.Net.Http.StringContent(json, Encoding.UTF8, "application/json"); @@ -100,7 +100,7 @@ public static async Task StopPlayback(string recordingId) Console.WriteLine($"StopPlayback {recordingId}"); Console.WriteLine(); - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + "/playback/stop"); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/playback/stop"); message.Headers.Add("x-recording-id", recordingId); var response = await _httpClient.SendAsync(message); @@ -119,7 +119,7 @@ public static async Task StartRecording(string recordingFile) { Console.WriteLine($"StartRecording {recordingFile}"); - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + "/record/start"); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/record/start"); var json = "{\"x-recording-file\":\"" + recordingFile + "\"}"; var content = new System.Net.Http.StringContent(json, Encoding.UTF8, "application/json"); @@ -148,7 +148,7 @@ public static async Task StopRecording(string recordingId) Console.WriteLine($"StopRecording {recordingId}"); Console.WriteLine(); - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + "/record/stop"); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/record/stop"); message.Headers.Add("x-recording-id", recordingId); message.Headers.Add("x-recording-save", bool.TrueString); @@ -164,7 +164,7 @@ public static async Task StopRecording(string recordingId) } } - public static Task AddUriSinatizer(string regexToMatch, string replaceValue) => AddSanitizer("UriRegexSanitizer", new JsonObject() { ["value"] = replaceValue, ["regex"] = regexToMatch }); + public static Task AddUriSanitizer(string regexToMatch, string replaceValue) => AddSanitizer("UriRegexSanitizer", new JsonObject() { ["value"] = replaceValue, ["regex"] = regexToMatch }); public static Task AddHeaderSanitizer(string key, string regex, string value) { @@ -184,20 +184,30 @@ public static Task AddHeaderSanitizer(string key, string regex, string value) private static async Task AddSanitizer(string headerName, JsonObject json) { var url = "/admin/addsanitizer"; - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + url); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + url); message.Headers.Add("x-abstraction-identifier", headerName); message.Content = new StringContent(json.ToJsonString(), Encoding.UTF8, "application/json"); var result = await _httpClient.SendAsync(message); if (result.StatusCode != HttpStatusCode.OK) { - throw new Exception($"Failed to add sanitizer {result.StatusCode} {result.Content}"); + string responseContent = string.Empty; + if (result.Content != null) + { + try + { + responseContent = await result.Content.ReadAsStringAsync(); + } + catch { } + } + + throw new Exception($"Failed to add sanitizer {result.StatusCode} {responseContent}"); } } - public static async Task ClearSanatizers() + public static async Task ClearSanitizers() { var url = "/admin/reset"; - var message = new HttpRequestMessage(HttpMethod.Post, _proxy + url); + var message = new HttpRequestMessage(HttpMethod.Post, BaseUrl + url); var result = await _httpClient.SendAsync(message); if (result.StatusCode != HttpStatusCode.OK) diff --git a/tests/UnitTests/AssertExtensions.cs b/tests/UnitTests/AssertExtensions.cs new file mode 100644 index 00000000..42025dff --- /dev/null +++ b/tests/UnitTests/AssertExtensions.cs @@ -0,0 +1,82 @@ +namespace Azure.AI.CLI.Test +{ + /// + /// Extensions for the Assert class + /// + public static class AssertExtensions + { + /// + /// Asserts that the given string contains the given substring + /// + /// The assert object + /// The substring to check for + /// The string to check + /// The message to display if the assertion fails + public static void ContainsString(this Assert _, string contains, string? str, string message) + { + bool success = str?.Contains(contains) == true; + if (success) + { + return; + } + + string displayValue = str == null + ? "<>" + : str; + + Assert.Fail($"{message} - String did not contain '{contains}'. Got '{displayValue}'"); + } + + /// + /// Asserts that the given string has been populated (aka is not null, empty, or whitespace only) + /// + /// The assert object + /// The string to check + /// The message to display if the assertion fails + public static void IsPopulatedString(this Assert _, string? str, string message) + { + if (!string.IsNullOrWhiteSpace(str)) + { + return; + } + + string displayValue = str == null + ? "<>" + : str; + + Assert.Fail($"{message} - String was not populated. Got '{displayValue}'"); + } + + /// + /// Asserts that the given value is not the default value for its type + /// + /// The type of the value type + /// The value to check + /// The message to display if the assertion fails + public static void IsNotDefault(this Assert _, T value, string message) where T : struct + { + if (!default(T).Equals(value)) + { + return; + } + + Assert.Fail($"{message} - Value was the default for its type"); + } + + /// + /// Asserts that the given value is the default value for its type + /// + /// The type of the value type + /// The value to check + /// The message to display if the assertion fails + public static void IsDefault(this Assert _, T value, string message) where T : struct + { + if (default(T).Equals(value)) + { + return; + } + + Assert.Fail($"{message} - Value was the default for its type"); + } + } +} diff --git a/tests/UnitTests/AzCliClientIntegrationTests.cs b/tests/UnitTests/AzCliClientIntegrationTests.cs new file mode 100644 index 00000000..5c984c4d --- /dev/null +++ b/tests/UnitTests/AzCliClientIntegrationTests.cs @@ -0,0 +1,893 @@ +using Azure.AI.CLI.Clients.AzPython; +using Azure.AI.CLI.Common.Clients; +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI; +using Azure.AI.Details.Common.CLI.AzCli; + +namespace Azure.AI.CLI.Test.UnitTests +{ + [TestClass] + [Ignore("Until the recording test proxy has been set up, this test is not ready to run in CI/CD. You can remove this to run these tests locally")] + public class AzCliClientIntegrationTests + { + const string TEST_DATA_FILE = "test_config.json"; + static TestConfig _config; + CancellationTokenSource? _cts; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + var configFile = FileHelpers.FindFileInConfigPath(TEST_DATA_FILE, null); + if (string.IsNullOrWhiteSpace(configFile)) + { + throw new ApplicationException($"Could not find {TEST_DATA_FILE} in any config folder. Please create that file before proceeding"); + } + + _config = Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText(configFile)); + } + + [TestInitialize] + public void Setup() + { + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _cts.CancelAfter(System.Diagnostics.Debugger.IsAttached + ? TimeSpan.FromMinutes(15) + : TimeSpan.FromSeconds(75)); + } + + public CancellationToken Token => _cts?.Token ?? CancellationToken.None; + protected TestConfig Config => _config; + + [TestMethod] + public async Task ListSubscriptionAsync() + { + using ISubscriptionsClient client = CreateClient(); + var result = await client.GetAllSubscriptionsAsync(Token); + + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty subscriptions"); + + var subs = result.Value.FirstOrDefault(); + Assert.IsTrue(!string.IsNullOrWhiteSpace(subs.Id), "Id was null or empty '{0}'", subs.Id); + Assert.IsTrue(!string.IsNullOrWhiteSpace(subs.Name), "Name was null or empty '{0}'", subs.Name); + Assert.IsTrue(!string.IsNullOrWhiteSpace(subs.UserName), "Username was null or empty '{0}'", subs.UserName); + + var anyDefault = result.Value.Any(s => s.IsDefault); + Assert.IsTrue(anyDefault, "No subscriptions were marked as default"); + + subs = result.Value.FirstOrDefault(s => string.Equals(s.Id, Config.SubscriptionId, StringComparison.OrdinalIgnoreCase)); + Assert.IsNotNull(subs, "Could not find the expected '{0}' subscription", Config.SubscriptionId); + Assert.AreEqual(Config.SubscriptionName, subs.Name, "Names did not match"); + } + + [TestMethod] + public async Task GetSubscriptionAsync() + { + using ISubscriptionsClient client = CreateClient(); + + // Null argument + var result = await client.GetSubscriptionAsync(null!, Token); + HasException(result); + + // Valid subscription id + result = await client.GetSubscriptionAsync(Config.SubscriptionId, Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsNotNull(result.Value, "Null subscription info"); + Assert.AreEqual(Config.SubscriptionName, result.Value?.Name, "Name did not match"); + + // Subscription that doesn't exist + result = await client.GetSubscriptionAsync("this_does_not_exit", Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsNull(result.Value, "Null subscription info"); + } + + [TestMethod] + public async Task ListRegionsAsync() + { + using ISubscriptionsClient client = CreateClient(); + + var result = await client.GetAllRegionsAsync(Config.SubscriptionId, Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty regions"); + + var region = result.Value.FirstOrDefault(r => r.Name == "eastus"); + Assert.IsNotNull(region, "Could not find eastus region"); + Assert.AreEqual("eastus", region.Name, "Wrong region name"); + Assert.IsTrue(!string.IsNullOrWhiteSpace(region.DisplayName), "Empty or invalid display name"); + Assert.IsTrue(!string.IsNullOrWhiteSpace(region.Id), "Empty or invalid ID"); + Assert.IsTrue(!string.IsNullOrWhiteSpace(region.RegionalDisplayName), "Empty or invalid regional display name"); + } + + [TestMethod] + public async Task ListRegionsInvalidArgAsync() + { + using ISubscriptionsClient client = CreateClient(); + var result = await client.GetAllRegionsAsync(null!, Token); + HasException(result); + } + + [TestMethod] + public async Task ListResourceGroupsAsync() + { + using ISubscriptionsClient client = CreateClient(); + + var result = await client.GetAllResourceGroupsAsync(Config.SubscriptionId, Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty regions"); + + var group = result.Value.FirstOrDefault(r => string.Equals(Config.ResourceGroup, r.Name)); + Assert.IsNotNull(group, "Could not find expected '{0}' resource group", Config.ResourceGroup); + Assert.AreEqual(Config.ResourceGroup, group.Name, "Wrong region name"); + Assert.IsTrue(!string.IsNullOrWhiteSpace(group.Id), "Empty or invalid ID"); + Assert.IsTrue(!string.IsNullOrWhiteSpace(group.Region), "Empty or invalid region"); + } + + [TestMethod] + public async Task ListResourceGroupsInvalidArgAsync() + { + using ISubscriptionsClient client = CreateClient(); + var result = await client.GetAllResourceGroupsAsync(null!, Token); + HasException(result); + } + + [TestMethod] + public async Task CreateAndDeleteResourceGroupAsync() + { + using ISubscriptionsClient client = CreateClient(); + string name = GenerateName("rg"); + + try + { + var result = await client.CreateResourceGroupAsync(Config.SubscriptionId, Config.Region, name, Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Failed with '{0}'", result.ErrorDetails); + Assert.IsTrue(!string.IsNullOrWhiteSpace(result.Value.Id), "Empty or invalid ID"); + Assert.AreEqual(Config.Region, result.Value.Region, "Wrong region"); + Assert.AreEqual(name, result.Value.Name, "Wrong name"); + } + finally + { + var delResult = await client.DeleteResourceGroupAsync(Config.SubscriptionId, name, Token); + Assert.AreEqual(ClientOutcome.Success, delResult.Outcome, "Wrong outcome. Details: {0}", delResult.ErrorDetails); + } + } + + [TestMethod] + public async Task CreateResourceGroupInvalidAsync() + { + using ISubscriptionsClient client = CreateClient(); + string id = Config.SubscriptionId; + string region = Config.Region; + string name = "this-is-a-test"; + + var args = new ValueTuple[] + { + ("", region, name), + (id, null!, name), + (id, region, " "), + }; + + foreach (var arg in args) + { + bool created = false; + try + { + var result = await client.CreateResourceGroupAsync(arg.Item1, arg.Item2, arg.Item3, Token); + created = result.IsSuccess; + HasException(result); + } + finally + { + if (created) + { + await client.DeleteResourceGroupAsync(id, name, Token); + } + } + } + } + + [TestMethod] + public async Task DeleteResourceGroupNotExistAsync() + { + using ISubscriptionsClient client = CreateClient(); + var result = await client.DeleteResourceGroupAsync(Config.SubscriptionId, "this_does_not_exist" + Guid.NewGuid(), Token); + Assert.AreEqual(ClientOutcome.Failed, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + } + + [TestMethod] + public async Task DeleteResourceGroupInvalidArgAsync() + { + using ISubscriptionsClient client = CreateClient(); + HasException(await client.DeleteResourceGroupAsync(null!, "name", Token)); + HasException(await client.DeleteResourceGroupAsync(Config.SubscriptionId, " ", Token)); + } + + [TestMethod] + public async Task ListCognitiveServicesResourcesAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + var result = await client.GetAllResourcesAsync(Config.SubscriptionId, Token); + result.ThrowOnFail("getting all resources"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty resources"); + + // All resources + var resource = result.Value.FirstOrDefault(r => string.Equals(Config.CognitiveServicesResource, r.Name)); + Assert.IsNotNull(resource, "Could not find expected '{0}' resource", Config.CognitiveServicesResource); + Assert.AreEqual(ResourceKind.CognitiveServices, resource.Kind, "Wrong resource kind"); + Assert.That.IsPopulatedString(resource.Id, "ID"); + Assert.AreEqual(Config.CognitiveServicesResource, resource.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, resource.Group, "Wrong resource group"); + Assert.AreEqual(Config.Region, resource.RegionLocation, "Wrong region"); + Assert.That.IsPopulatedString(resource.Endpoint, "Endpoint"); + Assert.IsTrue(resource.Endpoints?.Count() > 0, "Empty or null endpoints"); + Assert.IsTrue( + resource.Endpoints.Any(kvp => !string.IsNullOrWhiteSpace(kvp.Key + kvp.Value)), + "No populated endpoints in dictionary"); + + // Filtered resources + var expectedKind = ResourceKind.AIServices; + result = await client.GetAllResourcesAsync(Config.SubscriptionId, Token, expectedKind); + result.ThrowOnFail("getting all resources"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty resources"); + Assert.IsFalse( + result.Value.Any(r => r.Kind != expectedKind), + "Wrong type of resource found in filtered list:\n {0}", + string.Join("\n ", result.Value.Select(r => $"{r.Kind} - {r.Name}"))); + } + + [TestMethod] + public async Task ListCognitiveServicesResourcesInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + HasException(await client.GetAllResourcesAsync(null!, Token)); + } + + [TestMethod] + public async Task GetCognitiveServicesResourceAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + var result = await client.GetResourceFromNameAsync(Config.SubscriptionId, Config.ResourceGroup, Config.CognitiveServicesResource, Token); + result.ThrowOnFail("getting resource"); + Assert.IsNotNull(result.Value, "Null resource info"); + Assert.AreEqual(ResourceKind.CognitiveServices, result.Value?.Kind, "Wrong resource kind"); + Assert.That.IsPopulatedString(result.Value?.Id, "ID"); + Assert.AreEqual(Config.CognitiveServicesResource, result.Value?.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, result.Value?.Group, "Wrong resource group"); + Assert.AreEqual(Config.Region, result.Value?.RegionLocation, "Wrong region"); + Assert.That.IsPopulatedString(result.Value?.Endpoint, "Endpoint"); + Assert.IsTrue(result.Value?.Endpoints?.Count() > 0, "Empty or null endpoints"); + Assert.IsTrue( + result.Value.Endpoints.Any(kvp => !string.IsNullOrWhiteSpace(kvp.Key + kvp.Value)), + "No populated endpoints in dictionary"); + } + + [TestMethod] + public async Task GetCognitiveServicesResourceInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string name = "name"; + + HasException(await client.GetResourceFromNameAsync(null!, rg, name, Token)); + HasException(await client.GetResourceFromNameAsync(id, "", name, Token)); + HasException(await client.GetResourceFromNameAsync(id, rg, " ", Token)); + } + + [TestMethod] + public async Task GetCognitiveServicesResourceNotExistAsync() + { + using ICognitiveServicesClient client = CreateClient(); + var result = await client.GetResourceFromNameAsync(Config.SubscriptionId, Config.ResourceGroup, "this_does_not_exist" + Guid.NewGuid(), Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + Assert.IsNull(result.Value, "Null resource info"); + } + + [TestMethod] + [DataRow(ResourceKind.AIServices, "S0")] + [DataRow(ResourceKind.CognitiveServices, "S0")] + [DataRow(ResourceKind.OpenAI, "S0")] + [DataRow(ResourceKind.Speech, "S0")] + [DataRow(ResourceKind.Vision, "S1")] + public async Task CreateAndDeleteCognitiveServicesResource(ResourceKind kind, string sku) + { + using ICognitiveServicesClient client = CreateClient(); + string name = GenerateName("r" + Acronym(kind)); + + try + { + var result = await client.CreateResourceAsync(kind, Config.SubscriptionId, Config.ResourceGroup, Config.Region, name, sku, Token); + result.ThrowOnFail("creating resource"); + Assert.AreEqual(kind, result.Value.Kind, "Wrong resource kind"); + Assert.That.ContainsString(name, result.Value.Id, "ID"); + Assert.AreEqual(name, result.Value.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, result.Value.Group, "Wrong resource group"); + Assert.AreEqual(Config.Region, result.Value.RegionLocation, "Wrong region"); + Assert.That.IsPopulatedString(result.Value.Endpoint, "Endpoint"); + Assert.IsTrue(result.Value.Endpoints?.Count() > 0, "Empty or null endpoints"); + Assert.IsTrue( + result.Value.Endpoints.Any(kvp => !string.IsNullOrWhiteSpace(kvp.Key + kvp.Value)), + "No populated endpoints in dictionary"); + } + finally + { + var delResult = await client.DeleteResourceAsync(Config.SubscriptionId, Config.ResourceGroup, name, Token); + delResult.ThrowOnFail("deleting resource"); + } + } + + [TestMethod] + public async Task CreateCognitiveServicesInvalidArgResource() + { + using ICognitiveServicesClient client = CreateClient(); + var kind = ResourceKind.AIServices; + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string region = Config.Region; + string name = GenerateName("r-dummy"); + string sku = "S0"; + + var args = new ValueTuple[] + { + // not supported kind + (ResourceKind.Unknown, id, rg, region, name, sku), + + // invalud string + (kind, "", rg, region, name, sku), + (kind, id, null!, region, name, sku), + (kind, id, rg, " ", name, sku), + (kind, id, rg, region, null!, sku), + (kind, id, rg, region, name, "") + }; + + foreach (var arg in args) + { + bool created = false; + try + { + var result = await client.CreateResourceAsync(arg.Item1, arg.Item2, arg.Item3, arg.Item4, arg.Item5, arg.Item6, Token); + created = result.IsSuccess; + HasException(result); + } + finally + { + if (created) + { + await client.DeleteResourceAsync(id, rg, name, Token); + } + } + } + } + + [TestMethod] + public async Task DeleteCognitiveServicesResourceInvalidArgsAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string name = "name"; + + HasException(await client.DeleteResourceAsync(null!, rg, name, Token)); + HasException(await client.DeleteResourceAsync(id, "", name, Token)); + HasException(await client.DeleteResourceAsync(id, rg, " ", Token)); + } + + [TestMethod] + public async Task DeleteCognitiveServicesResourceNotExistAsync() + { + using ICognitiveServicesClient client = CreateClient(); + var result = await client.DeleteResourceAsync(Config.SubscriptionId, Config.ResourceGroup, "this_does_not_exist", Token); + + // TODO FIXME: the AZ CLI (and the corresponding REST APIs) do not return a 404 when deleting a resource that doesn't exist + // instead returning HTTP 204. This results in a "success" outcome which is inconsistent with e.g. resource groups + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + } + + [TestMethod] + public async Task ListDeploymentsAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + var result = await client.GetAllDeploymentsAsync(Config.SubscriptionId, Config.ResourceGroup, Config.AIServicesResource, Token); + result.ThrowOnFail("getting all deployments"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty deployments"); + + var deployment = result.Value.FirstOrDefault(r => string.Equals(Config.ChatDeployment, r.Name)); + Assert.IsNotNull(deployment, "Could not find expected '{0}' deployment", Config.ChatDeployment); + Assert.That.ContainsString(Config.ChatDeployment, deployment.Id, "ID"); + Assert.AreEqual(Config.ChatDeployment, deployment.Name, "Wrong deployment name"); + Assert.AreEqual(Config.ResourceGroup, deployment.ResourceGroup, "Wrong deployment group"); + Assert.That.IsPopulatedString(deployment.ModelName, "Model name"); + Assert.That.IsPopulatedString(deployment.ModelFormat, "Model format"); + Assert.AreEqual(true, deployment.ChatCompletionCapable, "Chat completion capability"); + Assert.AreEqual(false, deployment.EmbeddingsCapable, "Embeddings capability"); + + deployment = result.Value.FirstOrDefault(r => string.Equals(Config.EmbeddingsDeployment, r.Name)); + Assert.IsNotNull(deployment, "Could not find expected '{0}' deployment", Config.EmbeddingsDeployment); + Assert.That.ContainsString(Config.EmbeddingsDeployment, deployment.Id, "ID"); + Assert.AreEqual(Config.EmbeddingsDeployment, deployment.Name, "Wrong deployment name"); + Assert.AreEqual(Config.ResourceGroup, deployment.ResourceGroup, "Wrong deployment group"); + Assert.That.IsPopulatedString(deployment.ModelName, "Model name"); + Assert.That.IsPopulatedString(deployment.ModelFormat, "Model format"); + Assert.AreEqual(false, deployment.ChatCompletionCapable, "Chat completion capability"); + Assert.AreEqual(true, deployment.EmbeddingsCapable, "Embeddings capability"); + } + + [TestMethod] + public async Task ListDeploymentsInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string name = "name"; + + HasException(await client.GetAllDeploymentsAsync(null!, rg, name, Token)); + HasException(await client.GetAllDeploymentsAsync(id, "", name, Token)); + HasException(await client.GetAllDeploymentsAsync(id, rg, " ", Token)); + } + + + [TestMethod] + public async Task ListModelsAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + var result = await client.GetAllModelsAsync(Config.SubscriptionId, Config.Region, Token); + result.ThrowOnFail("getting all models"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty models"); + var models = result.Value; + + var model = models.FirstOrDefault(m => m.IsChatCapable); + Assert.That.IsNotDefault(model, "Could not find any chat capable model"); + Assert.That.IsPopulatedString(model.Name, "Name"); + Assert.That.IsPopulatedString(model.Kind, "Name"); + Assert.That.IsPopulatedString(model.Version, "Version"); + Assert.That.IsPopulatedString(model.Format, "Format"); + Assert.That.IsPopulatedString(model.SkuName, "Sku name"); + Assert.AreEqual(true, model.IsChatCapable, "Chat capability"); + Assert.AreEqual(false, model.IsEmbeddingsCapable, "Embeddings capability"); + Assert.AreEqual(false, model.IsImageCapable, "Image capability"); + + model = models.FirstOrDefault(m => m.IsEmbeddingsCapable); + Assert.That.IsNotDefault(model, "Could not find any embedding capable model"); + Assert.That.IsPopulatedString(model.Name, "Name"); + Assert.That.IsPopulatedString(model.Kind, "Name"); + Assert.That.IsPopulatedString(model.Version, "Version"); + Assert.That.IsPopulatedString(model.Format, "Format"); + Assert.That.IsPopulatedString(model.SkuName, "Sku name"); + Assert.AreEqual(false, model.IsChatCapable, "Chat capability"); + Assert.AreEqual(true, model.IsEmbeddingsCapable, "Embeddings capability"); + Assert.AreEqual(false, model.IsImageCapable, "Image capability"); + + model = models.FirstOrDefault(m => m.IsImageCapable); + Assert.That.IsNotDefault(model, "Could not find any image capable model"); + Assert.That.IsPopulatedString(model.Name, "Name"); + Assert.That.IsPopulatedString(model.Kind, "Name"); + Assert.That.IsPopulatedString(model.Version, "Version"); + Assert.That.IsPopulatedString(model.Format, "Format"); + Assert.That.IsPopulatedString(model.SkuName, "Sku name"); + Assert.AreEqual(false, model.IsChatCapable, "Chat capability"); + Assert.AreEqual(false, model.IsEmbeddingsCapable, "Embeddings capability"); + Assert.AreEqual(true, model.IsImageCapable, "Image capability"); + + model = models.FirstOrDefault(m => m.DefaultCapacity > 0); + Assert.That.IsNotDefault(model, "Could not find any model with a default capacity greater than 0"); + + model = models.FirstOrDefault(m => m.IsDeprecated); + Assert.That.IsNotDefault(model, "Could not find any model that is NOT deprecated"); + + model = models.FirstOrDefault(m => m.IsDeprecated); + Assert.That.IsNotDefault(model, "Could not find any model that is deprecated"); + } + + [TestMethod] + public async Task ListModelsInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + HasException(await client.GetAllModelsAsync(null!, Config.Region, Token)); + HasException(await client.GetAllModelsAsync(Config.SubscriptionId, "", Token)); + } + + [TestMethod] + public async Task ListModelUsageAsync() + { + using ICognitiveServicesClient client = CreateClient(); + + var result = await client.GetAllModelUsageAsync(Config.SubscriptionId, Config.Region, Token); + result.ThrowOnFail("getting all model usages"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty model usages"); + + // Do general deserialization welfare checks + Assert.IsTrue(result.Value.Any(u => Math.Abs(u.CurrentValue) > 0.001), "Nothing found with valid current value"); + Assert.IsTrue(result.Value.Any(u => Math.Abs(u.Limit) > 0.001), "Nothing found with valid limit"); + Assert.IsTrue(result.Value.Any(u => !string.IsNullOrWhiteSpace(u.Unit)), "Nothing found with valid unit"); + Assert.IsTrue(result.Value.Any(u => !string.IsNullOrWhiteSpace(u.Name.LocalizedValue)), "Nothing found with valid localized name"); + Assert.IsTrue(result.Value.Any(u => !string.IsNullOrWhiteSpace(u.Name.Value)), "Nothing found with valid name value"); + + // Let's see if we can find the gpt-4 tokens model usage + var usage = result.Value.FirstOrDefault(u => u.Name.Value?.EndsWith($".gpt-4") == true && u.Name.LocalizedValue?.Contains("Token") == true); + Assert.That.IsNotDefault(usage, "Could not find GPT4 model tokens usage"); + + Assert.IsTrue(usage.CurrentValue > 0, "Current value was not greater than 0: {0}", usage.CurrentValue); + Assert.IsTrue(usage.Limit > 0, "Limit was not greater than 0: {0}", usage.Limit); + Assert.AreEqual("Count", usage.Unit, "Unit"); + Assert.That.IsPopulatedString(usage.Name.LocalizedValue, "Localized name"); + Assert.That.IsPopulatedString(usage.Name.Value, "Name Value"); + } + + [TestMethod] + public async Task ListModelUsageInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + HasException(await client.GetAllModelUsageAsync(null!, Config.Region, Token)); + HasException(await client.GetAllModelUsageAsync(Config.SubscriptionId, " ", Token)); + } + + [TestMethod] + [DataRow(true, DisplayName = "chat deployment")] + [DataRow(false, DisplayName = "embeddings deployment")] + public async Task CreateAndDeleteDeploymentAsync(bool isChat) + { + using ICognitiveServicesClient client = CreateClient(); + string name = GenerateName((isChat ? "chat" : "embed") + "dep"); + + (string modelName, string modelVersion) = isChat + ? (Config.ChatModelName, Config.ChatModelVersion) + : (Config.EmbeddingsModelName, Config.EmbeddingsModelVersion); + + try + { + var result = await client.CreateDeploymentAsync( + Config.SubscriptionId, + Config.ResourceGroup, + Config.AIServicesResource, + name, + modelName, + modelVersion, + "OpenAI", + "1", + Token); + result.ThrowOnFail("creating deployment"); + + Assert.That.IsNotDefault(result.Value, "Invalid deployment info"); + Assert.That.ContainsString(name, result.Value.Id, "ID"); + Assert.AreEqual(name, result.Value.Name, "Wrong deployment name"); + Assert.AreEqual(Config.ResourceGroup, result.Value.ResourceGroup, "Wrong deployment resource group"); + Assert.That.IsPopulatedString(result.Value.ModelName, "Model name"); + Assert.That.IsPopulatedString(result.Value.ModelFormat, "Model format"); + Assert.AreEqual(isChat, result.Value.ChatCompletionCapable, "Chat completion capability"); + Assert.AreEqual(!isChat, result.Value.EmbeddingsCapable, "Embeddings capability"); + } + finally + { + var delResult = await client.DeleteDeploymentAsync(Config.SubscriptionId, Config.ResourceGroup, Config.AIServicesResource, name, Token); + delResult.ThrowOnFail("deleting deployment"); + } + } + + [TestMethod] + public async Task CreateDeploymentInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string res = Config.AIServicesResource; + string name = "name"; + string model = "model"; + string version = "version"; + string format = "OpenAI"; + string cap = "1"; + + var args = new ValueTuple>[] + { + ("", rg, res, name, model, version, format, cap), + (id, null!, res, name, model, version, format, cap), + (id, rg, " ", name, model, version, format, cap), + (id, rg, res, "", model, version, format, cap), + (id, rg, res, name, null!, version, format, cap), + (id, rg, res, name, model, " ", format, cap), + (id, rg, res, name, model, version, null!, cap), + (id, rg, res, name, model, version, format, ""), + }; + + foreach (var arg in args) + { + bool created = false; + try + { + var result = await client.CreateDeploymentAsync( + arg.Item1, arg.Item2, arg.Item3, arg.Item4, arg.Item5, arg.Item6, arg.Item7, arg.Item8, Token); + created = result.IsSuccess; + HasException(result); + } + finally + { + if (created) + { + await client.DeleteDeploymentAsync(id, rg, res, name, Token); + } + } + } + } + + [TestMethod] + public async Task DeleteDeploymentNotExistAsync() + { + using ICognitiveServicesClient client = CreateClient(); + var result = await client.DeleteDeploymentAsync( + Config.SubscriptionId, + Config.ResourceGroup, + Config.AIServicesResource, + "this_does_not_exist" + Guid.NewGuid(), + Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + } + + [TestMethod] + public async Task DeleteDeploymentInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + HasException(await client.DeleteDeploymentAsync(null!, Config.ResourceGroup, Config.AIServicesResource, "name", Token)); + HasException(await client.DeleteDeploymentAsync(Config.SubscriptionId, "", Config.AIServicesResource, "name", Token)); + HasException(await client.DeleteDeploymentAsync(Config.SubscriptionId, Config.ResourceGroup, " ", "name", Token)); + HasException(await client.DeleteDeploymentAsync(Config.SubscriptionId, Config.ResourceGroup, Config.AIServicesResource, null!, Token)); + } + + [TestMethod] + public async Task GetCognitiveServicesResourceKeyAsync() + { + using ICognitiveServicesClient client = CreateClient(); + var result = await client.GetResourceKeysFromNameAsync(Config.SubscriptionId, Config.ResourceGroup, Config.CognitiveServicesResource, Token); + result.ThrowOnFail("getting resource key"); + Assert.That.IsPopulatedString(result.Value.Item1, "Key1"); + } + + [TestMethod] + public async Task GetCognitiveServicesResourceKeyInvalidArgAsync() + { + using ICognitiveServicesClient client = CreateClient(); + HasException(await client.GetResourceKeysFromNameAsync(null!, Config.ResourceGroup, Config.CognitiveServicesResource, Token)); + HasException(await client.GetResourceKeysFromNameAsync(Config.SubscriptionId, "", Config.CognitiveServicesResource, Token)); + HasException(await client.GetResourceKeysFromNameAsync(Config.SubscriptionId, Config.ResourceGroup, " ", Token)); + } + + [TestMethod] + public async Task ListSearchResourcesAsync() + { + using ISearchClient client = CreateClient(); + + // all + var result = await client.GetAllAsync(Config.SubscriptionId, null, Token); + result.ThrowOnFail("getting all search resources"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty resources"); + + var resource = result.Value.FirstOrDefault(r => string.Equals(Config.SearchResource, r.Name)); + Assert.IsNotNull(resource, "Could not find expected '{0}' resource", Config.SearchResource); + Assert.AreEqual(ResourceKind.Search, resource.Kind, "Wrong resource kind"); + Assert.That.ContainsString(Config.SearchResource, resource.Id, "ID"); + Assert.AreEqual(Config.SearchResource, resource.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, resource.Group, "Wrong resource group"); + Assert.That.IsPopulatedString(resource.RegionLocation, "Region"); + Assert.That.IsPopulatedString(resource.Endpoint, "Endpoint"); + + // only in specific region + result = await client.GetAllAsync(Config.SubscriptionId, Config.Region, Token); + result.ThrowOnFail($"getting '{Config.Region}' search resources"); + Assert.IsTrue(result.Value?.Length > 0, "Null or empty resources"); + Assert.IsFalse( + result.Value.Any(r => !string.Equals(Config.Region, r.RegionLocation, StringComparison.OrdinalIgnoreCase)), + "Found resources from other regions"); + } + + [TestMethod] + public async Task ListSearchResourcesInvalidArgAsync() + { + using ISearchClient client = CreateClient(); + HasException(await client.GetAllAsync(null!, Config.Region, Token)); + } + + [TestMethod] + public async Task GetSearchResourceAsync() + { + using ISearchClient client = CreateClient(); + + var result = await client.GetFromNameAsync(Config.SubscriptionId, Config.ResourceGroup, Config.SearchResource, Token); + result.ThrowOnFail("getting search resource"); + Assert.IsNotNull(result.Value, "Null resource info"); + Assert.AreEqual(ResourceKind.Search, result.Value.Kind, "Wrong resource kind"); + Assert.That.ContainsString(Config.SearchResource, result.Value.Id, "ID"); + Assert.AreEqual(Config.SearchResource, result.Value.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, result.Value.Group, "Wrong resource group"); + Assert.That.IsPopulatedString(result.Value.Endpoint, "Endpoint"); + + // TODO FIXME: Right now we are using "az resource list" with a resource type filter to get all search resources + // which returns the short region name (e.g. eastus). Unfortunately, creating a search resource, or getting a single + // one using "az search service show" returns the 'display' region name (e.g. East US). + // Apply the ostrich approach liberally here and just check that the region has something set + Assert.That.IsPopulatedString(result.Value.RegionLocation, "Region"); + } + + [TestMethod] + public async Task GetSearchResourceInvalidArgAsync() + { + using ISearchClient client = CreateClient(); + + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string name = "name"; + + HasException(await client.GetFromNameAsync(null!, rg, name, Token)); + HasException(await client.GetFromNameAsync(id, "", name, Token)); + HasException(await client.GetFromNameAsync(id, rg, " ", Token)); + } + + [TestMethod] + public async Task GetSearchResourceNotExistAsync() + { + using ISearchClient client = CreateClient(); + var result = await client.GetFromNameAsync( + Config.SubscriptionId, + Config.ResourceGroup, + "this_does_not_exist" + Guid.NewGuid(), + Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + Assert.IsNull(result.Value, "Null resource info"); + } + + [TestMethod] + public async Task CreateAndDeleteSearchResourceAsync() + { + using ISearchClient client = CreateClient(); + string name = GenerateName("sr"); + + try + { + var result = await client.CreateAsync(Config.SubscriptionId, Config.ResourceGroup, Config.Region, name, Token); + result.ThrowOnFail("creating search resource"); + Assert.AreEqual(ResourceKind.Search, result.Value.Kind, "Wrong resource kind"); + Assert.That.ContainsString(name, result.Value.Id, "ID"); + Assert.AreEqual(name, result.Value.Name, "Wrong resource name"); + Assert.AreEqual(Config.ResourceGroup, result.Value.Group, "Wrong resource group"); + Assert.That.IsPopulatedString(result.Value.Endpoint, "Endpoint"); + + // TODO FIXME: Right now we are using "az resource list" with a resource type filter to get all search resources + // which returns the short region name (e.g. eastus). Unfortunately, creating a search resource, or getting a single + // one using "az search service show" returns the 'display' region name (e.g. East US). + // Apply the ostrich approach liberally here and just check that the region has something set + Assert.That.IsPopulatedString(result.Value.RegionLocation, "Region"); + } + finally + { + var delResult = await client.DeleteAsync(Config.SubscriptionId, Config.ResourceGroup, name, Token); + delResult.ThrowOnFail("deleting search resource"); + } + } + + [TestMethod] + public async Task CreateSearchResourceInvalidArg() + { + using ISearchClient client = CreateClient(); + string id = Config.SubscriptionId; + string rg = Config.ResourceGroup; + string region = Config.Region; + string name = "this-is-a-test"; + + var args = new ValueTuple[] + { + ("", rg, region, name), + (id, null!, region, name), + (id, rg, " ", name), + (id, rg, region, null!), + }; + + foreach (var arg in args) + { + bool created = false; + try + { + var result = await client.CreateAsync(arg.Item1, arg.Item2, arg.Item3, arg.Item4, Token); + created = result.IsSuccess; + HasException(result); + } + finally + { + if (created) + { + await client.DeleteAsync(id, rg, name, Token); + } + } + } + } + + [TestMethod] + public async Task DeleteSearchResourceNotExistAsync() + { + using ISearchClient client = CreateClient(); + var result = await client.DeleteAsync( + Config.SubscriptionId, + Config.ResourceGroup, + "this_does_not_exist" + Guid.NewGuid(), + Token); + Assert.AreEqual(ClientOutcome.Success, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + } + + [TestMethod] + public async Task DeleteSearchResourceInvalidArgAsync() + { + using ISearchClient client = CreateClient(); + HasException(await client.DeleteAsync(null!, Config.ResourceGroup, "name", Token)); + HasException(await client.DeleteAsync(Config.SubscriptionId, "", "name", Token)); + HasException(await client.DeleteAsync(Config.SubscriptionId, Config.ResourceGroup, " ", Token)); + } + + [TestMethod] + public async Task GetSearchResourceKeyAsync() + { + using ISearchClient client = CreateClient(); + var result = await client.GetKeysAsync(Config.SubscriptionId, Config.ResourceGroup, Config.SearchResource, Token); + result.ThrowOnFail("getting search resource key"); + Assert.That.IsPopulatedString(result.Value.Item1, "Key1"); + } + + #region helper methods and classes + + private AzCliClient CreateClient() + { + var environmentVariables = new Dictionary() + { + { "AZURE_HTTP_USER_AGENT", "ai-integration-tests" }, + // TODO add HTTP_PROXY, HTTPS_PROXY, and REQUESTS_CA_BUNDLE to enable the use of the Test proxy at a later point + }; + + return new AzCliClient(environmentVariables); + } + + private static void HasException(IClientResult result, ClientOutcome expectedOutcome = ClientOutcome.Failed) + { + Assert.AreEqual(expectedOutcome, result.Outcome, "Wrong outcome. Details: '{0}'", result.ErrorDetails); + Assert.IsTrue(result.Exception is TException, "Wrong type of exception. Actual: {0}", result.Exception?.GetType().FullName); + } + + private static string GenerateName(string type) + { + string prefix = "azcli-integrationtest"; + string datePart = DateTime.Now.ToString("yyyyMMddHHmmssffff"); + return $"{prefix}-{type}-{datePart}"; + } + + private static string Acronym(ResourceKind kind) + { + return string.Join( + string.Empty, + kind.ToString().Where(char.IsUpper).Select(char.ToLowerInvariant)); + } + + protected readonly struct TestConfig + { + public string SubscriptionId { get; init; } + public string SubscriptionName { get; init; } + public string ResourceGroup { get; init; } + public string Region { get; init; } + public string AIServicesResource { get; init; } + public string AIHub { get; init; } + public string AIProject { get; init; } + public string OpenAiResource { get; init; } + public string CognitiveServicesResource { get; init; } + public string SpeechResource { get; init; } + public string SearchResource { get; init; } + public string VisionResource { get; init; } + public string ChatDeployment { get; init; } + public string EmbeddingsDeployment { get; init; } + public string ChatModelName { get; init; } + public string ChatModelVersion { get; init; } + public string EmbeddingsModelName { get; init; } + public string EmbeddingsModelVersion { get; init; } + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj new file mode 100644 index 00000000..9968829f --- /dev/null +++ b/tests/UnitTests/UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + +