diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index aa9064c44bd..59d28532634 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -61,6 +61,8 @@ words: - lipgloss - gopxl - subcmd + - genproto + - errdetails languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 8ff24b1daef..755cd9ac8ab 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -650,6 +650,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(containerregistry.NewRemoteBuildManager) container.MustRegisterSingleton(keyvault.NewKeyVaultService) container.MustRegisterSingleton(storage.NewFileShareService) + container.MustRegisterSingleton(ai.NewAiModelService) container.MustRegisterScoped(project.NewContainerHelper) container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*project.ContainerHelper] { @@ -912,6 +913,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(grpcserver.NewExtensionService) container.MustRegisterSingleton(grpcserver.NewServiceTargetService) container.MustRegisterSingleton(grpcserver.NewFrameworkService) + container.MustRegisterSingleton(grpcserver.NewAiModelService) // Required for nested actions called from composite actions like 'up' registerAction[*cmd.ProvisionAction](container, "azd-provision-action") diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index e9dad1d5983..43958e70aab 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -14,6 +14,7 @@ Table of Contents - [Deployment Service](#deployment-service) - [Account Service](#account-service) - [Prompt Service](#prompt-service) + - [AI Model Service](#ai-model-service) - [Event Service](#event-service) - [Container Service](#container-service) - [Framework Service](#framework-service) @@ -1287,6 +1288,7 @@ The following are a list of available gRPC services for extension developer to i - [Deployment Service](#deployment-service) - [Account Service](#account-service) - [Prompt Service](#prompt-service) +- [AI Model Service](#ai-model-service) - [Event Service](#event-service) - [Container Service](#container-service) - [Framework Service](#framework-service) @@ -1807,6 +1809,225 @@ Prompts the user to select a resource from a resource group. - **Response:** _PromptResourceGroupResourceResponse_ - Contains **ResourceExtended** +#### PromptAiModel + +Prompts the user to select an AI model from the AI catalog. + +- **Request:** _PromptAiModelRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `filter` (AiModelFilterOptions): optional model filters + - `select_options` (SelectOptions): optional prompt customization (currently `message` override) + - `quota` (QuotaCheckOptions): optional quota-aware filtering +- **Response:** _PromptAiModelResponse_ + - Contains `model` (_AiModel_) + +Effective location is defined by `filter.locations`. +When `filter.locations` is empty, models are considered across subscription locations. +When `quota` is set: +- if `filter.locations` is empty, quota is evaluated across available locations +- if `filter.locations` contains exactly one location, quota is evaluated at that location +- if `filter.locations` contains multiple locations, quota is evaluated across that provided location set +Models are kept when quota is sufficient in at least one effective location. +Effective location only controls model eligibility for selection; returned `model.locations` remains canonical. + +#### PromptAiDeployment + +Prompts the user to select deployment configuration (version, SKU, and capacity). + +- **Request:** _PromptAiDeploymentRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `model_name` (string): target model + - `quota` (QuotaCheckOptions): optional quota-aware filtering + - `options` (AiModelDeploymentOptions): optional filters for locations/versions/skus/capacity + - `use_default_version` (bool): use default version when available; otherwise prompt for version + - `use_default_capacity` (bool): skip capacity prompt when true + - `include_finetune_skus` (bool): include fine-tune SKUs +- **Response:** _PromptAiDeploymentResponse_ + - Contains `deployment` (_AiModelDeployment_) + +Effective location is defined by `options.locations`. +When `options.locations` is empty, model catalog is considered across subscription locations. +When `quota` is set, exactly one effective location is required via `options.locations`. +SKU selection is always prompted when one or more valid SKU candidates are available. + +#### PromptAiLocationWithQuota + +Prompts the user to select a location that satisfies quota requirements. + +- **Request:** _PromptAiLocationWithQuotaRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `requirements` (repeated QuotaRequirement): usage meter requirements + - `allowed_locations` (repeated string): optional allowed location filter + - `select_options` (SelectOptions): optional prompt customization (currently `message` override) +- **Response:** _PromptAiLocationWithQuotaResponse_ + - Contains `location` (_Location_) + +#### PromptAiModelLocationWithQuota + +Prompts the user to select a location for a specific model and shows quota available in list labels. + +- **Request:** _PromptAiModelLocationWithQuotaRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `model_name` (string): required model name + - `allowed_locations` (repeated string): optional location filter + - `quota` (QuotaCheckOptions): optional minimum available requirement (defaults to 1) + - `select_options` (SelectOptions): optional prompt customization (currently `message` override) +- **Response:** _PromptAiModelLocationWithQuotaResponse_ + - Contains `location` (_Location_) and `max_remaining_quota` (double, maximum quota available across model SKUs) + +--- + +### AI Model Service + +This service provides non-interactive AI catalog, deployment resolution, and quota usage primitives. + +> See [ai_model.proto](../grpc/proto/ai_model.proto) for more details. + +#### ListModels + +Returns available AI models for a subscription. + +- **Request:** _ListModelsRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `filter` (AiModelFilterOptions), optional: + - `locations` (repeated string) + - `capabilities` (repeated string) + - `formats` (repeated string) + - `statuses` (repeated string) + - `exclude_model_names` (repeated string) +- **Response:** _ListModelsResponse_ + - `models` (repeated _AiModel_) + +If `filter.locations` is empty, models are listed across all subscription locations. +When `filter.locations` is provided, it limits which models are returned, but each returned model still contains canonical +`locations`. + +#### ResolveModelDeployments + +Resolves valid deployment configurations for a model. + +- **Request:** _ResolveModelDeploymentsRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `model_name` (string) + - `options` (AiModelDeploymentOptions), optional: + - `locations` (repeated string) + - `versions` (repeated string) + - `skus` (repeated string) + - `capacity` (optional int32) + - `quota` (QuotaCheckOptions), optional: + - `min_remaining_capacity` (double) +- **Response:** _ResolveModelDeploymentsResponse_ + - `deployments` (repeated _AiModelDeployment_) + - each deployment includes model/version/location/SKU/capacity and optional `remaining_quota` + +If `options.locations` is empty, all subscription locations are considered. +When `quota` is set, `options.locations` must contain exactly one location. + +#### ListUsages + +Returns usage meter data for a specific location. + +- **Request:** _ListUsagesRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `location` (string), required (no fallback from `azure_context.scope.location`) +- **Response:** _ListUsagesResponse_ + - `usages` (repeated _AiModelUsage_) with: + - `name` (string) + - `current_value` (double) + - `limit` (double) + +#### ListLocationsWithQuota + +Returns locations that satisfy all provided quota requirements. + +- **Request:** _ListLocationsWithQuotaRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `requirements` (repeated QuotaRequirement) + - `allowed_locations` (repeated string), optional +- **Response:** _ListLocationsWithQuotaResponse_ + - `locations` (repeated _Location_) + +#### ListModelLocationsWithQuota + +Returns locations where a model has sufficient quota, with per-location available quota. + +- **Request:** _ListModelLocationsWithQuotaRequest_ + - `azure_context` (AzureContext) with `scope.subscription_id` required + - `model_name` (string), required + - `allowed_locations` (repeated string), optional + - `quota` (QuotaCheckOptions), optional (`min_remaining_capacity` defaults to `1`) +- **Response:** _ListModelLocationsWithQuotaResponse_ + - `locations` (repeated _ModelLocationQuota_) + - each entry includes `location` (_Location_) and `max_remaining_quota` (double, maximum quota available) + +#### AI Error Reasons + +AI model and AI prompt APIs return structured gRPC errors with `ErrorInfo`: + +- `domain`: `azd.ai` +- `reason`: one of: + - `AI_MISSING_SUBSCRIPTION` + - `AI_LOCATION_REQUIRED` + - `AI_QUOTA_LOCATION_REQUIRED` + - `AI_MODEL_NOT_FOUND` + - `AI_NO_MODELS_MATCH` + - `AI_NO_DEPLOYMENT_MATCH` + - `AI_NO_VALID_SKUS` + - `AI_NO_LOCATIONS_WITH_QUOTA` + - `AI_INVALID_CAPACITY` + - `AI_INTERACTIVE_REQUIRED` + +Some reasons include `ErrorInfo.metadata` for additional context (for example `model_name`, `version`, `sku`). + +Extensions should prefer `ErrorInfo.reason` over parsing error text when handling recoverable branches. + +**Example Usage (Go):** + +```go +ctx := azdext.WithAccessToken(cmd.Context()) +azdClient, err := azdext.NewAzdClient() +if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) +} +defer azdClient.Close() + +azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + SubscriptionId: "", + Location: "eastus2", + }, +} + +modelResp, err := azdClient.Prompt().PromptAiModel(ctx, &azdext.PromptAiModelRequest{ + AzureContext: azureContext, + Filter: &azdext.AiModelFilterOptions{ + Locations: []string{"eastus2"}, + Capabilities: []string{"chatCompletion"}, + }, + Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1}, +}) +if err != nil { + return fmt.Errorf("selecting model: %w", err) +} + +deploymentResp, err := azdClient.Prompt().PromptAiDeployment(ctx, &azdext.PromptAiDeploymentRequest{ + AzureContext: azureContext, + ModelName: modelResp.Model.Name, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{"eastus2"}, + }, + Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1}, +}) +if err != nil { + return fmt.Errorf("selecting deployment: %w", err) +} + +fmt.Printf("Selected deployment: %s %s %s\n", + deploymentResp.Deployment.ModelName, + deploymentResp.Deployment.Version, + deploymentResp.Deployment.Sku.Name) +``` + --- ### Event Service diff --git a/cli/azd/extensions/microsoft.azd.demo/README.md b/cli/azd/extensions/microsoft.azd.demo/README.md index 2998127b7f0..34c1c908289 100644 --- a/cli/azd/extensions/microsoft.azd.demo/README.md +++ b/cli/azd/extensions/microsoft.azd.demo/README.md @@ -58,6 +58,24 @@ Displays a list of Azure resources based on the current logged in user, and subs Displays a list of Azure resources based on the current logged in user, subscription and resource group filtered by resource type and kind. +### `ai` + +The `ai` command demonstrates AI model catalog, deployment selection, and quota capabilities through interactive flows. + +#### Usage: `azd demo ai ` + +#### `azd demo ai models` + +Browse available AI models interactively and view model details, including locations, versions, SKUs, and capacity constraints. + +#### `azd demo ai deployment` + +Select model/version/SKU/capacity and resolve a valid deployment configuration. + +#### `azd demo ai quota` + +View usage meters and limits for a selected location. + ### `metadata` The `metadata` command demonstrates the metadata capability, which provides command structure and configuration schemas. @@ -106,4 +124,4 @@ This `listen` command is required when your extension leverages `LifecycleEvents #### Usage: `azd demo listen` -This command is invoked by `azd` to allow your extension to subscribe to lifecycle events within the current `azd` project and services. \ No newline at end of file +This command is invoked by `azd` to allow your extension to subscribe to lifecycle events within the current `azd` project and services. diff --git a/cli/azd/extensions/microsoft.azd.demo/extension.yaml b/cli/azd/extensions/microsoft.azd.demo/extension.yaml index 43597be80ce..4c0da8ccf2d 100644 --- a/cli/azd/extensions/microsoft.azd.demo/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.demo/extension.yaml @@ -24,6 +24,15 @@ examples: - name: prompt description: Display prompt capabilities. usage: azd demo prompt + - name: ai models + description: Browse available AI models interactively. + usage: azd demo ai models + - name: ai deployment + description: Select model/version/SKU/capacity and resolve a deployment configuration. + usage: azd demo ai deployment + - name: ai quota + description: View usage meters and limits for a selected location. + usage: azd demo ai quota - name: mcp description: Start MCP server with demo tools. - usage: azd demo mcp start \ No newline at end of file + usage: azd demo mcp start diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go new file mode 100644 index 00000000000..e0efae03a05 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func newAiCommand() *cobra.Command { + aiCmd := &cobra.Command{ + Use: "ai", + Short: "Interactive AI model discovery, deployment, and quota demos.", + } + + aiCmd.AddCommand(newAiModelsCommand()) + aiCmd.AddCommand(newAiQuotaCommand()) + aiCmd.AddCommand(newAiDeploymentCommand()) + + return aiCmd +} + +// promptSubscription prompts the user to select an Azure subscription. +func promptSubscription(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { + resp, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{ + Message: "Select an Azure subscription", + }) + if err != nil { + return "", fmt.Errorf("selecting subscription: %w", err) + } + return resp.Subscription.Id, nil +} + +// promptLocation prompts the user to select an Azure location. +func promptLocation(ctx context.Context, azdClient *azdext.AzdClient, subId string) (string, error) { + resp, err := azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ + AzureContext: &azdext.AzureContext{ + Scope: &azdext.AzureScope{SubscriptionId: subId}, + }, + }) + if err != nil { + return "", fmt.Errorf("selecting location: %w", err) + } + return resp.Location.Name, nil +} + +func newAiModelsCommand() *cobra.Command { + return &cobra.Command{ + Use: "models", + Short: "Browse available AI models interactively.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + subId, err := promptSubscription(ctx, azdClient) + if err != nil { + return err + } + + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + SubscriptionId: subId, + }, + } + + modelResp, err := azdClient.Prompt().PromptAiModel(ctx, &azdext.PromptAiModelRequest{ + AzureContext: azureContext, + Filter: &azdext.AiModelFilterOptions{ + Capabilities: []string{"chatCompletion"}, + }, + }) + if err != nil { + return fmt.Errorf("selecting model: %w", err) + } + + printAiModelDetails(modelResp.Model) + + return nil + }, + } +} + +func printAiModelDetails(model *azdext.AiModel) { + fmt.Println() + color.HiWhite("Model Details") + fmt.Printf(" Name: %s\n", color.CyanString(model.Name)) + fmt.Printf(" Format: %s\n", model.Format) + fmt.Printf(" Status: %s\n", model.LifecycleStatus) + + if len(model.Capabilities) > 0 { + capabilities := slices.Clone(model.Capabilities) + slices.Sort(capabilities) + fmt.Printf(" Capabilities (%d):\n", len(capabilities)) + for _, capability := range capabilities { + fmt.Printf(" - %s\n", capability) + } + } + + locations := slices.Clone(model.Locations) + slices.Sort(locations) + fmt.Printf(" Locations (%d):\n", len(locations)) + for _, location := range locations { + fmt.Printf(" - %s\n", location) + } + + if len(model.Versions) == 0 { + return + } + + versions := slices.Clone(model.Versions) + slices.SortFunc(versions, func(a, b *azdext.AiModelVersion) int { + return cmp.Compare(a.Version, b.Version) + }) + + fmt.Printf(" Versions (%d):\n", len(versions)) + for _, version := range versions { + defaultLabel := "" + if version.IsDefault { + defaultLabel = color.YellowString(" (default)") + } + fmt.Printf(" - Version: %s%s\n", version.Version, defaultLabel) + + skus := slices.Clone(version.Skus) + slices.SortFunc(skus, func(a, b *azdext.AiModelSku) int { + if n := cmp.Compare(a.Name, b.Name); n != 0 { + return n + } + return cmp.Compare(a.UsageName, b.UsageName) + }) + + for _, sku := range skus { + fmt.Printf(" SKU: %s\n", color.CyanString(sku.Name)) + fmt.Printf(" Usage: %s\n", sku.UsageName) + fmt.Printf(" Capacity: default=%d, min=%d, max=%d, step=%d\n", + sku.DefaultCapacity, sku.MinCapacity, sku.MaxCapacity, sku.CapacityStep) + } + } +} + +func newAiQuotaCommand() *cobra.Command { + return &cobra.Command{ + Use: "quota", + Short: "View usage meters and limits for a selected location.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + subId, err := promptSubscription(ctx, azdClient) + if err != nil { + return err + } + + location, err := promptLocation(ctx, azdClient, subId) + if err != nil { + return err + } + + color.Cyan("Listing AI model usages...") + fmt.Printf("Subscription: %s\n", subId) + fmt.Printf("Location: %s\n\n", location) + + resp, err := azdClient.Ai().ListUsages(ctx, &azdext.ListUsagesRequest{ + AzureContext: &azdext.AzureContext{ + Scope: &azdext.AzureScope{SubscriptionId: subId}, + }, + Location: location, + }) + if err != nil { + return fmt.Errorf("listing usages: %w", err) + } + + color.HiWhite("Found %d usage entries:\n", len(resp.Usages)) + for _, usage := range resp.Usages { + remaining := usage.Limit - usage.CurrentValue + usageColor := color.HiGreenString + if remaining <= 0 { + usageColor = color.HiRedString + } else if remaining < usage.Limit*0.2 { + usageColor = color.HiYellowString + } + + fmt.Printf(" %s: %s / %.0f\n", + color.CyanString(usage.Name), + usageColor("%.0f", usage.CurrentValue), + usage.Limit, + ) + } + + return nil + }, + } +} + +func newAiDeploymentCommand() *cobra.Command { + return &cobra.Command{ + Use: "deployment", + Short: "Select model/version/SKU/capacity and resolve a valid deployment configuration.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + subId, err := promptSubscription(ctx, azdClient) + if err != nil { + return err + } + + location, err := promptLocation(ctx, azdClient, subId) + if err != nil { + return err + } + + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + SubscriptionId: subId, + Location: location, + }, + } + + // Use PromptAiModel to let user select a model (scoped to chosen location) + color.Cyan("Loading models for %s...", location) + modelResp, err := azdClient.Prompt().PromptAiModel(ctx, &azdext.PromptAiModelRequest{ + AzureContext: azureContext, + Filter: &azdext.AiModelFilterOptions{ + Locations: []string{location}, + }, + SelectOptions: &azdext.SelectOptions{ + Message: "Select an AI model to deploy", + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + return fmt.Errorf("selecting model: %w", err) + } + + modelName := modelResp.Model.Name + color.Cyan("\nResolving deployment for %s...", modelName) + + deployResp, err := azdClient.Prompt().PromptAiDeployment(ctx, &azdext.PromptAiDeploymentRequest{ + AzureContext: azureContext, + ModelName: modelName, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{location}, + // Skus: []string{"GlobalStandard", "Standard"}, + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + return fmt.Errorf("resolving deployment: %w", err) + } + + d := deployResp.Deployment + fmt.Println() + color.HiWhite("Deployment Configuration:\n") + fmt.Printf(" Model: %s\n", color.CyanString(d.ModelName)) + fmt.Printf(" Format: %s\n", d.Format) + fmt.Printf(" Version: %s\n", d.Version) + fmt.Printf(" Location: %s\n", d.Location) + fmt.Printf(" SKU: %s\n", d.Sku.Name) + fmt.Printf(" UsageName: %s\n", d.Sku.UsageName) + fmt.Printf(" Capacity: %d\n", d.Capacity) + if d.RemainingQuota != nil { + fmt.Printf(" Remaining: %.0f\n", *d.RemainingQuota) + } + + return nil + }, + } +} diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go index 101cfdcd78c..1cbd12297a1 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/root.go @@ -30,6 +30,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newConfigCommand()) rootCmd.AddCommand(newGhUrlParseCommand()) rootCmd.AddCommand(newMetadataCommand()) + rootCmd.AddCommand(newAiCommand()) return rootCmd } diff --git a/cli/azd/grpc/proto/ai_model.proto b/cli/azd/grpc/proto/ai_model.proto new file mode 100644 index 00000000000..49262225345 --- /dev/null +++ b/cli/azd/grpc/proto/ai_model.proto @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +syntax = "proto3"; + +package azdext; + +option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; + +import "models.proto"; + +// AiModelService provides data-only operations for AI model catalog, +// deployment resolution, and quota/usage from Azure Cognitive Services. +// AzureContext is required for subscription_id. +service AiModelService { + // ListModels returns available AI models, optionally filtered by filter.locations. + // If filter.locations is empty, models are queried across all subscription locations. + // Note: filter.locations controls which models are returned, but each returned model + // keeps canonical metadata (including the full locations list). + rpc ListModels(ListModelsRequest) returns (ListModelsResponse); + + // ResolveModelDeployments returns all valid deployment configs for a model. + // options.locations controls location scoping (empty means all subscription locations). + // If quota is set, options.locations must contain exactly one location. + rpc ResolveModelDeployments(ResolveModelDeploymentsRequest) returns (ResolveModelDeploymentsResponse); + + // ListUsages returns quota/usage data for request.location. + // request.location is required. + rpc ListUsages(ListUsagesRequest) returns (ListUsagesResponse); + + // ListLocationsWithQuota returns locations with sufficient quota. + rpc ListLocationsWithQuota(ListLocationsWithQuotaRequest) returns (ListLocationsWithQuotaResponse); + + // ListModelLocationsWithQuota returns locations where model has sufficient quota. + // Response includes max remaining quota per location for label rendering. + rpc ListModelLocationsWithQuota(ListModelLocationsWithQuotaRequest) returns (ListModelLocationsWithQuotaResponse); +} + +// --- Core model types --- + +message AiModel { + string name = 1; // e.g. "gpt-4o" + string format = 2; // e.g. "OpenAI" + string lifecycle_status = 3; // e.g. "preview", "stable" + repeated string capabilities = 4; // e.g. ["chat", "embeddings"] + repeated AiModelVersion versions = 5; + repeated string locations = 6; // canonical locations where available +} + +message AiModelVersion { + string version = 1; + bool is_default = 2; + repeated AiModelSku skus = 3; +} + +// AiModelSku represents a deployment SKU with capacity constraints. +message AiModelSku { + string name = 1; // e.g. "GlobalStandard" + string usage_name = 2; // join key to usage API, e.g. "OpenAI.Standard.gpt-4o" + int32 default_capacity = 3; + int32 min_capacity = 4; + int32 max_capacity = 5; + int32 capacity_step = 6; +} + +// AiModelDeployment is a fully resolved deployment configuration. +// capacity = deployment-level units; remaining_quota = subscription-level remaining. +message AiModelDeployment { + string model_name = 1; + string format = 2; + string version = 3; + string location = 4; + AiModelSku sku = 5; + int32 capacity = 6; + optional double remaining_quota = 7; // populated when QuotaCheckOptions used +} + +// --- Quota types --- + +// QuotaRequirement: check usage_name has at least min_capacity remaining. +message QuotaRequirement { + string usage_name = 1; + double min_capacity = 2; // 0 defaults to 1 +} + +message AiModelUsage { + string name = 1; // quota usage name + double current_value = 2; + double limit = 3; +} + +// QuotaCheckOptions enables quota-aware filtering. +// Fetches usage data and excludes models/SKUs without sufficient remaining capacity. +message QuotaCheckOptions { + double min_remaining_capacity = 1; // 0 means any remaining > 0 +} + +// --- Filter/options types --- + +message AiModelFilterOptions { + // Restrict which models are returned to ones available in these locations + // (region names like "eastus", "swedencentral"). + // This filter does not rewrite AiModel.locations in the response. + repeated string locations = 1; + + // Include models that expose at least one of these capabilities. + // Matches values from AiModel.capabilities (for example: "chatCompletion"). + repeated string capabilities = 2; + + // Include models whose format matches one of these values. + // Matches AiModel.format exactly (for example: "OpenAI", "Microsoft"). + repeated string formats = 3; + + // Include models whose lifecycle status matches one of these values. + // Matches AiModel.lifecycle_status exactly (for example: "Stable", "Preview"). + repeated string statuses = 4; + + // Exclude models by exact model name (for example: "gpt-4o-mini"). + repeated string exclude_model_names = 5; +} + +// AiModelDeploymentOptions: all fields optional — empty means no filtering. +message AiModelDeploymentOptions { + // Preferred deployment locations. Empty means all subscription locations. + repeated string locations = 1; + // Preferred model versions. Empty means all available versions. + repeated string versions = 2; + // Preferred SKU names. Empty means all available SKUs. + repeated string skus = 3; + // Preferred deployment capacity. If unset, SKU default is used. + optional int32 capacity = 4; +} + +// --- Request/Response messages --- + +message ListModelsRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Optional model filter criteria. Empty means no filtering. + AiModelFilterOptions filter = 2; +} + +message ListModelsResponse { + // Catalog models after applying optional filters. + repeated AiModel models = 1; +} + +message ResolveModelDeploymentsRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Target model name to resolve deployment candidates for. + string model_name = 2; + // Optional deployment filters (locations/versions/SKUs/capacity). + AiModelDeploymentOptions options = 3; + // Optional quota filter. Requires options.locations with exactly one location. + QuotaCheckOptions quota = 4; +} + +message ResolveModelDeploymentsResponse { + // All valid deployment candidates for the requested model and options. + repeated AiModelDeployment deployments = 1; +} + +message ListUsagesRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required location for usage query (no fallback from azure_context.scope.location). + string location = 2; +} + +message ListUsagesResponse { + // Quota usage entries for the requested location. + repeated AiModelUsage usages = 1; +} + +message ListLocationsWithQuotaRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required quota requirements that each returned location must satisfy. + repeated QuotaRequirement requirements = 2; + // Optional allow-list. Empty means all AI Services-supported locations. + repeated string allowed_locations = 3; +} + +message ListLocationsWithQuotaResponse { + // Locations that satisfy all quota requirements. + repeated Location locations = 1; +} + +message ModelLocationQuota { + // Location where model quota was evaluated. + Location location = 1; + double max_remaining_quota = 2; // max remaining quota across model SKUs +} + +message ListModelLocationsWithQuotaRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required model name to evaluate across locations. + string model_name = 2; + // Optional allow-list. Empty means all locations where the model is available. + repeated string allowed_locations = 3; + // Optional min remaining quota threshold. + QuotaCheckOptions quota = 4; +} + +message ListModelLocationsWithQuotaResponse { + // Locations where the model has sufficient remaining quota. + repeated ModelLocationQuota locations = 1; +} diff --git a/cli/azd/grpc/proto/prompt.proto b/cli/azd/grpc/proto/prompt.proto index d226a240167..d320be6d23e 100644 --- a/cli/azd/grpc/proto/prompt.proto +++ b/cli/azd/grpc/proto/prompt.proto @@ -7,6 +7,7 @@ package azdext; option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; import "models.proto"; +import "ai_model.proto"; service PromptService { // PromptSubscription prompts the user to select a subscription. @@ -35,6 +36,31 @@ service PromptService { // PromptResourceGroupResource prompts the user to select a resource from a resource group. rpc PromptResourceGroupResource(PromptResourceGroupResourceRequest) returns (PromptResourceGroupResourceResponse); + + // PromptAiModel prompts the user to select an AI model scoped by effective location. + // Effective location only affects which models are eligible for selection; + // the returned model keeps canonical metadata (including full locations). + // Effective location is defined by filter.locations. + // If filter.locations is empty, models are considered across subscription locations. + // If quota is set: + // - one location in filter.locations: quota is evaluated at that location. + // - multiple locations in filter.locations: quota is evaluated for each location and + // models are kept when quota is sufficient in at least one location. + // - empty filter.locations: quota is evaluated across model-declared locations. + rpc PromptAiModel(PromptAiModelRequest) returns (PromptAiModelResponse); + + // PromptAiDeployment prompts for version, SKU, and capacity sequentially. + // This is the primary interactive primitive for model deployment selection. + // Effective location is defined by options.locations. + // If options.locations is empty, model catalog is considered across subscription locations. + // Quota requires exactly one effective location (via options.locations). + rpc PromptAiDeployment(PromptAiDeploymentRequest) returns (PromptAiDeploymentResponse); + + // PromptAiLocationWithQuota prompts for a location filtered by quota requirements. + rpc PromptAiLocationWithQuota(PromptAiLocationWithQuotaRequest) returns (PromptAiLocationWithQuotaResponse); + + // PromptAiModelLocationWithQuota prompts for a model location and displays remaining quota. + rpc PromptAiModelLocationWithQuota(PromptAiModelLocationWithQuotaRequest) returns (PromptAiModelLocationWithQuotaResponse); } message PromptSubscriptionRequest { @@ -190,3 +216,82 @@ message PromptResourceSelectOptions { message PromptResourceGroupOptions { PromptResourceSelectOptions select_options = 1; } + +// --- AI Model Prompt messages --- +// These messages reference types from ai_model.proto. + +message PromptAiModelRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Optional model filter criteria. + AiModelFilterOptions filter = 2; + // Optional select prompt customization (for example, message override). + SelectOptions select_options = 3; + // Optional quota filter. + // Quota is evaluated using effective locations from filter.locations. + // With multiple locations, a model is kept if any location has sufficient quota. + QuotaCheckOptions quota = 4; +} + +message PromptAiModelResponse { + // Selected model from the filtered catalog. + AiModel model = 1; +} + +message PromptAiDeploymentRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required model name to resolve deployment options for. + string model_name = 2; + // Optional deployment filters (locations/versions/SKUs/capacity). + AiModelDeploymentOptions options = 3; + // Optional quota filter. Requires options.locations with exactly one location. + QuotaCheckOptions quota = 4; + // Skip version prompt and use the default version when available. + bool use_default_version = 5; + // Skip capacity prompt and use resolved/default capacity. + bool use_default_capacity = 6; + // Include fine-tune SKUs (usage names ending with "-finetune"). + bool include_finetune_skus = 7; +} + +message PromptAiDeploymentResponse { + // Selected deployment configuration. + AiModelDeployment deployment = 1; +} + +message PromptAiLocationWithQuotaRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required quota requirements that each returned location must satisfy. + repeated QuotaRequirement requirements = 2; + // Optional allow-list for candidate locations. + repeated string allowed_locations = 3; + // Optional select prompt customization (for example, message override). + SelectOptions select_options = 4; +} + +message PromptAiLocationWithQuotaResponse { + // Selected location. + Location location = 1; +} + +message PromptAiModelLocationWithQuotaRequest { + // Azure context with scope.subscription_id required. + AzureContext azure_context = 1; + // Required model name to evaluate across locations. + string model_name = 2; + // Optional allow-list for candidate locations. + repeated string allowed_locations = 3; + // Optional min remaining quota threshold. + QuotaCheckOptions quota = 4; + // Optional select prompt customization (for example, message override). + SelectOptions select_options = 5; +} + +message PromptAiModelLocationWithQuotaResponse { + // Selected location. + Location location = 1; + // Maximum remaining quota at the selected location across model SKUs. + double max_remaining_quota = 2; +} diff --git a/cli/azd/internal/grpcserver/ai_errors.go b/cli/azd/internal/grpcserver/ai_errors.go new file mode 100644 index 00000000000..947f20376e0 --- /dev/null +++ b/cli/azd/internal/grpcserver/ai_errors.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "errors" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/ai" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func aiStatusError(code codes.Code, reason string, message string, metadata map[string]string) error { + st := status.New(code, message) + withDetails, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: reason, + Domain: azdext.AiErrorDomain, + Metadata: metadata, + }) + if err != nil { + return st.Err() + } + + return withDetails.Err() +} + +func mapAiResolveError(err error, modelName string) error { + switch { + case errors.Is(err, ai.ErrQuotaLocationRequired): + return aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonQuotaLocation, + err.Error(), + nil, + ) + case errors.Is(err, ai.ErrModelNotFound): + return aiStatusError( + codes.NotFound, + azdext.AiErrorReasonModelNotFound, + err.Error(), + map[string]string{"model_name": modelName}, + ) + case errors.Is(err, ai.ErrNoDeploymentMatch): + return aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonNoDeploymentMatch, + err.Error(), + map[string]string{"model_name": modelName}, + ) + default: + return fmt.Errorf("resolving model deployments: %w", err) + } +} diff --git a/cli/azd/internal/grpcserver/ai_model_service.go b/cli/azd/internal/grpcserver/ai_model_service.go new file mode 100644 index 00000000000..48bf795180d --- /dev/null +++ b/cli/azd/internal/grpcserver/ai_model_service.go @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/internal/mapper" + "github.com/azure/azure-dev/cli/azd/pkg/ai" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" +) + +type aiModelService struct { + azdext.UnimplementedAiModelServiceServer + modelService *ai.AiModelService +} + +// NewAiModelService creates a new AI model gRPC service. +func NewAiModelService( + modelService *ai.AiModelService, +) azdext.AiModelServiceServer { + return &aiModelService{ + modelService: modelService, + } +} + +// --- Primitives --- + +func (s *aiModelService) ListModels( + ctx context.Context, req *azdext.ListModelsRequest, +) (*azdext.ListModelsResponse, error) { + subscriptionId, err := requireSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + var filterOpts *ai.FilterOptions + if req.Filter != nil { + filterOpts = protoToFilterOptions(req.Filter) + } + + // Always fetch canonical model data across subscription locations. + // Location filters are applied only as inclusion filters below. + models, err := s.modelService.ListModels(ctx, subscriptionId, nil) + if err != nil { + return nil, fmt.Errorf("listing models: %w", err) + } + + if filterOpts != nil { + models = ai.FilterModels(models, filterOpts) + } + + protoModels := make([]*azdext.AiModel, len(models)) + for i := range models { + if err := mapper.Convert(&models[i], &protoModels[i]); err != nil { + return nil, fmt.Errorf("converting model to proto: %w", err) + } + } + + return &azdext.ListModelsResponse{Models: protoModels}, nil +} + +func (s *aiModelService) ResolveModelDeployments( + ctx context.Context, req *azdext.ResolveModelDeploymentsRequest, +) (*azdext.ResolveModelDeploymentsResponse, error) { + subscriptionId, err := requireSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + options := protoToDeploymentOptions(req.Options) + if options == nil { + options = &ai.DeploymentOptions{} + } + + var deployments []ai.AiModelDeployment + if req.Quota != nil { + quotaOpts := protoToQuotaCheckOptions(req.Quota) + deployments, err = s.modelService.ResolveModelDeploymentsWithQuota( + ctx, subscriptionId, req.ModelName, options, quotaOpts) + } else { + deployments, err = s.modelService.ResolveModelDeployments( + ctx, subscriptionId, req.ModelName, options) + } + if err != nil { + return nil, mapAiResolveError(err, req.ModelName) + } + + protoDeployments := make([]*azdext.AiModelDeployment, len(deployments)) + for i := range deployments { + if err := mapper.Convert(&deployments[i], &protoDeployments[i]); err != nil { + return nil, fmt.Errorf("converting deployment to proto: %w", err) + } + } + + return &azdext.ResolveModelDeploymentsResponse{ + Deployments: protoDeployments, + }, nil +} + +func (s *aiModelService) ListUsages( + ctx context.Context, req *azdext.ListUsagesRequest, +) (*azdext.ListUsagesResponse, error) { + subscriptionId, err := requireSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + if req.Location == "" { + return nil, aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonLocationRequired, + "location is required for listing usages", + nil, + ) + } + + usages, err := s.modelService.ListUsages(ctx, subscriptionId, req.Location) + if err != nil { + return nil, fmt.Errorf("listing usages: %w", err) + } + + protoUsages := make([]*azdext.AiModelUsage, len(usages)) + for i := range usages { + if err := mapper.Convert(&usages[i], &protoUsages[i]); err != nil { + return nil, fmt.Errorf("converting usage to proto: %w", err) + } + } + + return &azdext.ListUsagesResponse{Usages: protoUsages}, nil +} + +func (s *aiModelService) ListLocationsWithQuota( + ctx context.Context, req *azdext.ListLocationsWithQuotaRequest, +) (*azdext.ListLocationsWithQuotaResponse, error) { + subscriptionId, err := requireSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + requirements := make([]ai.QuotaRequirement, len(req.Requirements)) + for i, r := range req.Requirements { + requirements[i] = ai.QuotaRequirement{ + UsageName: r.UsageName, + MinCapacity: r.MinCapacity, + } + } + + locations, err := s.modelService.ListLocationsWithQuota( + ctx, subscriptionId, req.AllowedLocations, requirements) + if err != nil { + return nil, fmt.Errorf("listing locations with quota: %w", err) + } + + protoLocations := make([]*azdext.Location, len(locations)) + for i, loc := range locations { + protoLocations[i] = &azdext.Location{Name: loc} + } + + return &azdext.ListLocationsWithQuotaResponse{Locations: protoLocations}, nil +} + +func (s *aiModelService) ListModelLocationsWithQuota( + ctx context.Context, req *azdext.ListModelLocationsWithQuotaRequest, +) (*azdext.ListModelLocationsWithQuotaResponse, error) { + subscriptionId, err := requireSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + if req.ModelName == "" { + return nil, fmt.Errorf("model_name is required") + } + + minRemaining := float64(1) + if req.Quota != nil && req.Quota.MinRemainingCapacity > 0 { + minRemaining = req.Quota.MinRemainingCapacity + } + + locations, err := s.modelService.ListModelLocationsWithQuota( + ctx, subscriptionId, req.ModelName, req.AllowedLocations, minRemaining) + if err != nil { + return nil, mapAiResolveError(err, req.ModelName) + } + + protoLocations := make([]*azdext.ModelLocationQuota, len(locations)) + for i, loc := range locations { + protoLocations[i] = &azdext.ModelLocationQuota{ + Location: &azdext.Location{Name: loc.Location}, + MaxRemainingQuota: loc.MaxRemainingQuota, + } + } + + return &azdext.ListModelLocationsWithQuotaResponse{Locations: protoLocations}, nil +} + +func requireSubscriptionID(azureContext *azdext.AzureContext) (string, error) { + if azureContext == nil || azureContext.Scope == nil || azureContext.Scope.SubscriptionId == "" { + return "", aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonMissingSubscription, + "azure_context.scope.subscription_id is required", + nil, + ) + } + + return azureContext.Scope.SubscriptionId, nil +} + +func protoToFilterOptions(f *azdext.AiModelFilterOptions) *ai.FilterOptions { + if f == nil { + return nil + } + return &ai.FilterOptions{ + Locations: f.Locations, + Capabilities: f.Capabilities, + Formats: f.Formats, + Statuses: f.Statuses, + ExcludeModelNames: f.ExcludeModelNames, + } +} + +func protoToDeploymentOptions(o *azdext.AiModelDeploymentOptions) *ai.DeploymentOptions { + if o == nil { + return nil + } + opts := &ai.DeploymentOptions{ + Locations: o.Locations, + Versions: o.Versions, + Skus: o.Skus, + } + if o.Capacity != nil { + cap := *o.Capacity + opts.Capacity = &cap + } + return opts +} + +func protoToQuotaCheckOptions(q *azdext.QuotaCheckOptions) *ai.QuotaCheckOptions { + if q == nil { + return nil + } + return &ai.QuotaCheckOptions{ + MinRemainingCapacity: q.MinRemainingCapacity, + } +} diff --git a/cli/azd/internal/grpcserver/prompt_service.go b/cli/azd/internal/grpcserver/prompt_service.go index 69f3f04eaee..b505dca35b3 100644 --- a/cli/azd/internal/grpcserver/prompt_service.go +++ b/cli/azd/internal/grpcserver/prompt_service.go @@ -7,20 +7,28 @@ import ( "context" "errors" "fmt" + "slices" + "strconv" + "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/mapper" + "github.com/azure/azure-dev/cli/azd/pkg/ai" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/ux" + "google.golang.org/grpc/codes" ) type promptService struct { azdext.UnimplementedPromptServiceServer prompter prompt.PromptService resourceService *azapi.ResourceService + aiModelService *ai.AiModelService globalOptions *internal.GlobalCommandOptions lock *promptLock } @@ -28,11 +36,13 @@ type promptService struct { func NewPromptService( prompter prompt.PromptService, resourceService *azapi.ResourceService, + aiModelService *ai.AiModelService, globalOptions *internal.GlobalCommandOptions, ) azdext.PromptServiceServer { return &promptService{ prompter: prompter, resourceService: resourceService, + aiModelService: aiModelService, globalOptions: globalOptions, lock: newPromptLock(), } @@ -475,6 +485,787 @@ func convertToInt(input *int32) *int { return &value // Return the address of the new int value } +// --- AI Model Prompt Methods --- + +func (s *promptService) PromptAiModel( + ctx context.Context, req *azdext.PromptAiModelRequest, +) (*azdext.PromptAiModelResponse, error) { + subscriptionId, err := requirePromptSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + var filterOpts *ai.FilterOptions + var locations []string + if req.Filter != nil { + filterOpts = protoToFilterOptions(req.Filter) + locations = filterOpts.Locations + } + var effectiveFilter *ai.FilterOptions + if filterOpts != nil { + copyFilter := *filterOpts + effectiveFilter = ©Filter + } + if len(locations) > 0 { + if effectiveFilter == nil { + effectiveFilter = &ai.FilterOptions{} + } + effectiveFilter.Locations = locations + } + + var models []ai.AiModel + var usageMap map[string]ai.AiModelUsage + loadModels := func(ctx context.Context, onProgress func(string)) error { + if onProgress != nil { + onProgress("Loading AI model catalog...") + } + + var err error + // Always fetch canonical model data across subscription locations. + // Location scoping is applied as a filter so model.Locations remains canonical. + models, err = s.aiModelService.ListModels(ctx, subscriptionId, nil) + if err != nil { + return fmt.Errorf("listing models: %w", err) + } + + if effectiveFilter != nil { + models = ai.FilterModels(models, effectiveFilter) + } + + if req.Quota != nil { + minRemaining := req.Quota.MinRemainingCapacity + if len(locations) == 1 { + if onProgress != nil { + onProgress(fmt.Sprintf("Checking quota in %s...", locations[0])) + } + + usages, err := s.aiModelService.ListUsages(ctx, subscriptionId, locations[0]) + if err != nil { + return fmt.Errorf("listing usages for quota check: %w", err) + } + + usageMap = make(map[string]ai.AiModelUsage, len(usages)) + for _, u := range usages { + usageMap[u.Name] = u + } + + models = ai.FilterModelsByQuota(models, usages, minRemaining) + } else { + if onProgress != nil { + onProgress("Checking quota across available locations...") + } + + models, err = s.aiModelService.FilterModelsByQuotaAcrossLocations( + ctx, + subscriptionId, + models, + locations, + minRemaining, + ) + if err != nil { + return fmt.Errorf("listing usages for quota check: %w", err) + } + } + } + + if len(models) == 0 { + return aiStatusError( + codes.NotFound, + azdext.AiErrorReasonNoModelsMatch, + "no models found matching the specified criteria", + nil, + ) + } + + return nil + } + + if s.globalOptions.NoPrompt { + if err := loadModels(ctx, nil); err != nil { + return nil, err + } + } else { + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Loading AI model catalog...", + }) + + if err := spinner.Run(ctx, func(ctx context.Context) error { + return loadModels(ctx, spinner.UpdateText) + }); err != nil { + return nil, err + } + } + + if s.globalOptions.NoPrompt { + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonInteractiveRequired, + "cannot prompt for model selection in non-interactive mode", + nil, + ) + } + + release, err := s.acquirePromptLock(ctx) + if err != nil { + return nil, err + } + defer release() + + message := "Select an AI model" + if req.SelectOptions != nil && req.SelectOptions.Message != "" { + message = req.SelectOptions.Message + } + + selectOpts := &ux.SelectOptions{ + Message: message, + Choices: make([]*ux.SelectChoice, len(models)), + EnableFiltering: to.Ptr(true), + } + for i, m := range models { + label := m.Name + if req.Quota != nil && usageMap != nil { + label += " " + modelQuotaSummary(m, usageMap) + } + selectOpts.Choices[i] = &ux.SelectChoice{ + Value: m.Name, + Label: label, + } + } + + selected, err := ux.NewSelect(selectOpts).Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for model selection: %w", err) + } + + var protoModel *azdext.AiModel + if err := mapper.Convert(&models[*selected], &protoModel); err != nil { + return nil, fmt.Errorf("converting selected model to proto: %w", err) + } + + return &azdext.PromptAiModelResponse{ + Model: protoModel, + }, nil +} + +func (s *promptService) PromptAiDeployment( + ctx context.Context, req *azdext.PromptAiDeploymentRequest, +) (*azdext.PromptAiDeploymentResponse, error) { + subscriptionId, err := requirePromptSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + options := protoToDeploymentOptions(req.Options) + if options == nil { + options = &ai.DeploymentOptions{} + } + + // Fail explicitly if quota is requested without exactly one location. + if req.Quota != nil && len(options.Locations) != 1 { + return nil, aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonQuotaLocation, + fmt.Sprintf( + "quota checking requires exactly one effective location, got %d", + len(options.Locations), + ), + nil, + ) + } + + // Fetch the model catalog + models, err := s.aiModelService.ListModels(ctx, subscriptionId, options.Locations) + if err != nil { + return nil, fmt.Errorf("listing models: %w", err) + } + + var targetModel *ai.AiModel + for i := range models { + if models[i].Name == req.ModelName { + targetModel = &models[i] + break + } + } + if targetModel == nil { + return nil, aiStatusError( + codes.NotFound, + azdext.AiErrorReasonModelNotFound, + fmt.Sprintf("model %q not found", req.ModelName), + map[string]string{"model_name": req.ModelName}, + ) + } + + // Fetch quota data (guaranteed single location by check above) + var usageMap map[string]ai.AiModelUsage + if req.Quota != nil { + usages, err := s.aiModelService.ListUsages(ctx, subscriptionId, options.Locations[0]) + if err != nil { + return nil, fmt.Errorf("getting usages: %w", err) + } + usageMap = make(map[string]ai.AiModelUsage, len(usages)) + for _, u := range usages { + usageMap[u.Name] = u + } + } + + if s.globalOptions.NoPrompt { + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonInteractiveRequired, + "cannot prompt for deployment configuration in non-interactive mode", + nil, + ) + } + + release, err := s.acquirePromptLock(ctx) + if err != nil { + return nil, err + } + defer release() + + // --- Step 1: Select version --- + // Collect available versions (filtered by options.versions if provided), along with + // precomputed valid SKU candidates so version and SKU steps stay consistent. + type versionCandidate struct { + version ai.AiModelVersion + skuCandidates []skuCandidate + label string + } + var availableVersions []versionCandidate + for _, v := range targetModel.Versions { + if len(options.Versions) > 0 && !slices.Contains(options.Versions, v.Version) { + continue + } + + skuCandidates := buildSkuCandidatesForVersion( + v, + options, + req.Quota, + usageMap, + req.IncludeFinetuneSkus, + ) + if len(skuCandidates) == 0 { + continue + } + + label := v.Version + if v.IsDefault { + label += " (default)" + } + if maxRemaining, ok := maxSkuCandidateRemaining(skuCandidates); ok { + label += " " + output.WithGrayFormat("[up to %.0f quota available]", maxRemaining) + } + + availableVersions = append(availableVersions, versionCandidate{ + version: v, + skuCandidates: skuCandidates, + label: label, + }) + } + if len(availableVersions) == 0 { + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonNoValidSkus, + fmt.Sprintf("no valid versions/SKUs found for model %q with the specified options", req.ModelName), + map[string]string{"model_name": req.ModelName}, + ) + } + + selectedVersionCandidate := availableVersions[0] + selectedVersionChosen := false + if req.UseDefaultVersion { + for _, v := range availableVersions { + if v.version.IsDefault { + selectedVersionCandidate = v + selectedVersionChosen = true + break + } + } + } + + if !selectedVersionChosen { + versionChoices := make([]*ux.SelectChoice, len(availableVersions)) + for i, v := range availableVersions { + versionChoices[i] = &ux.SelectChoice{Value: v.label, Label: v.label} + } + vIdx, err := ux.NewSelect(&ux.SelectOptions{ + Message: fmt.Sprintf("Select a version for %s", req.ModelName), + Choices: versionChoices, + }).Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for version: %w", err) + } + selectedVersionCandidate = availableVersions[*vIdx] + } + selectedVersion := selectedVersionCandidate.version + + // --- Step 2: Select SKU --- + // Use precomputed candidates for the selected version to keep behavior consistent. + skuCandidates := slices.Clone(selectedVersionCandidate.skuCandidates) + + if len(skuCandidates) == 0 { + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonNoValidSkus, + fmt.Sprintf("no valid SKUs found for model %q version %q", req.ModelName, selectedVersion.Version), + map[string]string{ + "model_name": req.ModelName, + "version": selectedVersion.Version, + }, + ) + } + + // Build labels: only include usage_name when SKU names are ambiguous. + skuNameCount := make(map[string]int, len(skuCandidates)) + for _, c := range skuCandidates { + skuNameCount[c.sku.Name]++ + } + for i, c := range skuCandidates { + label := c.sku.Name + if skuNameCount[c.sku.Name] > 1 { + label += fmt.Sprintf(" (%s)", c.sku.UsageName) + } + if c.remaining != nil { + label += " " + output.WithGrayFormat("[%.0f quota available]", *c.remaining) + } + skuCandidates[i].label = label + } + + skuChoices := make([]*ux.SelectChoice, len(skuCandidates)) + for i, c := range skuCandidates { + skuChoices[i] = &ux.SelectChoice{Value: c.label, Label: c.label} + } + sIdx, err := ux.NewSelect(&ux.SelectOptions{ + Message: fmt.Sprintf("Select a SKU for %s v%s", req.ModelName, selectedVersion.Version), + Choices: skuChoices, + }).Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for SKU: %w", err) + } + selectedSku := skuCandidates[*sIdx] + + // --- Step 3: Resolve capacity, optionally prompting --- + capacity := ai.ResolveCapacity(selectedSku.sku, options.Capacity) + + if !req.UseDefaultCapacity { + sku := selectedSku.sku + defaultVal := fmt.Sprintf("%d", capacity) + if capacity == 0 && sku.DefaultCapacity > 0 { + defaultVal = fmt.Sprintf("%d", sku.DefaultCapacity) + } + + hint := "" + if sku.MinCapacity > 0 || sku.MaxCapacity > 0 { + hint = fmt.Sprintf("min: %d, max: %d, step: %d", sku.MinCapacity, sku.MaxCapacity, sku.CapacityStep) + } + + prompt := ux.NewPrompt(&ux.PromptOptions{ + Message: fmt.Sprintf("Enter deployment capacity for %s (%s)", req.ModelName, sku.Name), + DefaultValue: defaultVal, + HelpMessage: hint, + Required: true, + ValidationFn: func(value string) (bool, string) { + parsed, err := validateDeploymentCapacity(value, sku) + if err != nil { + return false, err.Error() + } + + if err := validateCapacityAgainstRemainingQuota(parsed, selectedSku.remaining); err != nil { + return false, err.Error() + } + + return true, "" + }, + }) + capStr, err := prompt.Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for capacity: %w", err) + } + + parsed, err := validateDeploymentCapacity(capStr, sku) + if err != nil { + return nil, aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonInvalidCapacity, + fmt.Sprintf("invalid capacity %q: %v", capStr, err), + map[string]string{ + "model_name": req.ModelName, + "sku": sku.Name, + }, + ) + } + + if err := validateCapacityAgainstRemainingQuota(parsed, selectedSku.remaining); err != nil { + metadata := map[string]string{ + "model_name": req.ModelName, + "sku": sku.Name, + } + if selectedSku.remaining != nil { + metadata["remaining_quota"] = fmt.Sprintf("%.0f", *selectedSku.remaining) + } + return nil, aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonInvalidCapacity, + fmt.Sprintf("invalid capacity %q: %v", capStr, err), + metadata, + ) + } + capacity = parsed + } + + deployLocation := "" + if len(options.Locations) == 1 { + deployLocation = options.Locations[0] + } + + deployment := &ai.AiModelDeployment{ + ModelName: req.ModelName, + Format: targetModel.Format, + Version: selectedVersion.Version, + Location: deployLocation, + Sku: selectedSku.sku, + Capacity: capacity, + RemainingQuota: selectedSku.remaining, + } + + var protoDeployment *azdext.AiModelDeployment + if err := mapper.Convert(deployment, &protoDeployment); err != nil { + return nil, fmt.Errorf("converting deployment to proto: %w", err) + } + + return &azdext.PromptAiDeploymentResponse{ + Deployment: protoDeployment, + }, nil +} + +func (s *promptService) PromptAiLocationWithQuota( + ctx context.Context, req *azdext.PromptAiLocationWithQuotaRequest, +) (*azdext.PromptAiLocationWithQuotaResponse, error) { + subscriptionId, err := requirePromptSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + + requirements := make([]ai.QuotaRequirement, len(req.Requirements)) + for i, r := range req.Requirements { + requirements[i] = ai.QuotaRequirement{ + UsageName: r.UsageName, + MinCapacity: r.MinCapacity, + } + } + + locations, err := s.aiModelService.ListLocationsWithQuota( + ctx, subscriptionId, req.AllowedLocations, requirements) + if err != nil { + return nil, fmt.Errorf("listing locations with quota: %w", err) + } + + if len(locations) == 0 { + return nil, aiStatusError( + codes.NotFound, + azdext.AiErrorReasonNoLocationsWithQuota, + "no locations found with sufficient quota", + nil, + ) + } + + if s.globalOptions.NoPrompt { + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonInteractiveRequired, + "cannot prompt for location selection in non-interactive mode", + nil, + ) + } + + release, err := s.acquirePromptLock(ctx) + if err != nil { + return nil, err + } + defer release() + + message := "Select a location" + if req.SelectOptions != nil && req.SelectOptions.Message != "" { + message = req.SelectOptions.Message + } + + selectOpts := &ux.SelectOptions{ + Message: message, + Choices: make([]*ux.SelectChoice, len(locations)), + EnableFiltering: to.Ptr(true), + } + for i, loc := range locations { + selectOpts.Choices[i] = &ux.SelectChoice{ + Value: loc, + Label: loc, + } + } + + selected, err := ux.NewSelect(selectOpts).Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for location selection: %w", err) + } + + return &azdext.PromptAiLocationWithQuotaResponse{ + Location: &azdext.Location{Name: locations[*selected]}, + }, nil +} + +func (s *promptService) PromptAiModelLocationWithQuota( + ctx context.Context, req *azdext.PromptAiModelLocationWithQuotaRequest, +) (*azdext.PromptAiModelLocationWithQuotaResponse, error) { + subscriptionId, err := requirePromptSubscriptionID(req.AzureContext) + if err != nil { + return nil, err + } + if req.ModelName == "" { + return nil, fmt.Errorf("model_name is required") + } + + minRemaining := float64(1) + if req.Quota != nil && req.Quota.MinRemainingCapacity > 0 { + minRemaining = req.Quota.MinRemainingCapacity + } + + var locations []ai.ModelLocationQuota + loadLocations := func(ctx context.Context, onProgress func(string)) error { + if onProgress != nil { + onProgress(fmt.Sprintf("Checking quota availability for %s...", req.ModelName)) + } + + var err error + locations, err = s.aiModelService.ListModelLocationsWithQuota( + ctx, subscriptionId, req.ModelName, req.AllowedLocations, minRemaining) + if err != nil { + return mapAiResolveError(err, req.ModelName) + } + if len(locations) == 0 { + return aiStatusError( + codes.NotFound, + azdext.AiErrorReasonNoLocationsWithQuota, + "no locations found with sufficient quota", + nil, + ) + } + + return nil + } + + if s.globalOptions.NoPrompt { + if err := loadLocations(ctx, nil); err != nil { + return nil, err + } + + return nil, aiStatusError( + codes.FailedPrecondition, + azdext.AiErrorReasonInteractiveRequired, + "cannot prompt for location selection in non-interactive mode", + nil, + ) + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Checking quota availability for %s...", req.ModelName), + }) + + if err := spinner.Run(ctx, func(ctx context.Context) error { + return loadLocations(ctx, spinner.UpdateText) + }); err != nil { + return nil, err + } + + release, err := s.acquirePromptLock(ctx) + if err != nil { + return nil, err + } + defer release() + + message := "Select a location" + if req.SelectOptions != nil && req.SelectOptions.Message != "" { + message = req.SelectOptions.Message + } + + selectOpts := &ux.SelectOptions{ + Message: message, + Choices: make([]*ux.SelectChoice, len(locations)), + EnableFiltering: to.Ptr(true), + } + for i, loc := range locations { + quotaLabel := output.WithGrayFormat("[up to %.0f quota available]", loc.MaxRemainingQuota) + label := fmt.Sprintf("%s %s", loc.Location, quotaLabel) + selectOpts.Choices[i] = &ux.SelectChoice{ + Value: loc.Location, + Label: label, + } + } + + selected, err := ux.NewSelect(selectOpts).Ask(ctx) + if err != nil { + return nil, fmt.Errorf("prompting for location selection: %w", err) + } + + return &azdext.PromptAiModelLocationWithQuotaResponse{ + Location: &azdext.Location{Name: locations[*selected].Location}, + MaxRemainingQuota: locations[*selected].MaxRemainingQuota, + }, nil +} + +func requirePromptSubscriptionID(azureContext *azdext.AzureContext) (string, error) { + if azureContext == nil || azureContext.Scope == nil || azureContext.Scope.SubscriptionId == "" { + return "", aiStatusError( + codes.InvalidArgument, + azdext.AiErrorReasonMissingSubscription, + "azure_context.scope.subscription_id is required", + nil, + ) + } + + return azureContext.Scope.SubscriptionId, nil +} + +// modelQuotaSummary builds a gray-formatted quota summary for a model's SKUs. +// Shows the max remaining quota across all SKUs, e.g. "[up to 1000 quota available]". +func modelQuotaSummary(model ai.AiModel, usageMap map[string]ai.AiModelUsage) string { + var maxRemaining float64 + found := false + for _, v := range model.Versions { + for _, sku := range v.Skus { + if usage, ok := usageMap[sku.UsageName]; ok { + rem := usage.Limit - usage.CurrentValue + if !found || rem > maxRemaining { + maxRemaining = rem + found = true + } + } + } + } + if !found { + return output.WithGrayFormat("[no quota info]") + } + return output.WithGrayFormat("[up to %.0f quota available]", maxRemaining) +} + +type skuCandidate struct { + sku ai.AiModelSku + remaining *float64 + label string +} + +func buildSkuCandidatesForVersion( + version ai.AiModelVersion, + options *ai.DeploymentOptions, + quota *azdext.QuotaCheckOptions, + usageMap map[string]ai.AiModelUsage, + includeFinetuneSkus bool, +) []skuCandidate { + if options == nil { + options = &ai.DeploymentOptions{} + } + + minReq := float64(1) + if quota != nil && quota.MinRemainingCapacity > 0 { + minReq = quota.MinRemainingCapacity + } + + skuCandidates := make([]skuCandidate, 0, len(version.Skus)) + for _, sku := range version.Skus { + if len(options.Skus) > 0 && !slices.Contains(options.Skus, sku.Name) { + continue + } + + if !includeFinetuneSkus && isFinetuneUsageName(sku.UsageName) { + continue + } + + capacity := ai.ResolveCapacity(sku, options.Capacity) + + var remaining *float64 + if quota != nil { + if usageMap == nil { + continue + } + + usage, ok := usageMap[sku.UsageName] + if !ok { + continue + } + + rem := usage.Limit - usage.CurrentValue + remaining = &rem + if rem < minReq || (capacity > 0 && float64(capacity) > rem) { + continue + } + } + + skuCandidates = append(skuCandidates, skuCandidate{ + sku: sku, + remaining: remaining, + }) + } + + return skuCandidates +} + +func maxSkuCandidateRemaining(skuCandidates []skuCandidate) (float64, bool) { + var maxRemaining float64 + found := false + + for _, candidate := range skuCandidates { + if candidate.remaining == nil { + continue + } + + if !found || *candidate.remaining > maxRemaining { + maxRemaining = *candidate.remaining + found = true + } + } + + return maxRemaining, found +} + +func isFinetuneUsageName(usageName string) bool { + return strings.HasSuffix(strings.ToLower(usageName), "-finetune") +} + +func validateDeploymentCapacity(value string, sku ai.AiModelSku) (int32, error) { + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 32) + if err != nil { + return 0, fmt.Errorf("capacity must be a whole number") + } + + capacity := int32(parsed) + if capacity <= 0 { + return 0, fmt.Errorf("capacity must be greater than 0") + } + + if sku.MinCapacity > 0 && capacity < sku.MinCapacity { + return 0, fmt.Errorf("capacity must be at least %d", sku.MinCapacity) + } + + if sku.MaxCapacity > 0 && capacity > sku.MaxCapacity { + return 0, fmt.Errorf("capacity must be at most %d", sku.MaxCapacity) + } + + if sku.CapacityStep > 0 && capacity%sku.CapacityStep != 0 { + return 0, fmt.Errorf("capacity must be a multiple of %d", sku.CapacityStep) + } + + return capacity, nil +} + +func validateCapacityAgainstRemainingQuota(capacity int32, remaining *float64) error { + if remaining == nil { + return nil + } + + if float64(capacity) > *remaining { + return fmt.Errorf("capacity must be at most %.0f due to available quota", *remaining) + } + + return nil +} + // promptLock is a context-aware mutual exclusion mechanism for serializing interactive prompts. // It prevents concurrent prompt access which could cause prompts to freeze up when multiple // extensions with "listen" capability are installed and running simultaneously. diff --git a/cli/azd/internal/grpcserver/prompt_service_test.go b/cli/azd/internal/grpcserver/prompt_service_test.go index 1764b15ea66..e9b6e35181a 100644 --- a/cli/azd/internal/grpcserver/prompt_service_test.go +++ b/cli/azd/internal/grpcserver/prompt_service_test.go @@ -12,6 +12,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/ai" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/prompt" @@ -22,7 +23,7 @@ import ( func Test_PromptService_Confirm_NoPromptWithDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) resp, err := service.Confirm(context.Background(), &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ @@ -38,7 +39,7 @@ func Test_PromptService_Confirm_NoPromptWithDefault(t *testing.T) { func Test_PromptService_Confirm_NoPromptWithoutDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) _, err := service.Confirm(context.Background(), &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ @@ -52,7 +53,7 @@ func Test_PromptService_Confirm_NoPromptWithoutDefault(t *testing.T) { func Test_PromptService_Select_NoPromptWithDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) resp, err := service.Select(context.Background(), &azdext.SelectRequest{ Options: &azdext.SelectOptions{ @@ -72,7 +73,7 @@ func Test_PromptService_Select_NoPromptWithDefault(t *testing.T) { func Test_PromptService_Select_NoPromptWithoutDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) _, err := service.Select(context.Background(), &azdext.SelectRequest{ Options: &azdext.SelectOptions{ @@ -89,7 +90,7 @@ func Test_PromptService_Select_NoPromptWithoutDefault(t *testing.T) { func Test_PromptService_MultiSelect_NoPrompt(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) resp, err := service.MultiSelect(context.Background(), &azdext.MultiSelectRequest{ Options: &azdext.MultiSelectOptions{ @@ -110,7 +111,7 @@ func Test_PromptService_MultiSelect_NoPrompt(t *testing.T) { func Test_PromptService_Prompt_NoPromptWithDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) resp, err := service.Prompt(context.Background(), &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -126,7 +127,7 @@ func Test_PromptService_Prompt_NoPromptWithDefault(t *testing.T) { func Test_PromptService_Prompt_NoPromptRequiredWithoutDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) _, err := service.Prompt(context.Background(), &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -141,7 +142,7 @@ func Test_PromptService_Prompt_NoPromptRequiredWithoutDefault(t *testing.T) { func Test_PromptService_Prompt_NoPromptNotRequiredWithoutDefault(t *testing.T) { globalOptions := &internal.GlobalCommandOptions{NoPrompt: true} - service := NewPromptService(nil, nil, globalOptions) + service := NewPromptService(nil, nil, nil, globalOptions) resp, err := service.Prompt(context.Background(), &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -168,7 +169,7 @@ func Test_PromptService_PromptSubscription(t *testing.T) { On("PromptSubscription", mock.Anything, mock.Anything). Return(expectedSub, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptSubscription(context.Background(), &azdext.PromptSubscriptionRequest{ Message: "Select subscription:", @@ -197,7 +198,7 @@ func Test_PromptService_PromptLocation(t *testing.T) { On("PromptLocation", mock.Anything, mock.Anything, mock.Anything). Return(expectedLocation, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptLocation(context.Background(), &azdext.PromptLocationRequest{ AzureContext: &azdext.AzureContext{ @@ -237,7 +238,7 @@ func Test_PromptService_PromptResourceGroup(t *testing.T) { })). Return(expectedRg, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptResourceGroup(context.Background(), &azdext.PromptResourceGroupRequest{ AzureContext: &azdext.AzureContext{ @@ -276,7 +277,7 @@ func Test_PromptService_PromptResourceGroup_NilOptions(t *testing.T) { On("PromptResourceGroup", mock.Anything, mock.Anything, (*prompt.ResourceGroupOptions)(nil)). Return(expectedRg, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptResourceGroup(context.Background(), &azdext.PromptResourceGroupRequest{ AzureContext: &azdext.AzureContext{ @@ -323,7 +324,7 @@ func Test_PromptService_PromptSubscriptionResource(t *testing.T) { ). Return(expectedResource, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptSubscriptionResource(context.Background(), &azdext.PromptSubscriptionResourceRequest{ AzureContext: &azdext.AzureContext{ @@ -381,7 +382,7 @@ func Test_PromptService_PromptResourceGroupResource(t *testing.T) { ). Return(expectedResource, nil) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) resp, err := service.PromptResourceGroupResource(context.Background(), &azdext.PromptResourceGroupResourceRequest{ AzureContext: &azdext.AzureContext{ @@ -604,7 +605,7 @@ func Test_PromptService_PromptSubscription_ErrorWithSuggestion(t *testing.T) { On("PromptSubscription", mock.Anything, mock.Anything). Return(nil, authErr) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) _, err := service.PromptSubscription(context.Background(), &azdext.PromptSubscriptionRequest{ Message: "Select subscription:", @@ -630,7 +631,7 @@ func Test_PromptService_PromptResourceGroup_ErrorWithSuggestion(t *testing.T) { On("PromptResourceGroup", mock.Anything, mock.Anything, mock.Anything). Return(nil, authErr) - service := NewPromptService(mockPrompter, nil, globalOptions) + service := NewPromptService(mockPrompter, nil, nil, globalOptions) _, err := service.PromptResourceGroup(context.Background(), &azdext.PromptResourceGroupRequest{ AzureContext: &azdext.AzureContext{ @@ -646,3 +647,194 @@ func Test_PromptService_PromptResourceGroup_ErrorWithSuggestion(t *testing.T) { require.Contains(t, err.Error(), "AADSTS70043") mockPrompter.AssertExpectations(t) } + +func Test_validateDeploymentCapacity(t *testing.T) { + tests := []struct { + name string + value string + sku ai.AiModelSku + want int32 + errContains string + }{ + { + name: "valid capacity with constraints", + value: "20", + sku: ai.AiModelSku{ + MinCapacity: 10, + MaxCapacity: 100, + CapacityStep: 10, + }, + want: 20, + }, + { + name: "non-numeric value", + value: "abc", + sku: ai.AiModelSku{}, + errContains: "whole number", + }, + { + name: "below minimum", + value: "5", + sku: ai.AiModelSku{ + MinCapacity: 10, + }, + errContains: "at least 10", + }, + { + name: "above maximum", + value: "120", + sku: ai.AiModelSku{ + MaxCapacity: 100, + }, + errContains: "at most 100", + }, + { + name: "step mismatch", + value: "25", + sku: ai.AiModelSku{ + CapacityStep: 10, + }, + errContains: "multiple of 10", + }, + { + name: "trimmed input is accepted", + value: " 30 ", + sku: ai.AiModelSku{ + MinCapacity: 10, + }, + want: 30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateDeploymentCapacity(tt.value, tt.sku) + if tt.errContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_validateCapacityAgainstRemainingQuota(t *testing.T) { + tests := []struct { + name string + capacity int32 + remaining *float64 + errContains string + }{ + { + name: "no remaining quota info", + capacity: 100, + remaining: nil, + }, + { + name: "capacity within remaining quota", + capacity: 10, + remaining: to.Ptr(float64(25)), + }, + { + name: "capacity exceeds remaining quota", + capacity: 30, + remaining: to.Ptr(float64(20)), + errContains: "at most 20", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCapacityAgainstRemainingQuota(tt.capacity, tt.remaining) + if tt.errContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + return + } + + require.NoError(t, err) + }) + } +} + +func Test_buildSkuCandidatesForVersion(t *testing.T) { + version := ai.AiModelVersion{ + Version: "2024-06-01", + Skus: []ai.AiModelSku{ + { + Name: "Standard", + UsageName: "OpenAI.Standard.gpt-4o", + DefaultCapacity: 5, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + { + Name: "Standard", + UsageName: "OpenAI.Standard.gpt-4o-finetune", + DefaultCapacity: 5, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + }, + } + + t.Run("excludes finetune skus when include flag is false", func(t *testing.T) { + candidates := buildSkuCandidatesForVersion(version, nil, nil, nil, false) + require.Len(t, candidates, 1) + require.Equal(t, "OpenAI.Standard.gpt-4o", candidates[0].sku.UsageName) + }) + + t.Run("includes finetune skus when include flag is true", func(t *testing.T) { + candidates := buildSkuCandidatesForVersion(version, nil, nil, nil, true) + require.Len(t, candidates, 2) + }) + + t.Run("applies quota and capacity filters", func(t *testing.T) { + options := &ai.DeploymentOptions{ + Capacity: to.Ptr(int32(5)), + } + quota := &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + } + usageMap := map[string]ai.AiModelUsage{ + "OpenAI.Standard.gpt-4o": { + Name: "OpenAI.Standard.gpt-4o", + CurrentValue: 6, + Limit: 10, // remaining 4 < capacity 5 => excluded + }, + "OpenAI.Standard.gpt-4o-finetune": { + Name: "OpenAI.Standard.gpt-4o-finetune", + CurrentValue: 0, + Limit: 10, // remaining 10 => included + }, + } + + candidates := buildSkuCandidatesForVersion(version, options, quota, usageMap, true) + require.Len(t, candidates, 1) + require.Equal(t, "OpenAI.Standard.gpt-4o-finetune", candidates[0].sku.UsageName) + require.NotNil(t, candidates[0].remaining) + require.Equal(t, float64(10), *candidates[0].remaining) + }) +} + +func Test_maxSkuCandidateRemaining(t *testing.T) { + remainingA := float64(4) + remainingB := float64(10) + skuCandidates := []skuCandidate{ + {remaining: &remainingA}, + {remaining: nil}, + {remaining: &remainingB}, + } + + maxRemaining, found := maxSkuCandidateRemaining(skuCandidates) + require.True(t, found) + require.Equal(t, float64(10), maxRemaining) + + _, found = maxSkuCandidateRemaining([]skuCandidate{{remaining: nil}}) + require.False(t, found) +} diff --git a/cli/azd/internal/grpcserver/server.go b/cli/azd/internal/grpcserver/server.go index fc1873b0c31..8b2065ce0dd 100644 --- a/cli/azd/internal/grpcserver/server.go +++ b/cli/azd/internal/grpcserver/server.go @@ -38,6 +38,7 @@ type Server struct { frameworkService azdext.FrameworkServiceServer containerService azdext.ContainerServiceServer accountService azdext.AccountServiceServer + aiModelService azdext.AiModelServiceServer } func NewServer( @@ -54,6 +55,7 @@ func NewServer( frameworkService azdext.FrameworkServiceServer, containerService azdext.ContainerServiceServer, accountService azdext.AccountServiceServer, + aiModelService azdext.AiModelServiceServer, ) *Server { return &Server{ projectService: projectService, @@ -69,6 +71,7 @@ func NewServer( frameworkService: frameworkService, containerService: containerService, accountService: accountService, + aiModelService: aiModelService, } } @@ -107,6 +110,7 @@ func (s *Server) Start() (*ServerInfo, error) { azdext.RegisterFrameworkServiceServer(s.grpcServer, s.frameworkService) azdext.RegisterContainerServiceServer(s.grpcServer, s.containerService) azdext.RegisterAccountServiceServer(s.grpcServer, s.accountService) + azdext.RegisterAiModelServiceServer(s.grpcServer, s.aiModelService) serverInfo.Address = fmt.Sprintf("localhost:%d", randomPort) serverInfo.Port = randomPort diff --git a/cli/azd/internal/grpcserver/server_test.go b/cli/azd/internal/grpcserver/server_test.go index 0697ed33307..f198490f1da 100644 --- a/cli/azd/internal/grpcserver/server_test.go +++ b/cli/azd/internal/grpcserver/server_test.go @@ -35,6 +35,7 @@ func Test_Server_Start(t *testing.T) { azdext.UnimplementedFrameworkServiceServer{}, azdext.UnimplementedContainerServiceServer{}, azdext.UnimplementedAccountServiceServer{}, + azdext.UnimplementedAiModelServiceServer{}, ) serverInfo, err := server.Start() diff --git a/cli/azd/pkg/account/subscriptions_manager.go b/cli/azd/pkg/account/subscriptions_manager.go index 494a89bbdd1..01fb12eac3d 100644 --- a/cli/azd/pkg/account/subscriptions_manager.go +++ b/cli/azd/pkg/account/subscriptions_manager.go @@ -366,6 +366,14 @@ func (m *SubscriptionsManager) ListLocations( m.console.ShowSpinner(ctx, msg, input.Step) defer m.console.StopSpinner(ctx, "", input.GetStepResultFormat(err)) + return m.GetLocations(ctx, subscriptionId) +} + +// GetLocations lists locations for a subscription without rendering progress UX. +func (m *SubscriptionsManager) GetLocations( + ctx context.Context, + subscriptionId string, +) ([]Location, error) { return m.listLocations(ctx, subscriptionId) } diff --git a/cli/azd/pkg/ai/errors.go b/cli/azd/pkg/ai/errors.go new file mode 100644 index 00000000000..a7f787729a2 --- /dev/null +++ b/cli/azd/pkg/ai/errors.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ai + +import "errors" + +var ( + // ErrQuotaLocationRequired indicates quota checks were requested without exactly one location. + ErrQuotaLocationRequired = errors.New("quota checking requires exactly one location") + // ErrModelNotFound indicates the requested model was not found in the effective model catalog. + ErrModelNotFound = errors.New("model not found") + // ErrNoDeploymentMatch indicates no deployment candidate matched provided filters/constraints. + ErrNoDeploymentMatch = errors.New("no deployment match") +) diff --git a/cli/azd/pkg/ai/mapper_registry.go b/cli/azd/pkg/ai/mapper_registry.go new file mode 100644 index 00000000000..570a0d68a9d --- /dev/null +++ b/cli/azd/pkg/ai/mapper_registry.go @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ai + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/internal/mapper" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func init() { + registerAiModelMappings() +} + +// registerAiModelMappings registers all AI model type conversions with the mapper. +func registerAiModelMappings() { + // AiModel -> proto AiModel + mapper.MustRegister(func(_ context.Context, src *AiModel) (*azdext.AiModel, error) { + versions := make([]*azdext.AiModelVersion, len(src.Versions)) + for i, v := range src.Versions { + proto, err := aiModelVersionToProto(&v) + if err != nil { + return nil, err + } + versions[i] = proto + } + + return &azdext.AiModel{ + Name: src.Name, + Format: src.Format, + LifecycleStatus: src.LifecycleStatus, + Capabilities: src.Capabilities, + Versions: versions, + Locations: src.Locations, + }, nil + }) + + // proto AiModel -> AiModel + mapper.MustRegister(func(_ context.Context, src *azdext.AiModel) (*AiModel, error) { + versions := make([]AiModelVersion, len(src.Versions)) + for i, v := range src.Versions { + versions[i] = protoToAiModelVersion(v) + } + + return &AiModel{ + Name: src.Name, + Format: src.Format, + LifecycleStatus: src.LifecycleStatus, + Capabilities: src.Capabilities, + Versions: versions, + Locations: src.Locations, + }, nil + }) + + // AiModelSku -> proto AiModelSku + mapper.MustRegister(func(_ context.Context, src *AiModelSku) (*azdext.AiModelSku, error) { + return aiModelSkuToProto(src), nil + }) + + // proto AiModelSku -> AiModelSku + mapper.MustRegister(func(_ context.Context, src *azdext.AiModelSku) (*AiModelSku, error) { + return protoToAiModelSku(src), nil + }) + + // AiModelDeployment -> proto AiModelDeployment + mapper.MustRegister(func(_ context.Context, src *AiModelDeployment) (*azdext.AiModelDeployment, error) { + return &azdext.AiModelDeployment{ + ModelName: src.ModelName, + Format: src.Format, + Version: src.Version, + Location: src.Location, + Sku: aiModelSkuToProto(&src.Sku), + Capacity: src.Capacity, + RemainingQuota: src.RemainingQuota, + }, nil + }) + + // proto AiModelDeployment -> AiModelDeployment + mapper.MustRegister(func(_ context.Context, src *azdext.AiModelDeployment) (*AiModelDeployment, error) { + var sku AiModelSku + if src.Sku != nil { + sku = *protoToAiModelSku(src.Sku) + } + return &AiModelDeployment{ + ModelName: src.ModelName, + Format: src.Format, + Version: src.Version, + Location: src.Location, + Sku: sku, + Capacity: src.Capacity, + RemainingQuota: src.RemainingQuota, + }, nil + }) + + // AiModelUsage -> proto AiModelUsage + mapper.MustRegister(func(_ context.Context, src *AiModelUsage) (*azdext.AiModelUsage, error) { + return &azdext.AiModelUsage{ + Name: src.Name, + CurrentValue: src.CurrentValue, + Limit: src.Limit, + }, nil + }) + + // proto AiModelUsage -> AiModelUsage + mapper.MustRegister(func(_ context.Context, src *azdext.AiModelUsage) (*AiModelUsage, error) { + return &AiModelUsage{ + Name: src.Name, + CurrentValue: src.CurrentValue, + Limit: src.Limit, + }, nil + }) +} + +func aiModelVersionToProto(src *AiModelVersion) (*azdext.AiModelVersion, error) { + skus := make([]*azdext.AiModelSku, len(src.Skus)) + for i, s := range src.Skus { + skus[i] = aiModelSkuToProto(&s) + } + + return &azdext.AiModelVersion{ + Version: src.Version, + IsDefault: src.IsDefault, + Skus: skus, + }, nil +} + +func protoToAiModelVersion(src *azdext.AiModelVersion) AiModelVersion { + skus := make([]AiModelSku, len(src.Skus)) + for i, s := range src.Skus { + skus[i] = *protoToAiModelSku(s) + } + + return AiModelVersion{ + Version: src.Version, + IsDefault: src.IsDefault, + Skus: skus, + } +} + +func aiModelSkuToProto(src *AiModelSku) *azdext.AiModelSku { + return &azdext.AiModelSku{ + Name: src.Name, + UsageName: src.UsageName, + DefaultCapacity: src.DefaultCapacity, + MinCapacity: src.MinCapacity, + MaxCapacity: src.MaxCapacity, + CapacityStep: src.CapacityStep, + } +} + +func protoToAiModelSku(src *azdext.AiModelSku) *AiModelSku { + return &AiModelSku{ + Name: src.Name, + UsageName: src.UsageName, + DefaultCapacity: src.DefaultCapacity, + MinCapacity: src.MinCapacity, + MaxCapacity: src.MaxCapacity, + CapacityStep: src.CapacityStep, + } +} diff --git a/cli/azd/pkg/ai/model_service.go b/cli/azd/pkg/ai/model_service.go new file mode 100644 index 00000000000..4f4a7dd5cbf --- /dev/null +++ b/cli/azd/pkg/ai/model_service.go @@ -0,0 +1,904 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ai + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/azapi" +) + +// AiModelService provides operations for querying AI model availability, +// resolving deployments, and checking quota/usage from Azure Cognitive Services. +type AiModelService struct { + azureClient *azapi.AzureClient + subManager *account.SubscriptionsManager + catalogCacheMu sync.RWMutex + catalogCache map[string][]*armcognitiveservices.Model // key: "subscriptionId:location" +} + +// NewAiModelService creates a new AiModelService. +func NewAiModelService( + azureClient *azapi.AzureClient, + subManager *account.SubscriptionsManager, +) *AiModelService { + return &AiModelService{ + azureClient: azureClient, + subManager: subManager, + catalogCache: make(map[string][]*armcognitiveservices.Model), + } +} + +// ListModels fetches AI models from the Azure Cognitive Services catalog. +// If locations is empty, fetches across all subscription locations in parallel. +func (s *AiModelService) ListModels( + ctx context.Context, + subscriptionId string, + locations []string, +) ([]AiModel, error) { + if len(locations) == 0 { + resolvedLocations, err := s.ListLocations(ctx, subscriptionId) + if err != nil { + return nil, err + } + + locations = resolvedLocations + } + + rawModels, err := s.fetchModelsForLocations(ctx, subscriptionId, locations) + if err != nil { + return nil, err + } + + return s.convertToAiModels(rawModels), nil +} + +// ListLocations returns subscription location names that can be used for model queries. +func (s *AiModelService) ListLocations( + ctx context.Context, + subscriptionId string, +) ([]string, error) { + subLocations, err := s.subManager.GetLocations(ctx, subscriptionId) + if err != nil { + return nil, fmt.Errorf("listing locations: %w", err) + } + + locations := make([]string, 0, len(subLocations)) + for _, loc := range subLocations { + locations = append(locations, loc.Name) + } + + return locations, nil +} + +// ListFilteredModels fetches and filters AI models based on the provided criteria. +func (s *AiModelService) ListFilteredModels( + ctx context.Context, + subscriptionId string, + options *FilterOptions, +) ([]AiModel, error) { + // Fetch canonical models and apply filters in-memory so model metadata + // (especially Locations) remains complete. + models, err := s.ListModels(ctx, subscriptionId, nil) + if err != nil { + return nil, err + } + + return FilterModels(models, options), nil +} + +// ListModelVersions returns available versions for a specific model at a location. +func (s *AiModelService) ListModelVersions( + ctx context.Context, + subscriptionId string, + modelName string, + location string, +) ([]AiModelVersion, string, error) { + models, err := s.ListModels(ctx, subscriptionId, []string{location}) + if err != nil { + return nil, "", err + } + + for _, model := range models { + if model.Name == modelName { + defaultVersion := "" + for _, v := range model.Versions { + if v.IsDefault { + defaultVersion = v.Version + break + } + } + return model.Versions, defaultVersion, nil + } + } + + return nil, "", fmt.Errorf("model %q not found at location %q", modelName, location) +} + +// ListModelSkus returns available SKUs for a model+version at a location. +func (s *AiModelService) ListModelSkus( + ctx context.Context, + subscriptionId string, + modelName string, + location string, + version string, +) ([]AiModelSku, error) { + versions, _, err := s.ListModelVersions(ctx, subscriptionId, modelName, location) + if err != nil { + return nil, err + } + + for _, v := range versions { + if v.Version == version { + return v.Skus, nil + } + } + + return nil, fmt.Errorf("version %q not found for model %q at %q", version, modelName, location) +} + +// ResolveModelDeployments returns all valid deployment configurations for the given model. +// Returns multiple candidates when multiple version/SKU/location combos are valid. +// Capacity resolution: options.Capacity → SKU default → 0 (caller must handle). +func (s *AiModelService) ResolveModelDeployments( + ctx context.Context, + subscriptionId string, + modelName string, + options *DeploymentOptions, +) ([]AiModelDeployment, error) { + return s.resolveDeployments(ctx, subscriptionId, modelName, options, nil) +} + +// ResolveModelDeploymentsWithQuota resolves deployments and filters by quota. +// Skips SKUs where resolved capacity exceeds remaining quota. +// Populates RemainingQuota on results. +func (s *AiModelService) ResolveModelDeploymentsWithQuota( + ctx context.Context, + subscriptionId string, + modelName string, + options *DeploymentOptions, + quotaOpts *QuotaCheckOptions, +) ([]AiModelDeployment, error) { + return s.resolveDeployments(ctx, subscriptionId, modelName, options, quotaOpts) +} + +// ListUsages returns quota/usage data for a location. +func (s *AiModelService) ListUsages( + ctx context.Context, + subscriptionId string, + location string, +) ([]AiModelUsage, error) { + rawUsages, err := s.azureClient.GetAiUsages(ctx, subscriptionId, location) + if err != nil { + return nil, fmt.Errorf("getting usages at %q: %w", location, err) + } + + usages := make([]AiModelUsage, 0, len(rawUsages)) + for _, u := range rawUsages { + if u.Name == nil || u.Name.Value == nil { + continue + } + usages = append(usages, AiModelUsage{ + Name: *u.Name.Value, + CurrentValue: safeFloat64(u.CurrentValue), + Limit: safeFloat64(u.Limit), + }) + } + + return usages, nil +} + +// ListLocationsWithQuota returns locations with sufficient quota for all given requirements. +// When allowedLocations are provided, they are intersected with AI Services-supported locations +// to avoid querying locations where AI Services are not available. +func (s *AiModelService) ListLocationsWithQuota( + ctx context.Context, + subscriptionId string, + allowedLocations []string, + requirements []QuotaRequirement, +) ([]string, error) { + skuLocations, err := s.azureClient.GetResourceSkuLocations( + ctx, subscriptionId, "AIServices", "S0", "Standard", "accounts") + if err != nil { + return nil, fmt.Errorf("getting AI Services locations: %w", err) + } + + if len(allowedLocations) == 0 { + allowedLocations = skuLocations + } + + var sharedResults sync.Map + var wg sync.WaitGroup + + for _, loc := range allowedLocations { + // Skip locations where AIServices is not available to avoid unnecessary usage API calls. + if !slices.Contains(skuLocations, loc) { + continue + } + wg.Add(1) + go func(loc string) { + defer wg.Done() + usages, err := s.azureClient.GetAiUsages(ctx, subscriptionId, loc) + if err != nil { + return + } + sharedResults.Store(loc, usages) + }(loc) + } + wg.Wait() + + var results []string + sharedResults.Range(func(key, value any) bool { + loc := key.(string) + usages := value.([]*armcognitiveservices.Usage) + + for _, req := range requirements { + minCap := req.MinCapacity + if minCap <= 0 { + minCap = 1 + } + found := slices.ContainsFunc(usages, func(u *armcognitiveservices.Usage) bool { + if u.Name == nil || u.Name.Value == nil || *u.Name.Value != req.UsageName { + return false + } + remaining := safeFloat64(u.Limit) - safeFloat64(u.CurrentValue) + return remaining >= minCap + }) + if !found { + return true // skip this location + } + } + results = append(results, loc) + return true + }) + + slices.Sort(results) + return results, nil +} + +// ListModelLocationsWithQuota returns model locations that have sufficient remaining quota. +// MaxRemainingQuota is the max remaining quota across the model's SKU usage names +// in each location where usage data exists. +func (s *AiModelService) ListModelLocationsWithQuota( + ctx context.Context, + subscriptionId string, + modelName string, + allowedLocations []string, + minRemaining float64, +) ([]ModelLocationQuota, error) { + if minRemaining <= 0 { + minRemaining = 1 + } + + models, err := s.ListModels(ctx, subscriptionId, nil) + if err != nil { + return nil, err + } + + var targetModel *AiModel + for i := range models { + if models[i].Name == modelName { + targetModel = &models[i] + break + } + } + if targetModel == nil { + return nil, fmt.Errorf("%w: %q", ErrModelNotFound, modelName) + } + + modelLocations := targetModel.Locations + if len(allowedLocations) > 0 { + modelLocations = slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool { + return !slices.Contains(allowedLocations, loc) + }) + } + + var sharedResults sync.Map + var wg sync.WaitGroup + + for _, loc := range modelLocations { + wg.Add(1) + go func(loc string) { + defer wg.Done() + usages, err := s.ListUsages(ctx, subscriptionId, loc) + if err != nil { + return + } + sharedResults.Store(loc, usages) + }(loc) + } + wg.Wait() + + results := []ModelLocationQuota{} + sharedResults.Range(func(key, value any) bool { + loc := key.(string) + usages := value.([]AiModelUsage) + usageMap := make(map[string]AiModelUsage, len(usages)) + for _, usage := range usages { + usageMap[usage.Name] = usage + } + + maxRemainingAtLocation, found := maxModelRemainingQuota(*targetModel, usageMap) + if found && maxRemainingAtLocation >= minRemaining { + results = append(results, ModelLocationQuota{ + Location: loc, + MaxRemainingQuota: maxRemainingAtLocation, + }) + } + + return true + }) + + slices.SortFunc(results, func(a, b ModelLocationQuota) int { + return strings.Compare(a.Location, b.Location) + }) + + return results, nil +} + +// FilterModelsByQuota cross-references models' SKU usage names against usage data +// to filter out models without sufficient remaining capacity. +func FilterModelsByQuota( + models []AiModel, + usages []AiModelUsage, + minRemaining float64, +) []AiModel { + if minRemaining <= 0 { + minRemaining = 1 + } + + usageMap := make(map[string]AiModelUsage, len(usages)) + for _, u := range usages { + usageMap[u.Name] = u + } + + var filtered []AiModel + for _, model := range models { + if modelHasQuota(model, usageMap, minRemaining) { + filtered = append(filtered, model) + } + } + return filtered +} + +// FilterModelsByQuotaAcrossLocations filters models to those having sufficient quota in at least one location. +// When locations is empty, model-declared locations are used. +func (s *AiModelService) FilterModelsByQuotaAcrossLocations( + ctx context.Context, + subscriptionId string, + models []AiModel, + locations []string, + minRemaining float64, +) ([]AiModel, error) { + effectiveLocations := locations + if len(effectiveLocations) == 0 { + effectiveLocations = modelLocations(models) + } + + usagesByLocation, err := s.listUsagesByLocation(ctx, subscriptionId, effectiveLocations) + if err != nil { + return nil, err + } + + return filterModelsByAnyLocationQuota(models, usagesByLocation, minRemaining), nil +} + +// resolveDeployments is the internal deployment resolution logic. +// Returns all valid deployment candidates instead of just the first match. +// No implicit defaults: when options fields are empty, no filtering is applied for that dimension. +// Location is only set on results when exactly one location is provided; otherwise left empty. +// Quota checking requires exactly one location; returns an error if quota is requested with != 1 location. +func (s *AiModelService) resolveDeployments( + ctx context.Context, + subscriptionId string, + modelName string, + options *DeploymentOptions, + quotaOpts *QuotaCheckOptions, +) ([]AiModelDeployment, error) { + if options == nil { + options = &DeploymentOptions{} + } + + // Fail explicitly if quota is requested without exactly one location. + if quotaOpts != nil && len(options.Locations) != 1 { + return nil, fmt.Errorf( + "%w, got %d", ErrQuotaLocationRequired, len(options.Locations)) + } + + models, err := s.ListModels(ctx, subscriptionId, options.Locations) + if err != nil { + return nil, err + } + + // Find the target model + var targetModel *AiModel + for i := range models { + if models[i].Name == modelName { + targetModel = &models[i] + break + } + } + if targetModel == nil { + return nil, fmt.Errorf("%w: %q", ErrModelNotFound, modelName) + } + + // Fetch quota data (guaranteed single location by check above) + var usageMap map[string]AiModelUsage + if quotaOpts != nil { + usages, err := s.ListUsages(ctx, subscriptionId, options.Locations[0]) + if err != nil { + return nil, fmt.Errorf("getting usages for quota check: %w", err) + } + usageMap = make(map[string]AiModelUsage, len(usages)) + for _, u := range usages { + usageMap[u.Name] = u + } + } + + // Resolve: iterate versions → SKUs to collect all valid candidates. + // No implicit version or SKU filtering — callers must pass explicit filters. + var results []AiModelDeployment + + for _, version := range targetModel.Versions { + if len(options.Versions) > 0 && !slices.Contains(options.Versions, version.Version) { + continue + } + + for _, sku := range version.Skus { + if len(options.Skus) > 0 && !slices.Contains(options.Skus, sku.Name) { + continue + } + + capacity := ResolveCapacity(sku, options.Capacity) + + // Quota check + if quotaOpts != nil && usageMap != nil { + usage, ok := usageMap[sku.UsageName] + if !ok { + continue + } + + remaining := usage.Limit - usage.CurrentValue + minReq := quotaOpts.MinRemainingCapacity + if minReq <= 0 { + minReq = 1 + } + if remaining < minReq || (capacity > 0 && float64(capacity) > remaining) { + continue + } + } + + // Only set location when exactly one was provided — never guess. + deployLocation := "" + if len(options.Locations) == 1 { + deployLocation = options.Locations[0] + } + + deployment := AiModelDeployment{ + ModelName: modelName, + Format: targetModel.Format, + Version: version.Version, + Location: deployLocation, + Sku: sku, + Capacity: capacity, + } + + // Populate remaining quota if available + if quotaOpts != nil && usageMap != nil { + usage, ok := usageMap[sku.UsageName] + if ok { + remaining := usage.Limit - usage.CurrentValue + deployment.RemainingQuota = &remaining + } + } + + results = append(results, deployment) + } + } + + if len(results) == 0 { + return nil, fmt.Errorf("%w for model %q with the specified options", ErrNoDeploymentMatch, modelName) + } + + return results, nil +} + +// fetchModelsForLocations fetches models across multiple locations in parallel. +func (s *AiModelService) fetchModelsForLocations( + ctx context.Context, + subscriptionId string, + locations []string, +) (map[string][]*armcognitiveservices.Model, error) { + result := make(map[string][]*armcognitiveservices.Model) + var mu sync.Mutex + var errMu sync.Mutex + var wg sync.WaitGroup + errs := []error{} + + for _, loc := range locations { + // Check cache first + cacheKey := subscriptionId + ":" + loc + s.catalogCacheMu.RLock() + cached, ok := s.catalogCache[cacheKey] + s.catalogCacheMu.RUnlock() + if ok { + mu.Lock() + result[loc] = cached + mu.Unlock() + continue + } + + wg.Add(1) + go func(loc string) { + defer wg.Done() + models, err := s.azureClient.GetAiModels(ctx, subscriptionId, loc) + if err != nil { + errMu.Lock() + errs = append(errs, fmt.Errorf("%s: %w", loc, err)) + errMu.Unlock() + return + } + + // Cache the result + cacheKey := subscriptionId + ":" + loc + s.catalogCacheMu.Lock() + s.catalogCache[cacheKey] = models + s.catalogCacheMu.Unlock() + + mu.Lock() + result[loc] = models + mu.Unlock() + }(loc) + } + wg.Wait() + + if len(result) == 0 && len(errs) > 0 { + return nil, fmt.Errorf("fetching model catalogs: %w", errors.Join(errs...)) + } + + return result, nil +} + +// convertToAiModels converts raw ARM models grouped by location into domain AiModel types. +func (s *AiModelService) convertToAiModels( + rawByLocation map[string][]*armcognitiveservices.Model, +) []AiModel { + // Aggregate: model name → location → version → SKUs + modelMap := make(map[string]*AiModel) + + for loc, models := range rawByLocation { + for _, m := range models { + if m.Model == nil || m.Model.Name == nil { + continue + } + name := *m.Model.Name + + aiModel, exists := modelMap[name] + if !exists { + aiModel = &AiModel{ + Name: name, + Format: safeString(m.Model.Format), + } + if m.Model.LifecycleStatus != nil { + aiModel.LifecycleStatus = string(*m.Model.LifecycleStatus) + } + if m.Model.Capabilities != nil { + for key := range m.Model.Capabilities { + aiModel.Capabilities = append(aiModel.Capabilities, key) + } + slices.Sort(aiModel.Capabilities) + } + modelMap[name] = aiModel + } + + // Track locations + if !slices.Contains(aiModel.Locations, loc) { + aiModel.Locations = append(aiModel.Locations, loc) + } + + // Build version entry + ver := safeString(m.Model.Version) + isDefault := m.Model.IsDefaultVersion != nil && *m.Model.IsDefaultVersion + + var skus []AiModelSku + if m.Model.SKUs != nil { + for _, sku := range m.Model.SKUs { + skus = append(skus, convertSku(sku)) + } + } + + // Find or create version in model + versionFound := false + for i := range aiModel.Versions { + if aiModel.Versions[i].Version == ver { + versionFound = true + if isDefault { + aiModel.Versions[i].IsDefault = true + } + // Merge SKUs (deduplicate by name + usage_name, since the same SKU name + // can appear with different usage names representing different quota pools) + for _, newSku := range skus { + if !slices.ContainsFunc(aiModel.Versions[i].Skus, func(s AiModelSku) bool { + return s.Name == newSku.Name && s.UsageName == newSku.UsageName + }) { + aiModel.Versions[i].Skus = append(aiModel.Versions[i].Skus, newSku) + } + } + break + } + } + if !versionFound { + aiModel.Versions = append(aiModel.Versions, AiModelVersion{ + Version: ver, + IsDefault: isDefault, + Skus: skus, + }) + } + } + } + + // Convert map to sorted slice + result := make([]AiModel, 0, len(modelMap)) + for _, model := range modelMap { + slices.Sort(model.Locations) + result = append(result, *model) + } + slices.SortFunc(result, func(a, b AiModel) int { + return strings.Compare(a.Name, b.Name) + }) + + return result +} + +// FilterModels applies FilterOptions to a list of models. +func FilterModels(models []AiModel, options *FilterOptions) []AiModel { + if options == nil { + return models + } + + var filtered []AiModel + for _, model := range models { + if len(options.ExcludeModelNames) > 0 && slices.Contains(options.ExcludeModelNames, model.Name) { + continue + } + if len(options.Formats) > 0 && !slices.Contains(options.Formats, model.Format) { + continue + } + if len(options.Statuses) > 0 && !slices.Contains(options.Statuses, model.LifecycleStatus) { + continue + } + if len(options.Capabilities) > 0 { + hasCapability := false + for _, cap := range options.Capabilities { + if slices.Contains(model.Capabilities, cap) { + hasCapability = true + break + } + } + if !hasCapability { + continue + } + } + if len(options.Locations) > 0 { + hasLocation := false + for _, loc := range options.Locations { + if slices.Contains(model.Locations, loc) { + hasLocation = true + break + } + } + if !hasLocation { + continue + } + } + filtered = append(filtered, model) + } + + return filtered +} + +func convertSku(sku *armcognitiveservices.ModelSKU) AiModelSku { + result := AiModelSku{ + Name: safeString(sku.Name), + UsageName: safeString(sku.UsageName), + } + if sku.Capacity != nil { + if sku.Capacity.Default != nil { + result.DefaultCapacity = *sku.Capacity.Default + } + if sku.Capacity.Minimum != nil { + result.MinCapacity = *sku.Capacity.Minimum + } + if sku.Capacity.Maximum != nil { + result.MaxCapacity = *sku.Capacity.Maximum + } + if sku.Capacity.Step != nil { + result.CapacityStep = *sku.Capacity.Step + } + } + return result +} + +// ResolveCapacity resolves the deployment capacity for a SKU. +// If preferred is set and valid within the SKU's min/max/step constraints, it's used. +// Otherwise falls back to the SKU's default capacity. +func ResolveCapacity(sku AiModelSku, preferred *int32) int32 { + if preferred != nil { + cap := *preferred + if cap > 0 && + (sku.MinCapacity <= 0 || cap >= sku.MinCapacity) && + (sku.MaxCapacity <= 0 || cap <= sku.MaxCapacity) && + (sku.CapacityStep <= 0 || cap%sku.CapacityStep == 0) { + return cap + } + } + return sku.DefaultCapacity +} + +// ModelHasDefaultVersion returns true if any version of the model is marked as default. +func ModelHasDefaultVersion(model AiModel) bool { + for _, v := range model.Versions { + if v.IsDefault { + return true + } + } + return false +} + +func modelHasQuota(model AiModel, usageMap map[string]AiModelUsage, minRemaining float64) bool { + for _, version := range model.Versions { + for _, sku := range version.Skus { + usage, ok := usageMap[sku.UsageName] + if ok { + remaining := usage.Limit - usage.CurrentValue + if remaining >= minRemaining { + return true + } + } + } + } + return false +} + +func maxModelRemainingQuota(model AiModel, usageMap map[string]AiModelUsage) (float64, bool) { + var maxRemaining float64 + found := false + for _, version := range model.Versions { + for _, sku := range version.Skus { + usage, ok := usageMap[sku.UsageName] + if !ok { + continue + } + + remaining := usage.Limit - usage.CurrentValue + if !found || remaining > maxRemaining { + maxRemaining = remaining + } + found = true + } + } + + return maxRemaining, found +} + +func modelLocations(models []AiModel) []string { + locationSet := map[string]struct{}{} + for _, model := range models { + for _, location := range model.Locations { + locationSet[location] = struct{}{} + } + } + + locations := make([]string, 0, len(locationSet)) + for location := range locationSet { + locations = append(locations, location) + } + + slices.Sort(locations) + + return locations +} + +func filterModelsByAnyLocationQuota( + models []AiModel, + usagesByLocation map[string][]AiModelUsage, + minRemaining float64, +) []AiModel { + eligible := map[string]struct{}{} + + for _, usages := range usagesByLocation { + for _, model := range FilterModelsByQuota(models, usages, minRemaining) { + eligible[model.Name] = struct{}{} + } + } + + filtered := make([]AiModel, 0, len(eligible)) + for _, model := range models { + if _, ok := eligible[model.Name]; ok { + filtered = append(filtered, model) + } + } + + return filtered +} + +func (s *AiModelService) listUsagesByLocation( + ctx context.Context, + subscriptionId string, + locations []string, +) (map[string][]AiModelUsage, error) { + const maxConcurrentUsageCalls = 8 + + var wg sync.WaitGroup + var mu sync.Mutex + sem := make(chan struct{}, maxConcurrentUsageCalls) + usagesByLocation := make(map[string][]AiModelUsage, len(locations)) + var firstErr error + + for _, location := range locations { + location := location + wg.Add(1) + + go func() { + defer wg.Done() + + select { + case sem <- struct{}{}: + case <-ctx.Done(): + mu.Lock() + if firstErr == nil { + firstErr = ctx.Err() + } + mu.Unlock() + + return + } + defer func() { <-sem }() + + usages, err := s.ListUsages(ctx, subscriptionId, location) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + + return + } + + mu.Lock() + usagesByLocation[location] = usages + mu.Unlock() + }() + } + + wg.Wait() + + if len(usagesByLocation) == 0 && firstErr != nil { + return nil, firstErr + } + + return usagesByLocation, nil +} + +func safeString(s *string) string { + if s == nil { + return "" + } + return *s +} + +func safeFloat64(f *float64) float64 { + if f == nil { + return 0 + } + return *f +} diff --git a/cli/azd/pkg/ai/model_service_test.go b/cli/azd/pkg/ai/model_service_test.go new file mode 100644 index 00000000000..43940f4ca9a --- /dev/null +++ b/cli/azd/pkg/ai/model_service_test.go @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFilterModels(t *testing.T) { + models := []AiModel{ + { + Name: "gpt-4o", + Format: "OpenAI", + LifecycleStatus: "stable", + Capabilities: []string{"chat", "completion"}, + Locations: []string{"eastus", "westus"}, + }, + { + Name: "gpt-4o-mini", + Format: "OpenAI", + LifecycleStatus: "preview", + Capabilities: []string{"chat"}, + Locations: []string{"eastus"}, + }, + { + Name: "text-embedding-ada-002", + Format: "OpenAI", + LifecycleStatus: "stable", + Capabilities: []string{"embeddings"}, + Locations: []string{"westus"}, + }, + } + + tests := []struct { + name string + options *FilterOptions + expected []string // expected model names + }{ + { + name: "nil options returns all", + options: nil, + expected: []string{"gpt-4o", "gpt-4o-mini", "text-embedding-ada-002"}, + }, + { + name: "filter by capability - chat", + options: &FilterOptions{Capabilities: []string{"chat"}}, + expected: []string{"gpt-4o", "gpt-4o-mini"}, + }, + { + name: "filter by capability - embeddings", + options: &FilterOptions{Capabilities: []string{"embeddings"}}, + expected: []string{"text-embedding-ada-002"}, + }, + { + name: "filter by format", + options: &FilterOptions{Formats: []string{"OpenAI"}}, + expected: []string{"gpt-4o", "gpt-4o-mini", "text-embedding-ada-002"}, + }, + { + name: "filter by status", + options: &FilterOptions{Statuses: []string{"stable"}}, + expected: []string{"gpt-4o", "text-embedding-ada-002"}, + }, + { + name: "filter by location", + options: &FilterOptions{Locations: []string{"eastus"}}, + expected: []string{"gpt-4o", "gpt-4o-mini"}, + }, + { + name: "exclude model names", + options: &FilterOptions{ExcludeModelNames: []string{"gpt-4o"}}, + expected: []string{"gpt-4o-mini", "text-embedding-ada-002"}, + }, + { + name: "combined filters", + options: &FilterOptions{ + Capabilities: []string{"chat"}, + Statuses: []string{"stable"}, + }, + expected: []string{"gpt-4o"}, + }, + { + name: "no matches", + options: &FilterOptions{ + Capabilities: []string{"image-generation"}, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterModels(models, tt.options) + names := make([]string, len(result)) + for i, m := range result { + names[i] = m.Name + } + require.Equal(t, tt.expected, names) + }) + } +} + +func TestFilterModelsByQuota(t *testing.T) { + models := []AiModel{ + { + Name: "gpt-4o", + Versions: []AiModelVersion{ + { + Version: "2024-05-13", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "OpenAI.Standard.gpt-4o"}, + {Name: "GlobalStandard", UsageName: "OpenAI.GlobalStandard.gpt-4o"}, + }, + }, + }, + }, + { + Name: "gpt-4o-mini", + Versions: []AiModelVersion{ + { + Version: "2024-07-18", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "OpenAI.Standard.gpt-4o-mini"}, + }, + }, + }, + }, + { + Name: "text-embedding-ada-002", + Versions: []AiModelVersion{ + { + Version: "2", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "OpenAI.Standard.text-embedding-ada-002"}, + }, + }, + }, + }, + } + + tests := []struct { + name string + usages []AiModelUsage + minRemaining float64 + expected []string + }{ + { + name: "all models have quota", + usages: []AiModelUsage{ + {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 10, Limit: 100}, + {Name: "OpenAI.GlobalStandard.gpt-4o", CurrentValue: 0, Limit: 200}, + {Name: "OpenAI.Standard.gpt-4o-mini", CurrentValue: 50, Limit: 200}, + {Name: "OpenAI.Standard.text-embedding-ada-002", CurrentValue: 0, Limit: 50}, + }, + minRemaining: 1, + expected: []string{"gpt-4o", "gpt-4o-mini", "text-embedding-ada-002"}, + }, + { + name: "one model exhausted", + usages: []AiModelUsage{ + {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 100, Limit: 100}, + {Name: "OpenAI.GlobalStandard.gpt-4o", CurrentValue: 200, Limit: 200}, + {Name: "OpenAI.Standard.gpt-4o-mini", CurrentValue: 50, Limit: 200}, + {Name: "OpenAI.Standard.text-embedding-ada-002", CurrentValue: 0, Limit: 50}, + }, + minRemaining: 1, + expected: []string{"gpt-4o-mini", "text-embedding-ada-002"}, + }, + { + name: "model with one SKU above threshold keeps model", + usages: []AiModelUsage{ + {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 100, Limit: 100}, // exhausted + {Name: "OpenAI.GlobalStandard.gpt-4o", CurrentValue: 0, Limit: 200}, // 200 remaining + {Name: "OpenAI.Standard.gpt-4o-mini", CurrentValue: 190, Limit: 200}, // 10 remaining + {Name: "OpenAI.Standard.text-embedding-ada-002", CurrentValue: 0, Limit: 50}, + }, + minRemaining: 50, + expected: []string{"gpt-4o", "text-embedding-ada-002"}, + }, + { + name: "min remaining 0 means any remaining > 0", + usages: []AiModelUsage{ + {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 100, Limit: 100}, // 0 remaining + {Name: "OpenAI.GlobalStandard.gpt-4o", CurrentValue: 200, Limit: 200}, // 0 remaining + {Name: "OpenAI.Standard.gpt-4o-mini", CurrentValue: 199, Limit: 200}, // 1 remaining + }, + minRemaining: 0, + expected: []string{"gpt-4o-mini"}, + }, + { + name: "model with no matching usage excluded (conservative)", + usages: []AiModelUsage{ + {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 10, Limit: 100}, + }, + minRemaining: 1, + expected: []string{"gpt-4o"}, + }, + { + name: "empty usages excludes all models", + usages: []AiModelUsage{}, + minRemaining: 1, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterModelsByQuota(models, tt.usages, tt.minRemaining) + names := make([]string, len(result)) + for i, m := range result { + names[i] = m.Name + } + require.Equal(t, tt.expected, names) + }) + } +} + +func TestResolveCapacity(t *testing.T) { + tests := []struct { + name string + sku AiModelSku + preferred *int32 + expected int32 + }{ + { + name: "preferred capacity used when valid", + sku: AiModelSku{ + DefaultCapacity: 10, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + preferred: intPtr(50), + expected: 50, + }, + { + name: "preferred capacity out of range uses default", + sku: AiModelSku{ + DefaultCapacity: 10, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + preferred: intPtr(200), + expected: 10, + }, + { + name: "preferred capacity below min uses default", + sku: AiModelSku{ + DefaultCapacity: 10, + MinCapacity: 5, + MaxCapacity: 100, + CapacityStep: 1, + }, + preferred: intPtr(2), + expected: 10, + }, + { + name: "preferred capacity aligned to step", + sku: AiModelSku{ + DefaultCapacity: 10, + MinCapacity: 0, + MaxCapacity: 100, + CapacityStep: 10, + }, + preferred: intPtr(15), + expected: 10, + }, + { + name: "no preferred uses default", + sku: AiModelSku{ + DefaultCapacity: 25, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + preferred: nil, + expected: 25, + }, + { + name: "no preferred no default returns 0", + sku: AiModelSku{ + DefaultCapacity: 0, + MinCapacity: 1, + MaxCapacity: 100, + CapacityStep: 1, + }, + preferred: nil, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ResolveCapacity(tt.sku, tt.preferred) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestMaxModelRemainingQuota(t *testing.T) { + model := AiModel{ + Name: "gpt-4o", + Versions: []AiModelVersion{ + { + Version: "v1", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "OpenAI.Standard.gpt-4o"}, + {Name: "GlobalStandard", UsageName: "OpenAI.GlobalStandard.gpt-4o"}, + }, + }, + }, + } + + t.Run("returns highest remaining across model skus", func(t *testing.T) { + usageMap := map[string]AiModelUsage{ + "OpenAI.Standard.gpt-4o": {Name: "OpenAI.Standard.gpt-4o", CurrentValue: 50, Limit: 100}, + "OpenAI.GlobalStandard.gpt-4o": {Name: "OpenAI.GlobalStandard.gpt-4o", CurrentValue: 10, Limit: 200}, + } + + maxRemaining, found := maxModelRemainingQuota(model, usageMap) + require.True(t, found) + require.Equal(t, float64(190), maxRemaining) + }) + + t.Run("returns not found when no model usage entries exist", func(t *testing.T) { + usageMap := map[string]AiModelUsage{ + "OpenAI.Standard.other": {Name: "OpenAI.Standard.other", CurrentValue: 1, Limit: 10}, + } + + maxRemaining, found := maxModelRemainingQuota(model, usageMap) + require.False(t, found) + require.Equal(t, float64(0), maxRemaining) + }) +} + +func TestModelLocations(t *testing.T) { + models := []AiModel{ + {Name: "a", Locations: []string{"westus", "eastus"}}, + {Name: "b", Locations: []string{"eastus", "centralus"}}, + } + + locations := modelLocations(models) + + require.Equal(t, []string{"centralus", "eastus", "westus"}, locations) +} + +func TestFilterModelsByAnyLocationQuota(t *testing.T) { + models := []AiModel{ + { + Name: "model-a", + Locations: []string{"eastus", "westus"}, + Versions: []AiModelVersion{ + { + Version: "1", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "a_usage"}, + }, + }, + }, + }, + { + Name: "model-b", + Locations: []string{"westus"}, + Versions: []AiModelVersion{ + { + Version: "1", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "b_usage"}, + }, + }, + }, + }, + { + Name: "model-c", + Locations: []string{"eastus"}, + Versions: []AiModelVersion{ + { + Version: "1", + Skus: []AiModelSku{ + {Name: "Standard", UsageName: "c_usage"}, + }, + }, + }, + }, + } + + usagesByLocation := map[string][]AiModelUsage{ + "eastus": { + {Name: "a_usage", CurrentValue: 9, Limit: 10}, // remaining: 1 + {Name: "c_usage", CurrentValue: 5, Limit: 5}, // remaining: 0 + }, + "westus": { + {Name: "b_usage", CurrentValue: 8, Limit: 10}, // remaining: 2 + }, + } + + filtered := filterModelsByAnyLocationQuota(models, usagesByLocation, 1) + filteredNames := make([]string, 0, len(filtered)) + for _, model := range filtered { + filteredNames = append(filteredNames, model.Name) + } + + require.Equal(t, []string{"model-a", "model-b"}, filteredNames) +} + +func intPtr(v int32) *int32 { + return &v +} diff --git a/cli/azd/pkg/ai/types.go b/cli/azd/pkg/ai/types.go new file mode 100644 index 00000000000..fe169e1246f --- /dev/null +++ b/cli/azd/pkg/ai/types.go @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ai + +// AiModel represents an AI model available in the Azure Cognitive Services catalog. +// It is SDK-agnostic and decoupled from armcognitiveservices types. +type AiModel struct { + // Name is the model name, e.g. "gpt-4o". + Name string + // Format is the model format, e.g. "OpenAI". + Format string + // LifecycleStatus is the model lifecycle status, e.g. "preview", "stable". + LifecycleStatus string + // Capabilities lists the model's capabilities, e.g. ["chat", "embeddings"]. + Capabilities []string + // Versions lists the available versions of this model. + Versions []AiModelVersion + // Locations lists the Azure locations where this model is available. + Locations []string +} + +// AiModelVersion represents a specific version of an AI model. +type AiModelVersion struct { + // Version is the version string, e.g. "2024-05-13". + Version string + // IsDefault indicates whether this is the default version. + IsDefault bool + // Skus lists the available SKUs for this version. + Skus []AiModelSku +} + +// AiModelSku represents a deployment SKU with its capacity constraints. +type AiModelSku struct { + // Name is the SKU name, e.g. "GlobalStandard", "Standard". + Name string + // UsageName is the quota usage name used to join with usage/quota data, + // e.g. "OpenAI.Standard.gpt-4o". + UsageName string + // DefaultCapacity is the suggested deployment capacity (0 if unavailable). + DefaultCapacity int32 + // MinCapacity is the minimum allowed deployment capacity. + MinCapacity int32 + // MaxCapacity is the maximum allowed deployment capacity. + MaxCapacity int32 + // CapacityStep is the capacity increment granularity. + CapacityStep int32 +} + +// AiModelDeployment is a fully resolved deployment configuration. +// +// Capacity vs Quota: +// - Capacity is deployment-level: how many units this specific deployment will consume. +// - RemainingQuota is subscription-level: how much total capacity remains at this +// location for this SKU across all deployments (limit - current_value from usage API). +// +// Constraint: Capacity must be <= RemainingQuota for the deployment to succeed. +type AiModelDeployment struct { + // ModelName is the model name, e.g. "gpt-4o". + ModelName string + // Format is the model format, e.g. "OpenAI". + Format string + // Version is the model version, e.g. "2024-05-13". + Version string + // Location is the Azure location for this deployment. + Location string + // Sku is the selected SKU for this deployment. + Sku AiModelSku + // Capacity is the resolved deployment capacity in units. + // Resolved from: DeploymentOptions.Capacity → Sku.DefaultCapacity → 0 (caller must handle). + Capacity int32 + // RemainingQuota is the subscription quota remaining at this location for this SKU. + // Only populated when a quota check is performed. nil means no quota check was done. + RemainingQuota *float64 +} + +// AiModelUsage represents a subscription-level quota/usage entry for a specific +// model SKU at a location. +type AiModelUsage struct { + // Name is the quota usage name, e.g. "OpenAI.Standard.gpt-4o". + Name string + // CurrentValue is the amount of quota currently consumed. + CurrentValue float64 + // Limit is the total quota limit for this usage name. + Limit float64 +} + +// ModelLocationQuota represents model quota availability in a specific location. +type ModelLocationQuota struct { + // Location is the Azure location name. + Location string + // MaxRemainingQuota is the maximum remaining quota across model SKUs with usage entries. + MaxRemainingQuota float64 +} + +// QuotaRequirement specifies a single quota check: the usage name to check +// and the minimum remaining capacity needed. +type QuotaRequirement struct { + // UsageName is the quota usage name to check, e.g. "OpenAI.Standard.gpt-4o". + UsageName string + // MinCapacity is the minimum remaining capacity needed. If 0, defaults to 1. + MinCapacity float64 +} + +// QuotaCheckOptions enables quota-aware model/deployment selection. +// When provided, the service fetches usage data alongside the model catalog +// and cross-references via AiModelSku.UsageName == AiModelUsage.Name. +type QuotaCheckOptions struct { + // MinRemainingCapacity is the minimum remaining quota required per SKU. + // Models/deployments where no SKU meets this threshold are excluded. + // 0 means "any remaining > 0" (i.e. not fully exhausted). + MinRemainingCapacity float64 +} + +// FilterOptions specifies criteria for filtering AI models. +type FilterOptions struct { + // Locations filters to models available at these locations. + Locations []string + // Capabilities filters by model capabilities, e.g. ["chat", "embeddings"]. + Capabilities []string + // Formats filters by model format, e.g. ["OpenAI"]. + Formats []string + // Statuses filters by lifecycle status, e.g. ["preview", "stable"]. + Statuses []string + // ExcludeModelNames excludes models by name (for multi-model selection flows). + ExcludeModelNames []string +} + +// DeploymentOptions specifies preferences for resolving a model deployment. +// All fields are optional filters. When empty, no filtering is applied for that dimension. +type DeploymentOptions struct { + // Locations lists preferred locations. If empty, location is left unset on results. + Locations []string + // Versions lists preferred versions. If empty, all versions are included. + Versions []string + // Skus lists preferred SKU names, e.g. ["GlobalStandard", "Standard"]. If empty, all SKUs are included. + Skus []string + // Capacity is the preferred deployment capacity. If set and valid + // (within min/max, aligned to step), used directly. If nil, uses SKU default. + Capacity *int32 +} diff --git a/cli/azd/pkg/azdext/ai_error_reasons.go b/cli/azd/pkg/azdext/ai_error_reasons.go new file mode 100644 index 00000000000..649bc7abb6c --- /dev/null +++ b/cli/azd/pkg/azdext/ai_error_reasons.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +// AI error metadata constants used in gRPC ErrorInfo for AI model/prompt APIs. +const ( + AiErrorDomain = "azd.ai" +) + +// AI error reason codes used in gRPC ErrorInfo.Reason. +const ( + AiErrorReasonMissingSubscription = "AI_MISSING_SUBSCRIPTION" + AiErrorReasonLocationRequired = "AI_LOCATION_REQUIRED" + AiErrorReasonQuotaLocation = "AI_QUOTA_LOCATION_REQUIRED" + AiErrorReasonModelNotFound = "AI_MODEL_NOT_FOUND" + AiErrorReasonNoModelsMatch = "AI_NO_MODELS_MATCH" + AiErrorReasonNoDeploymentMatch = "AI_NO_DEPLOYMENT_MATCH" + AiErrorReasonNoValidSkus = "AI_NO_VALID_SKUS" + AiErrorReasonNoLocationsWithQuota = "AI_NO_LOCATIONS_WITH_QUOTA" + AiErrorReasonInvalidCapacity = "AI_INVALID_CAPACITY" + AiErrorReasonInteractiveRequired = "AI_INTERACTIVE_REQUIRED" +) diff --git a/cli/azd/pkg/azdext/ai_model.pb.go b/cli/azd/pkg/azdext/ai_model.pb.go new file mode 100644 index 00000000000..db2a4adc82a --- /dev/null +++ b/cli/azd/pkg/azdext/ai_model.pb.go @@ -0,0 +1,1462 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.32.1 +// source: ai_model.proto + +package azdext + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AiModel struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // e.g. "gpt-4o" + Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` // e.g. "OpenAI" + LifecycleStatus string `protobuf:"bytes,3,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // e.g. "preview", "stable" + Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` // e.g. ["chat", "embeddings"] + Versions []*AiModelVersion `protobuf:"bytes,5,rep,name=versions,proto3" json:"versions,omitempty"` + Locations []string `protobuf:"bytes,6,rep,name=locations,proto3" json:"locations,omitempty"` // canonical locations where available + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModel) Reset() { + *x = AiModel{} + mi := &file_ai_model_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModel) ProtoMessage() {} + +func (x *AiModel) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModel.ProtoReflect.Descriptor instead. +func (*AiModel) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{0} +} + +func (x *AiModel) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AiModel) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +func (x *AiModel) GetLifecycleStatus() string { + if x != nil { + return x.LifecycleStatus + } + return "" +} + +func (x *AiModel) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AiModel) GetVersions() []*AiModelVersion { + if x != nil { + return x.Versions + } + return nil +} + +func (x *AiModel) GetLocations() []string { + if x != nil { + return x.Locations + } + return nil +} + +type AiModelVersion struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + IsDefault bool `protobuf:"varint,2,opt,name=is_default,json=isDefault,proto3" json:"is_default,omitempty"` + Skus []*AiModelSku `protobuf:"bytes,3,rep,name=skus,proto3" json:"skus,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelVersion) Reset() { + *x = AiModelVersion{} + mi := &file_ai_model_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelVersion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelVersion) ProtoMessage() {} + +func (x *AiModelVersion) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelVersion.ProtoReflect.Descriptor instead. +func (*AiModelVersion) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{1} +} + +func (x *AiModelVersion) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AiModelVersion) GetIsDefault() bool { + if x != nil { + return x.IsDefault + } + return false +} + +func (x *AiModelVersion) GetSkus() []*AiModelSku { + if x != nil { + return x.Skus + } + return nil +} + +// AiModelSku represents a deployment SKU with capacity constraints. +type AiModelSku struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // e.g. "GlobalStandard" + UsageName string `protobuf:"bytes,2,opt,name=usage_name,json=usageName,proto3" json:"usage_name,omitempty"` // join key to usage API, e.g. "OpenAI.Standard.gpt-4o" + DefaultCapacity int32 `protobuf:"varint,3,opt,name=default_capacity,json=defaultCapacity,proto3" json:"default_capacity,omitempty"` + MinCapacity int32 `protobuf:"varint,4,opt,name=min_capacity,json=minCapacity,proto3" json:"min_capacity,omitempty"` + MaxCapacity int32 `protobuf:"varint,5,opt,name=max_capacity,json=maxCapacity,proto3" json:"max_capacity,omitempty"` + CapacityStep int32 `protobuf:"varint,6,opt,name=capacity_step,json=capacityStep,proto3" json:"capacity_step,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelSku) Reset() { + *x = AiModelSku{} + mi := &file_ai_model_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelSku) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelSku) ProtoMessage() {} + +func (x *AiModelSku) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelSku.ProtoReflect.Descriptor instead. +func (*AiModelSku) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{2} +} + +func (x *AiModelSku) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AiModelSku) GetUsageName() string { + if x != nil { + return x.UsageName + } + return "" +} + +func (x *AiModelSku) GetDefaultCapacity() int32 { + if x != nil { + return x.DefaultCapacity + } + return 0 +} + +func (x *AiModelSku) GetMinCapacity() int32 { + if x != nil { + return x.MinCapacity + } + return 0 +} + +func (x *AiModelSku) GetMaxCapacity() int32 { + if x != nil { + return x.MaxCapacity + } + return 0 +} + +func (x *AiModelSku) GetCapacityStep() int32 { + if x != nil { + return x.CapacityStep + } + return 0 +} + +// AiModelDeployment is a fully resolved deployment configuration. +// capacity = deployment-level units; remaining_quota = subscription-level remaining. +type AiModelDeployment struct { + state protoimpl.MessageState `protogen:"open.v1"` + ModelName string `protobuf:"bytes,1,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Location string `protobuf:"bytes,4,opt,name=location,proto3" json:"location,omitempty"` + Sku *AiModelSku `protobuf:"bytes,5,opt,name=sku,proto3" json:"sku,omitempty"` + Capacity int32 `protobuf:"varint,6,opt,name=capacity,proto3" json:"capacity,omitempty"` + RemainingQuota *float64 `protobuf:"fixed64,7,opt,name=remaining_quota,json=remainingQuota,proto3,oneof" json:"remaining_quota,omitempty"` // populated when QuotaCheckOptions used + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelDeployment) Reset() { + *x = AiModelDeployment{} + mi := &file_ai_model_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelDeployment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelDeployment) ProtoMessage() {} + +func (x *AiModelDeployment) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelDeployment.ProtoReflect.Descriptor instead. +func (*AiModelDeployment) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{3} +} + +func (x *AiModelDeployment) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +func (x *AiModelDeployment) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +func (x *AiModelDeployment) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AiModelDeployment) GetLocation() string { + if x != nil { + return x.Location + } + return "" +} + +func (x *AiModelDeployment) GetSku() *AiModelSku { + if x != nil { + return x.Sku + } + return nil +} + +func (x *AiModelDeployment) GetCapacity() int32 { + if x != nil { + return x.Capacity + } + return 0 +} + +func (x *AiModelDeployment) GetRemainingQuota() float64 { + if x != nil && x.RemainingQuota != nil { + return *x.RemainingQuota + } + return 0 +} + +// QuotaRequirement: check usage_name has at least min_capacity remaining. +type QuotaRequirement struct { + state protoimpl.MessageState `protogen:"open.v1"` + UsageName string `protobuf:"bytes,1,opt,name=usage_name,json=usageName,proto3" json:"usage_name,omitempty"` + MinCapacity float64 `protobuf:"fixed64,2,opt,name=min_capacity,json=minCapacity,proto3" json:"min_capacity,omitempty"` // 0 defaults to 1 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuotaRequirement) Reset() { + *x = QuotaRequirement{} + mi := &file_ai_model_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuotaRequirement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuotaRequirement) ProtoMessage() {} + +func (x *QuotaRequirement) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuotaRequirement.ProtoReflect.Descriptor instead. +func (*QuotaRequirement) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{4} +} + +func (x *QuotaRequirement) GetUsageName() string { + if x != nil { + return x.UsageName + } + return "" +} + +func (x *QuotaRequirement) GetMinCapacity() float64 { + if x != nil { + return x.MinCapacity + } + return 0 +} + +type AiModelUsage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // quota usage name + CurrentValue float64 `protobuf:"fixed64,2,opt,name=current_value,json=currentValue,proto3" json:"current_value,omitempty"` + Limit float64 `protobuf:"fixed64,3,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelUsage) Reset() { + *x = AiModelUsage{} + mi := &file_ai_model_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelUsage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelUsage) ProtoMessage() {} + +func (x *AiModelUsage) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelUsage.ProtoReflect.Descriptor instead. +func (*AiModelUsage) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{5} +} + +func (x *AiModelUsage) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AiModelUsage) GetCurrentValue() float64 { + if x != nil { + return x.CurrentValue + } + return 0 +} + +func (x *AiModelUsage) GetLimit() float64 { + if x != nil { + return x.Limit + } + return 0 +} + +// QuotaCheckOptions enables quota-aware filtering. +// Fetches usage data and excludes models/SKUs without sufficient remaining capacity. +type QuotaCheckOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + MinRemainingCapacity float64 `protobuf:"fixed64,1,opt,name=min_remaining_capacity,json=minRemainingCapacity,proto3" json:"min_remaining_capacity,omitempty"` // 0 means any remaining > 0 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuotaCheckOptions) Reset() { + *x = QuotaCheckOptions{} + mi := &file_ai_model_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuotaCheckOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuotaCheckOptions) ProtoMessage() {} + +func (x *QuotaCheckOptions) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuotaCheckOptions.ProtoReflect.Descriptor instead. +func (*QuotaCheckOptions) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{6} +} + +func (x *QuotaCheckOptions) GetMinRemainingCapacity() float64 { + if x != nil { + return x.MinRemainingCapacity + } + return 0 +} + +type AiModelFilterOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Restrict which models are returned to ones available in these locations + // (region names like "eastus", "swedencentral"). + // This filter does not rewrite AiModel.locations in the response. + Locations []string `protobuf:"bytes,1,rep,name=locations,proto3" json:"locations,omitempty"` + // Include models that expose at least one of these capabilities. + // Matches values from AiModel.capabilities (for example: "chatCompletion"). + Capabilities []string `protobuf:"bytes,2,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + // Include models whose format matches one of these values. + // Matches AiModel.format exactly (for example: "OpenAI", "Microsoft"). + Formats []string `protobuf:"bytes,3,rep,name=formats,proto3" json:"formats,omitempty"` + // Include models whose lifecycle status matches one of these values. + // Matches AiModel.lifecycle_status exactly (for example: "Stable", "Preview"). + Statuses []string `protobuf:"bytes,4,rep,name=statuses,proto3" json:"statuses,omitempty"` + // Exclude models by exact model name (for example: "gpt-4o-mini"). + ExcludeModelNames []string `protobuf:"bytes,5,rep,name=exclude_model_names,json=excludeModelNames,proto3" json:"exclude_model_names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelFilterOptions) Reset() { + *x = AiModelFilterOptions{} + mi := &file_ai_model_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelFilterOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelFilterOptions) ProtoMessage() {} + +func (x *AiModelFilterOptions) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelFilterOptions.ProtoReflect.Descriptor instead. +func (*AiModelFilterOptions) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{7} +} + +func (x *AiModelFilterOptions) GetLocations() []string { + if x != nil { + return x.Locations + } + return nil +} + +func (x *AiModelFilterOptions) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AiModelFilterOptions) GetFormats() []string { + if x != nil { + return x.Formats + } + return nil +} + +func (x *AiModelFilterOptions) GetStatuses() []string { + if x != nil { + return x.Statuses + } + return nil +} + +func (x *AiModelFilterOptions) GetExcludeModelNames() []string { + if x != nil { + return x.ExcludeModelNames + } + return nil +} + +// AiModelDeploymentOptions: all fields optional — empty means no filtering. +type AiModelDeploymentOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Preferred deployment locations. Empty means all subscription locations. + Locations []string `protobuf:"bytes,1,rep,name=locations,proto3" json:"locations,omitempty"` + // Preferred model versions. Empty means all available versions. + Versions []string `protobuf:"bytes,2,rep,name=versions,proto3" json:"versions,omitempty"` + // Preferred SKU names. Empty means all available SKUs. + Skus []string `protobuf:"bytes,3,rep,name=skus,proto3" json:"skus,omitempty"` + // Preferred deployment capacity. If unset, SKU default is used. + Capacity *int32 `protobuf:"varint,4,opt,name=capacity,proto3,oneof" json:"capacity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AiModelDeploymentOptions) Reset() { + *x = AiModelDeploymentOptions{} + mi := &file_ai_model_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AiModelDeploymentOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AiModelDeploymentOptions) ProtoMessage() {} + +func (x *AiModelDeploymentOptions) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AiModelDeploymentOptions.ProtoReflect.Descriptor instead. +func (*AiModelDeploymentOptions) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{8} +} + +func (x *AiModelDeploymentOptions) GetLocations() []string { + if x != nil { + return x.Locations + } + return nil +} + +func (x *AiModelDeploymentOptions) GetVersions() []string { + if x != nil { + return x.Versions + } + return nil +} + +func (x *AiModelDeploymentOptions) GetSkus() []string { + if x != nil { + return x.Skus + } + return nil +} + +func (x *AiModelDeploymentOptions) GetCapacity() int32 { + if x != nil && x.Capacity != nil { + return *x.Capacity + } + return 0 +} + +type ListModelsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Optional model filter criteria. Empty means no filtering. + Filter *AiModelFilterOptions `protobuf:"bytes,2,opt,name=filter,proto3" json:"filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListModelsRequest) Reset() { + *x = ListModelsRequest{} + mi := &file_ai_model_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListModelsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListModelsRequest) ProtoMessage() {} + +func (x *ListModelsRequest) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListModelsRequest.ProtoReflect.Descriptor instead. +func (*ListModelsRequest) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{9} +} + +func (x *ListModelsRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *ListModelsRequest) GetFilter() *AiModelFilterOptions { + if x != nil { + return x.Filter + } + return nil +} + +type ListModelsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Catalog models after applying optional filters. + Models []*AiModel `protobuf:"bytes,1,rep,name=models,proto3" json:"models,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListModelsResponse) Reset() { + *x = ListModelsResponse{} + mi := &file_ai_model_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListModelsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListModelsResponse) ProtoMessage() {} + +func (x *ListModelsResponse) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListModelsResponse.ProtoReflect.Descriptor instead. +func (*ListModelsResponse) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{10} +} + +func (x *ListModelsResponse) GetModels() []*AiModel { + if x != nil { + return x.Models + } + return nil +} + +type ResolveModelDeploymentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Target model name to resolve deployment candidates for. + ModelName string `protobuf:"bytes,2,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + // Optional deployment filters (locations/versions/SKUs/capacity). + Options *AiModelDeploymentOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` + // Optional quota filter. Requires options.locations with exactly one location. + Quota *QuotaCheckOptions `protobuf:"bytes,4,opt,name=quota,proto3" json:"quota,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveModelDeploymentsRequest) Reset() { + *x = ResolveModelDeploymentsRequest{} + mi := &file_ai_model_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveModelDeploymentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveModelDeploymentsRequest) ProtoMessage() {} + +func (x *ResolveModelDeploymentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveModelDeploymentsRequest.ProtoReflect.Descriptor instead. +func (*ResolveModelDeploymentsRequest) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{11} +} + +func (x *ResolveModelDeploymentsRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *ResolveModelDeploymentsRequest) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +func (x *ResolveModelDeploymentsRequest) GetOptions() *AiModelDeploymentOptions { + if x != nil { + return x.Options + } + return nil +} + +func (x *ResolveModelDeploymentsRequest) GetQuota() *QuotaCheckOptions { + if x != nil { + return x.Quota + } + return nil +} + +type ResolveModelDeploymentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // All valid deployment candidates for the requested model and options. + Deployments []*AiModelDeployment `protobuf:"bytes,1,rep,name=deployments,proto3" json:"deployments,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolveModelDeploymentsResponse) Reset() { + *x = ResolveModelDeploymentsResponse{} + mi := &file_ai_model_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolveModelDeploymentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolveModelDeploymentsResponse) ProtoMessage() {} + +func (x *ResolveModelDeploymentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolveModelDeploymentsResponse.ProtoReflect.Descriptor instead. +func (*ResolveModelDeploymentsResponse) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{12} +} + +func (x *ResolveModelDeploymentsResponse) GetDeployments() []*AiModelDeployment { + if x != nil { + return x.Deployments + } + return nil +} + +type ListUsagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required location for usage query (no fallback from azure_context.scope.location). + Location string `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUsagesRequest) Reset() { + *x = ListUsagesRequest{} + mi := &file_ai_model_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUsagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUsagesRequest) ProtoMessage() {} + +func (x *ListUsagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUsagesRequest.ProtoReflect.Descriptor instead. +func (*ListUsagesRequest) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{13} +} + +func (x *ListUsagesRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *ListUsagesRequest) GetLocation() string { + if x != nil { + return x.Location + } + return "" +} + +type ListUsagesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Quota usage entries for the requested location. + Usages []*AiModelUsage `protobuf:"bytes,1,rep,name=usages,proto3" json:"usages,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUsagesResponse) Reset() { + *x = ListUsagesResponse{} + mi := &file_ai_model_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUsagesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUsagesResponse) ProtoMessage() {} + +func (x *ListUsagesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUsagesResponse.ProtoReflect.Descriptor instead. +func (*ListUsagesResponse) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{14} +} + +func (x *ListUsagesResponse) GetUsages() []*AiModelUsage { + if x != nil { + return x.Usages + } + return nil +} + +type ListLocationsWithQuotaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required quota requirements that each returned location must satisfy. + Requirements []*QuotaRequirement `protobuf:"bytes,2,rep,name=requirements,proto3" json:"requirements,omitempty"` + // Optional allow-list. Empty means all AI Services-supported locations. + AllowedLocations []string `protobuf:"bytes,3,rep,name=allowed_locations,json=allowedLocations,proto3" json:"allowed_locations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLocationsWithQuotaRequest) Reset() { + *x = ListLocationsWithQuotaRequest{} + mi := &file_ai_model_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLocationsWithQuotaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLocationsWithQuotaRequest) ProtoMessage() {} + +func (x *ListLocationsWithQuotaRequest) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLocationsWithQuotaRequest.ProtoReflect.Descriptor instead. +func (*ListLocationsWithQuotaRequest) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{15} +} + +func (x *ListLocationsWithQuotaRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *ListLocationsWithQuotaRequest) GetRequirements() []*QuotaRequirement { + if x != nil { + return x.Requirements + } + return nil +} + +func (x *ListLocationsWithQuotaRequest) GetAllowedLocations() []string { + if x != nil { + return x.AllowedLocations + } + return nil +} + +type ListLocationsWithQuotaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Locations that satisfy all quota requirements. + Locations []*Location `protobuf:"bytes,1,rep,name=locations,proto3" json:"locations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListLocationsWithQuotaResponse) Reset() { + *x = ListLocationsWithQuotaResponse{} + mi := &file_ai_model_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListLocationsWithQuotaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListLocationsWithQuotaResponse) ProtoMessage() {} + +func (x *ListLocationsWithQuotaResponse) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListLocationsWithQuotaResponse.ProtoReflect.Descriptor instead. +func (*ListLocationsWithQuotaResponse) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{16} +} + +func (x *ListLocationsWithQuotaResponse) GetLocations() []*Location { + if x != nil { + return x.Locations + } + return nil +} + +type ModelLocationQuota struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Location where model quota was evaluated. + Location *Location `protobuf:"bytes,1,opt,name=location,proto3" json:"location,omitempty"` + MaxRemainingQuota float64 `protobuf:"fixed64,2,opt,name=max_remaining_quota,json=maxRemainingQuota,proto3" json:"max_remaining_quota,omitempty"` // max remaining quota across model SKUs + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelLocationQuota) Reset() { + *x = ModelLocationQuota{} + mi := &file_ai_model_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelLocationQuota) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelLocationQuota) ProtoMessage() {} + +func (x *ModelLocationQuota) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModelLocationQuota.ProtoReflect.Descriptor instead. +func (*ModelLocationQuota) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{17} +} + +func (x *ModelLocationQuota) GetLocation() *Location { + if x != nil { + return x.Location + } + return nil +} + +func (x *ModelLocationQuota) GetMaxRemainingQuota() float64 { + if x != nil { + return x.MaxRemainingQuota + } + return 0 +} + +type ListModelLocationsWithQuotaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required model name to evaluate across locations. + ModelName string `protobuf:"bytes,2,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + // Optional allow-list. Empty means all locations where the model is available. + AllowedLocations []string `protobuf:"bytes,3,rep,name=allowed_locations,json=allowedLocations,proto3" json:"allowed_locations,omitempty"` + // Optional min remaining quota threshold. + Quota *QuotaCheckOptions `protobuf:"bytes,4,opt,name=quota,proto3" json:"quota,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListModelLocationsWithQuotaRequest) Reset() { + *x = ListModelLocationsWithQuotaRequest{} + mi := &file_ai_model_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListModelLocationsWithQuotaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListModelLocationsWithQuotaRequest) ProtoMessage() {} + +func (x *ListModelLocationsWithQuotaRequest) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListModelLocationsWithQuotaRequest.ProtoReflect.Descriptor instead. +func (*ListModelLocationsWithQuotaRequest) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{18} +} + +func (x *ListModelLocationsWithQuotaRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *ListModelLocationsWithQuotaRequest) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +func (x *ListModelLocationsWithQuotaRequest) GetAllowedLocations() []string { + if x != nil { + return x.AllowedLocations + } + return nil +} + +func (x *ListModelLocationsWithQuotaRequest) GetQuota() *QuotaCheckOptions { + if x != nil { + return x.Quota + } + return nil +} + +type ListModelLocationsWithQuotaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Locations where the model has sufficient remaining quota. + Locations []*ModelLocationQuota `protobuf:"bytes,1,rep,name=locations,proto3" json:"locations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListModelLocationsWithQuotaResponse) Reset() { + *x = ListModelLocationsWithQuotaResponse{} + mi := &file_ai_model_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListModelLocationsWithQuotaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListModelLocationsWithQuotaResponse) ProtoMessage() {} + +func (x *ListModelLocationsWithQuotaResponse) ProtoReflect() protoreflect.Message { + mi := &file_ai_model_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListModelLocationsWithQuotaResponse.ProtoReflect.Descriptor instead. +func (*ListModelLocationsWithQuotaResponse) Descriptor() ([]byte, []int) { + return file_ai_model_proto_rawDescGZIP(), []int{19} +} + +func (x *ListModelLocationsWithQuotaResponse) GetLocations() []*ModelLocationQuota { + if x != nil { + return x.Locations + } + return nil +} + +var File_ai_model_proto protoreflect.FileDescriptor + +const file_ai_model_proto_rawDesc = "" + + "\n" + + "\x0eai_model.proto\x12\x06azdext\x1a\fmodels.proto\"\xd6\x01\n" + + "\aAiModel\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + + "\x06format\x18\x02 \x01(\tR\x06format\x12)\n" + + "\x10lifecycle_status\x18\x03 \x01(\tR\x0flifecycleStatus\x12\"\n" + + "\fcapabilities\x18\x04 \x03(\tR\fcapabilities\x122\n" + + "\bversions\x18\x05 \x03(\v2\x16.azdext.AiModelVersionR\bversions\x12\x1c\n" + + "\tlocations\x18\x06 \x03(\tR\tlocations\"q\n" + + "\x0eAiModelVersion\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1d\n" + + "\n" + + "is_default\x18\x02 \x01(\bR\tisDefault\x12&\n" + + "\x04skus\x18\x03 \x03(\v2\x12.azdext.AiModelSkuR\x04skus\"\xd5\x01\n" + + "\n" + + "AiModelSku\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "usage_name\x18\x02 \x01(\tR\tusageName\x12)\n" + + "\x10default_capacity\x18\x03 \x01(\x05R\x0fdefaultCapacity\x12!\n" + + "\fmin_capacity\x18\x04 \x01(\x05R\vminCapacity\x12!\n" + + "\fmax_capacity\x18\x05 \x01(\x05R\vmaxCapacity\x12#\n" + + "\rcapacity_step\x18\x06 \x01(\x05R\fcapacityStep\"\x84\x02\n" + + "\x11AiModelDeployment\x12\x1d\n" + + "\n" + + "model_name\x18\x01 \x01(\tR\tmodelName\x12\x16\n" + + "\x06format\x18\x02 \x01(\tR\x06format\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12\x1a\n" + + "\blocation\x18\x04 \x01(\tR\blocation\x12$\n" + + "\x03sku\x18\x05 \x01(\v2\x12.azdext.AiModelSkuR\x03sku\x12\x1a\n" + + "\bcapacity\x18\x06 \x01(\x05R\bcapacity\x12,\n" + + "\x0fremaining_quota\x18\a \x01(\x01H\x00R\x0eremainingQuota\x88\x01\x01B\x12\n" + + "\x10_remaining_quota\"T\n" + + "\x10QuotaRequirement\x12\x1d\n" + + "\n" + + "usage_name\x18\x01 \x01(\tR\tusageName\x12!\n" + + "\fmin_capacity\x18\x02 \x01(\x01R\vminCapacity\"]\n" + + "\fAiModelUsage\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + + "\rcurrent_value\x18\x02 \x01(\x01R\fcurrentValue\x12\x14\n" + + "\x05limit\x18\x03 \x01(\x01R\x05limit\"I\n" + + "\x11QuotaCheckOptions\x124\n" + + "\x16min_remaining_capacity\x18\x01 \x01(\x01R\x14minRemainingCapacity\"\xbe\x01\n" + + "\x14AiModelFilterOptions\x12\x1c\n" + + "\tlocations\x18\x01 \x03(\tR\tlocations\x12\"\n" + + "\fcapabilities\x18\x02 \x03(\tR\fcapabilities\x12\x18\n" + + "\aformats\x18\x03 \x03(\tR\aformats\x12\x1a\n" + + "\bstatuses\x18\x04 \x03(\tR\bstatuses\x12.\n" + + "\x13exclude_model_names\x18\x05 \x03(\tR\x11excludeModelNames\"\x96\x01\n" + + "\x18AiModelDeploymentOptions\x12\x1c\n" + + "\tlocations\x18\x01 \x03(\tR\tlocations\x12\x1a\n" + + "\bversions\x18\x02 \x03(\tR\bversions\x12\x12\n" + + "\x04skus\x18\x03 \x03(\tR\x04skus\x12\x1f\n" + + "\bcapacity\x18\x04 \x01(\x05H\x00R\bcapacity\x88\x01\x01B\v\n" + + "\t_capacity\"\x84\x01\n" + + "\x11ListModelsRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x124\n" + + "\x06filter\x18\x02 \x01(\v2\x1c.azdext.AiModelFilterOptionsR\x06filter\"=\n" + + "\x12ListModelsResponse\x12'\n" + + "\x06models\x18\x01 \x03(\v2\x0f.azdext.AiModelR\x06models\"\xe7\x01\n" + + "\x1eResolveModelDeploymentsRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12\x1d\n" + + "\n" + + "model_name\x18\x02 \x01(\tR\tmodelName\x12:\n" + + "\aoptions\x18\x03 \x01(\v2 .azdext.AiModelDeploymentOptionsR\aoptions\x12/\n" + + "\x05quota\x18\x04 \x01(\v2\x19.azdext.QuotaCheckOptionsR\x05quota\"^\n" + + "\x1fResolveModelDeploymentsResponse\x12;\n" + + "\vdeployments\x18\x01 \x03(\v2\x19.azdext.AiModelDeploymentR\vdeployments\"j\n" + + "\x11ListUsagesRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12\x1a\n" + + "\blocation\x18\x02 \x01(\tR\blocation\"B\n" + + "\x12ListUsagesResponse\x12,\n" + + "\x06usages\x18\x01 \x03(\v2\x14.azdext.AiModelUsageR\x06usages\"\xc5\x01\n" + + "\x1dListLocationsWithQuotaRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12<\n" + + "\frequirements\x18\x02 \x03(\v2\x18.azdext.QuotaRequirementR\frequirements\x12+\n" + + "\x11allowed_locations\x18\x03 \x03(\tR\x10allowedLocations\"P\n" + + "\x1eListLocationsWithQuotaResponse\x12.\n" + + "\tlocations\x18\x01 \x03(\v2\x10.azdext.LocationR\tlocations\"r\n" + + "\x12ModelLocationQuota\x12,\n" + + "\blocation\x18\x01 \x01(\v2\x10.azdext.LocationR\blocation\x12.\n" + + "\x13max_remaining_quota\x18\x02 \x01(\x01R\x11maxRemainingQuota\"\xdc\x01\n" + + "\"ListModelLocationsWithQuotaRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12\x1d\n" + + "\n" + + "model_name\x18\x02 \x01(\tR\tmodelName\x12+\n" + + "\x11allowed_locations\x18\x03 \x03(\tR\x10allowedLocations\x12/\n" + + "\x05quota\x18\x04 \x01(\v2\x19.azdext.QuotaCheckOptionsR\x05quota\"_\n" + + "#ListModelLocationsWithQuotaResponse\x128\n" + + "\tlocations\x18\x01 \x03(\v2\x1a.azdext.ModelLocationQuotaR\tlocations2\xe7\x03\n" + + "\x0eAiModelService\x12C\n" + + "\n" + + "ListModels\x12\x19.azdext.ListModelsRequest\x1a\x1a.azdext.ListModelsResponse\x12j\n" + + "\x17ResolveModelDeployments\x12&.azdext.ResolveModelDeploymentsRequest\x1a'.azdext.ResolveModelDeploymentsResponse\x12C\n" + + "\n" + + "ListUsages\x12\x19.azdext.ListUsagesRequest\x1a\x1a.azdext.ListUsagesResponse\x12g\n" + + "\x16ListLocationsWithQuota\x12%.azdext.ListLocationsWithQuotaRequest\x1a&.azdext.ListLocationsWithQuotaResponse\x12v\n" + + "\x1bListModelLocationsWithQuota\x12*.azdext.ListModelLocationsWithQuotaRequest\x1a+.azdext.ListModelLocationsWithQuotaResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + +var ( + file_ai_model_proto_rawDescOnce sync.Once + file_ai_model_proto_rawDescData []byte +) + +func file_ai_model_proto_rawDescGZIP() []byte { + file_ai_model_proto_rawDescOnce.Do(func() { + file_ai_model_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ai_model_proto_rawDesc), len(file_ai_model_proto_rawDesc))) + }) + return file_ai_model_proto_rawDescData +} + +var file_ai_model_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_ai_model_proto_goTypes = []any{ + (*AiModel)(nil), // 0: azdext.AiModel + (*AiModelVersion)(nil), // 1: azdext.AiModelVersion + (*AiModelSku)(nil), // 2: azdext.AiModelSku + (*AiModelDeployment)(nil), // 3: azdext.AiModelDeployment + (*QuotaRequirement)(nil), // 4: azdext.QuotaRequirement + (*AiModelUsage)(nil), // 5: azdext.AiModelUsage + (*QuotaCheckOptions)(nil), // 6: azdext.QuotaCheckOptions + (*AiModelFilterOptions)(nil), // 7: azdext.AiModelFilterOptions + (*AiModelDeploymentOptions)(nil), // 8: azdext.AiModelDeploymentOptions + (*ListModelsRequest)(nil), // 9: azdext.ListModelsRequest + (*ListModelsResponse)(nil), // 10: azdext.ListModelsResponse + (*ResolveModelDeploymentsRequest)(nil), // 11: azdext.ResolveModelDeploymentsRequest + (*ResolveModelDeploymentsResponse)(nil), // 12: azdext.ResolveModelDeploymentsResponse + (*ListUsagesRequest)(nil), // 13: azdext.ListUsagesRequest + (*ListUsagesResponse)(nil), // 14: azdext.ListUsagesResponse + (*ListLocationsWithQuotaRequest)(nil), // 15: azdext.ListLocationsWithQuotaRequest + (*ListLocationsWithQuotaResponse)(nil), // 16: azdext.ListLocationsWithQuotaResponse + (*ModelLocationQuota)(nil), // 17: azdext.ModelLocationQuota + (*ListModelLocationsWithQuotaRequest)(nil), // 18: azdext.ListModelLocationsWithQuotaRequest + (*ListModelLocationsWithQuotaResponse)(nil), // 19: azdext.ListModelLocationsWithQuotaResponse + (*AzureContext)(nil), // 20: azdext.AzureContext + (*Location)(nil), // 21: azdext.Location +} +var file_ai_model_proto_depIdxs = []int32{ + 1, // 0: azdext.AiModel.versions:type_name -> azdext.AiModelVersion + 2, // 1: azdext.AiModelVersion.skus:type_name -> azdext.AiModelSku + 2, // 2: azdext.AiModelDeployment.sku:type_name -> azdext.AiModelSku + 20, // 3: azdext.ListModelsRequest.azure_context:type_name -> azdext.AzureContext + 7, // 4: azdext.ListModelsRequest.filter:type_name -> azdext.AiModelFilterOptions + 0, // 5: azdext.ListModelsResponse.models:type_name -> azdext.AiModel + 20, // 6: azdext.ResolveModelDeploymentsRequest.azure_context:type_name -> azdext.AzureContext + 8, // 7: azdext.ResolveModelDeploymentsRequest.options:type_name -> azdext.AiModelDeploymentOptions + 6, // 8: azdext.ResolveModelDeploymentsRequest.quota:type_name -> azdext.QuotaCheckOptions + 3, // 9: azdext.ResolveModelDeploymentsResponse.deployments:type_name -> azdext.AiModelDeployment + 20, // 10: azdext.ListUsagesRequest.azure_context:type_name -> azdext.AzureContext + 5, // 11: azdext.ListUsagesResponse.usages:type_name -> azdext.AiModelUsage + 20, // 12: azdext.ListLocationsWithQuotaRequest.azure_context:type_name -> azdext.AzureContext + 4, // 13: azdext.ListLocationsWithQuotaRequest.requirements:type_name -> azdext.QuotaRequirement + 21, // 14: azdext.ListLocationsWithQuotaResponse.locations:type_name -> azdext.Location + 21, // 15: azdext.ModelLocationQuota.location:type_name -> azdext.Location + 20, // 16: azdext.ListModelLocationsWithQuotaRequest.azure_context:type_name -> azdext.AzureContext + 6, // 17: azdext.ListModelLocationsWithQuotaRequest.quota:type_name -> azdext.QuotaCheckOptions + 17, // 18: azdext.ListModelLocationsWithQuotaResponse.locations:type_name -> azdext.ModelLocationQuota + 9, // 19: azdext.AiModelService.ListModels:input_type -> azdext.ListModelsRequest + 11, // 20: azdext.AiModelService.ResolveModelDeployments:input_type -> azdext.ResolveModelDeploymentsRequest + 13, // 21: azdext.AiModelService.ListUsages:input_type -> azdext.ListUsagesRequest + 15, // 22: azdext.AiModelService.ListLocationsWithQuota:input_type -> azdext.ListLocationsWithQuotaRequest + 18, // 23: azdext.AiModelService.ListModelLocationsWithQuota:input_type -> azdext.ListModelLocationsWithQuotaRequest + 10, // 24: azdext.AiModelService.ListModels:output_type -> azdext.ListModelsResponse + 12, // 25: azdext.AiModelService.ResolveModelDeployments:output_type -> azdext.ResolveModelDeploymentsResponse + 14, // 26: azdext.AiModelService.ListUsages:output_type -> azdext.ListUsagesResponse + 16, // 27: azdext.AiModelService.ListLocationsWithQuota:output_type -> azdext.ListLocationsWithQuotaResponse + 19, // 28: azdext.AiModelService.ListModelLocationsWithQuota:output_type -> azdext.ListModelLocationsWithQuotaResponse + 24, // [24:29] is the sub-list for method output_type + 19, // [19:24] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name +} + +func init() { file_ai_model_proto_init() } +func file_ai_model_proto_init() { + if File_ai_model_proto != nil { + return + } + file_models_proto_init() + file_ai_model_proto_msgTypes[3].OneofWrappers = []any{} + file_ai_model_proto_msgTypes[8].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ai_model_proto_rawDesc), len(file_ai_model_proto_rawDesc)), + NumEnums: 0, + NumMessages: 20, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ai_model_proto_goTypes, + DependencyIndexes: file_ai_model_proto_depIdxs, + MessageInfos: file_ai_model_proto_msgTypes, + }.Build() + File_ai_model_proto = out.File + file_ai_model_proto_goTypes = nil + file_ai_model_proto_depIdxs = nil +} diff --git a/cli/azd/pkg/azdext/ai_model_grpc.pb.go b/cli/azd/pkg/azdext/ai_model_grpc.pb.go new file mode 100644 index 00000000000..a8d07ebe5ae --- /dev/null +++ b/cli/azd/pkg/azdext/ai_model_grpc.pb.go @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v6.32.1 +// source: ai_model.proto + +package azdext + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AiModelService_ListModels_FullMethodName = "/azdext.AiModelService/ListModels" + AiModelService_ResolveModelDeployments_FullMethodName = "/azdext.AiModelService/ResolveModelDeployments" + AiModelService_ListUsages_FullMethodName = "/azdext.AiModelService/ListUsages" + AiModelService_ListLocationsWithQuota_FullMethodName = "/azdext.AiModelService/ListLocationsWithQuota" + AiModelService_ListModelLocationsWithQuota_FullMethodName = "/azdext.AiModelService/ListModelLocationsWithQuota" +) + +// AiModelServiceClient is the client API for AiModelService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AiModelService provides data-only operations for AI model catalog, +// deployment resolution, and quota/usage from Azure Cognitive Services. +// AzureContext is required for subscription_id. +type AiModelServiceClient interface { + // ListModels returns available AI models, optionally filtered by filter.locations. + // If filter.locations is empty, models are queried across all subscription locations. + // Note: filter.locations controls which models are returned, but each returned model + // keeps canonical metadata (including the full locations list). + ListModels(ctx context.Context, in *ListModelsRequest, opts ...grpc.CallOption) (*ListModelsResponse, error) + // ResolveModelDeployments returns all valid deployment configs for a model. + // options.locations controls location scoping (empty means all subscription locations). + // If quota is set, options.locations must contain exactly one location. + ResolveModelDeployments(ctx context.Context, in *ResolveModelDeploymentsRequest, opts ...grpc.CallOption) (*ResolveModelDeploymentsResponse, error) + // ListUsages returns quota/usage data for request.location. + // request.location is required. + ListUsages(ctx context.Context, in *ListUsagesRequest, opts ...grpc.CallOption) (*ListUsagesResponse, error) + // ListLocationsWithQuota returns locations with sufficient quota. + ListLocationsWithQuota(ctx context.Context, in *ListLocationsWithQuotaRequest, opts ...grpc.CallOption) (*ListLocationsWithQuotaResponse, error) + // ListModelLocationsWithQuota returns locations where model has sufficient quota. + // Response includes max remaining quota per location for label rendering. + ListModelLocationsWithQuota(ctx context.Context, in *ListModelLocationsWithQuotaRequest, opts ...grpc.CallOption) (*ListModelLocationsWithQuotaResponse, error) +} + +type aiModelServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAiModelServiceClient(cc grpc.ClientConnInterface) AiModelServiceClient { + return &aiModelServiceClient{cc} +} + +func (c *aiModelServiceClient) ListModels(ctx context.Context, in *ListModelsRequest, opts ...grpc.CallOption) (*ListModelsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListModelsResponse) + err := c.cc.Invoke(ctx, AiModelService_ListModels_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiModelServiceClient) ResolveModelDeployments(ctx context.Context, in *ResolveModelDeploymentsRequest, opts ...grpc.CallOption) (*ResolveModelDeploymentsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResolveModelDeploymentsResponse) + err := c.cc.Invoke(ctx, AiModelService_ResolveModelDeployments_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiModelServiceClient) ListUsages(ctx context.Context, in *ListUsagesRequest, opts ...grpc.CallOption) (*ListUsagesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListUsagesResponse) + err := c.cc.Invoke(ctx, AiModelService_ListUsages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiModelServiceClient) ListLocationsWithQuota(ctx context.Context, in *ListLocationsWithQuotaRequest, opts ...grpc.CallOption) (*ListLocationsWithQuotaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListLocationsWithQuotaResponse) + err := c.cc.Invoke(ctx, AiModelService_ListLocationsWithQuota_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiModelServiceClient) ListModelLocationsWithQuota(ctx context.Context, in *ListModelLocationsWithQuotaRequest, opts ...grpc.CallOption) (*ListModelLocationsWithQuotaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListModelLocationsWithQuotaResponse) + err := c.cc.Invoke(ctx, AiModelService_ListModelLocationsWithQuota_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AiModelServiceServer is the server API for AiModelService service. +// All implementations must embed UnimplementedAiModelServiceServer +// for forward compatibility. +// +// AiModelService provides data-only operations for AI model catalog, +// deployment resolution, and quota/usage from Azure Cognitive Services. +// AzureContext is required for subscription_id. +type AiModelServiceServer interface { + // ListModels returns available AI models, optionally filtered by filter.locations. + // If filter.locations is empty, models are queried across all subscription locations. + // Note: filter.locations controls which models are returned, but each returned model + // keeps canonical metadata (including the full locations list). + ListModels(context.Context, *ListModelsRequest) (*ListModelsResponse, error) + // ResolveModelDeployments returns all valid deployment configs for a model. + // options.locations controls location scoping (empty means all subscription locations). + // If quota is set, options.locations must contain exactly one location. + ResolveModelDeployments(context.Context, *ResolveModelDeploymentsRequest) (*ResolveModelDeploymentsResponse, error) + // ListUsages returns quota/usage data for request.location. + // request.location is required. + ListUsages(context.Context, *ListUsagesRequest) (*ListUsagesResponse, error) + // ListLocationsWithQuota returns locations with sufficient quota. + ListLocationsWithQuota(context.Context, *ListLocationsWithQuotaRequest) (*ListLocationsWithQuotaResponse, error) + // ListModelLocationsWithQuota returns locations where model has sufficient quota. + // Response includes max remaining quota per location for label rendering. + ListModelLocationsWithQuota(context.Context, *ListModelLocationsWithQuotaRequest) (*ListModelLocationsWithQuotaResponse, error) + mustEmbedUnimplementedAiModelServiceServer() +} + +// UnimplementedAiModelServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAiModelServiceServer struct{} + +func (UnimplementedAiModelServiceServer) ListModels(context.Context, *ListModelsRequest) (*ListModelsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListModels not implemented") +} +func (UnimplementedAiModelServiceServer) ResolveModelDeployments(context.Context, *ResolveModelDeploymentsRequest) (*ResolveModelDeploymentsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResolveModelDeployments not implemented") +} +func (UnimplementedAiModelServiceServer) ListUsages(context.Context, *ListUsagesRequest) (*ListUsagesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListUsages not implemented") +} +func (UnimplementedAiModelServiceServer) ListLocationsWithQuota(context.Context, *ListLocationsWithQuotaRequest) (*ListLocationsWithQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListLocationsWithQuota not implemented") +} +func (UnimplementedAiModelServiceServer) ListModelLocationsWithQuota(context.Context, *ListModelLocationsWithQuotaRequest) (*ListModelLocationsWithQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListModelLocationsWithQuota not implemented") +} +func (UnimplementedAiModelServiceServer) mustEmbedUnimplementedAiModelServiceServer() {} +func (UnimplementedAiModelServiceServer) testEmbeddedByValue() {} + +// UnsafeAiModelServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AiModelServiceServer will +// result in compilation errors. +type UnsafeAiModelServiceServer interface { + mustEmbedUnimplementedAiModelServiceServer() +} + +func RegisterAiModelServiceServer(s grpc.ServiceRegistrar, srv AiModelServiceServer) { + // If the following call pancis, it indicates UnimplementedAiModelServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AiModelService_ServiceDesc, srv) +} + +func _AiModelService_ListModels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListModelsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiModelServiceServer).ListModels(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AiModelService_ListModels_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiModelServiceServer).ListModels(ctx, req.(*ListModelsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AiModelService_ResolveModelDeployments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResolveModelDeploymentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiModelServiceServer).ResolveModelDeployments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AiModelService_ResolveModelDeployments_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiModelServiceServer).ResolveModelDeployments(ctx, req.(*ResolveModelDeploymentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AiModelService_ListUsages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListUsagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiModelServiceServer).ListUsages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AiModelService_ListUsages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiModelServiceServer).ListUsages(ctx, req.(*ListUsagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AiModelService_ListLocationsWithQuota_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListLocationsWithQuotaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiModelServiceServer).ListLocationsWithQuota(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AiModelService_ListLocationsWithQuota_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiModelServiceServer).ListLocationsWithQuota(ctx, req.(*ListLocationsWithQuotaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AiModelService_ListModelLocationsWithQuota_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListModelLocationsWithQuotaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiModelServiceServer).ListModelLocationsWithQuota(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AiModelService_ListModelLocationsWithQuota_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiModelServiceServer).ListModelLocationsWithQuota(ctx, req.(*ListModelLocationsWithQuotaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AiModelService_ServiceDesc is the grpc.ServiceDesc for AiModelService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AiModelService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "azdext.AiModelService", + HandlerType: (*AiModelServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListModels", + Handler: _AiModelService_ListModels_Handler, + }, + { + MethodName: "ResolveModelDeployments", + Handler: _AiModelService_ResolveModelDeployments_Handler, + }, + { + MethodName: "ListUsages", + Handler: _AiModelService_ListUsages_Handler, + }, + { + MethodName: "ListLocationsWithQuota", + Handler: _AiModelService_ListLocationsWithQuota_Handler, + }, + { + MethodName: "ListModelLocationsWithQuota", + Handler: _AiModelService_ListModelLocationsWithQuota_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ai_model.proto", +} diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index 91aa4f4d8dc..d42a2608536 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -30,6 +30,7 @@ type AzdClient struct { serviceTargetClient ServiceTargetServiceClient containerClient ContainerServiceClient accountClient AccountServiceClient + aiClient AiModelServiceClient } // WithAddress sets the address of the `azd` gRPC server. @@ -189,3 +190,12 @@ func (c *AzdClient) Account() AccountServiceClient { return c.accountClient } + +// Ai returns the AI model service client. +func (c *AzdClient) Ai() AiModelServiceClient { + if c.aiClient == nil { + c.aiClient = NewAiModelServiceClient(c.connection) + } + + return c.aiClient +} diff --git a/cli/azd/pkg/azdext/prompt.pb.go b/cli/azd/pkg/azdext/prompt.pb.go index 027dff5bb83..960e1e7abab 100644 --- a/cli/azd/pkg/azdext/prompt.pb.go +++ b/cli/azd/pkg/azdext/prompt.pb.go @@ -1580,11 +1580,526 @@ func (x *PromptResourceGroupOptions) GetSelectOptions() *PromptResourceSelectOpt return nil } +type PromptAiModelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Optional model filter criteria. + Filter *AiModelFilterOptions `protobuf:"bytes,2,opt,name=filter,proto3" json:"filter,omitempty"` + // Optional select prompt customization (for example, message override). + SelectOptions *SelectOptions `protobuf:"bytes,3,opt,name=select_options,json=selectOptions,proto3" json:"select_options,omitempty"` + // Optional quota filter. + // Quota is evaluated using effective locations from filter.locations. + // With multiple locations, a model is kept if any location has sufficient quota. + Quota *QuotaCheckOptions `protobuf:"bytes,4,opt,name=quota,proto3" json:"quota,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiModelRequest) Reset() { + *x = PromptAiModelRequest{} + mi := &file_prompt_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiModelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiModelRequest) ProtoMessage() {} + +func (x *PromptAiModelRequest) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiModelRequest.ProtoReflect.Descriptor instead. +func (*PromptAiModelRequest) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{27} +} + +func (x *PromptAiModelRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *PromptAiModelRequest) GetFilter() *AiModelFilterOptions { + if x != nil { + return x.Filter + } + return nil +} + +func (x *PromptAiModelRequest) GetSelectOptions() *SelectOptions { + if x != nil { + return x.SelectOptions + } + return nil +} + +func (x *PromptAiModelRequest) GetQuota() *QuotaCheckOptions { + if x != nil { + return x.Quota + } + return nil +} + +type PromptAiModelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Selected model from the filtered catalog. + Model *AiModel `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiModelResponse) Reset() { + *x = PromptAiModelResponse{} + mi := &file_prompt_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiModelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiModelResponse) ProtoMessage() {} + +func (x *PromptAiModelResponse) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiModelResponse.ProtoReflect.Descriptor instead. +func (*PromptAiModelResponse) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{28} +} + +func (x *PromptAiModelResponse) GetModel() *AiModel { + if x != nil { + return x.Model + } + return nil +} + +type PromptAiDeploymentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required model name to resolve deployment options for. + ModelName string `protobuf:"bytes,2,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + // Optional deployment filters (locations/versions/SKUs/capacity). + Options *AiModelDeploymentOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` + // Optional quota filter. Requires options.locations with exactly one location. + Quota *QuotaCheckOptions `protobuf:"bytes,4,opt,name=quota,proto3" json:"quota,omitempty"` + // Skip version prompt and use the default version when available. + UseDefaultVersion bool `protobuf:"varint,5,opt,name=use_default_version,json=useDefaultVersion,proto3" json:"use_default_version,omitempty"` + // Skip capacity prompt and use resolved/default capacity. + UseDefaultCapacity bool `protobuf:"varint,6,opt,name=use_default_capacity,json=useDefaultCapacity,proto3" json:"use_default_capacity,omitempty"` + // Include fine-tune SKUs (usage names ending with "-finetune"). + IncludeFinetuneSkus bool `protobuf:"varint,7,opt,name=include_finetune_skus,json=includeFinetuneSkus,proto3" json:"include_finetune_skus,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiDeploymentRequest) Reset() { + *x = PromptAiDeploymentRequest{} + mi := &file_prompt_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiDeploymentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiDeploymentRequest) ProtoMessage() {} + +func (x *PromptAiDeploymentRequest) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiDeploymentRequest.ProtoReflect.Descriptor instead. +func (*PromptAiDeploymentRequest) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{29} +} + +func (x *PromptAiDeploymentRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *PromptAiDeploymentRequest) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +func (x *PromptAiDeploymentRequest) GetOptions() *AiModelDeploymentOptions { + if x != nil { + return x.Options + } + return nil +} + +func (x *PromptAiDeploymentRequest) GetQuota() *QuotaCheckOptions { + if x != nil { + return x.Quota + } + return nil +} + +func (x *PromptAiDeploymentRequest) GetUseDefaultVersion() bool { + if x != nil { + return x.UseDefaultVersion + } + return false +} + +func (x *PromptAiDeploymentRequest) GetUseDefaultCapacity() bool { + if x != nil { + return x.UseDefaultCapacity + } + return false +} + +func (x *PromptAiDeploymentRequest) GetIncludeFinetuneSkus() bool { + if x != nil { + return x.IncludeFinetuneSkus + } + return false +} + +type PromptAiDeploymentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Selected deployment configuration. + Deployment *AiModelDeployment `protobuf:"bytes,1,opt,name=deployment,proto3" json:"deployment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiDeploymentResponse) Reset() { + *x = PromptAiDeploymentResponse{} + mi := &file_prompt_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiDeploymentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiDeploymentResponse) ProtoMessage() {} + +func (x *PromptAiDeploymentResponse) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiDeploymentResponse.ProtoReflect.Descriptor instead. +func (*PromptAiDeploymentResponse) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{30} +} + +func (x *PromptAiDeploymentResponse) GetDeployment() *AiModelDeployment { + if x != nil { + return x.Deployment + } + return nil +} + +type PromptAiLocationWithQuotaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required quota requirements that each returned location must satisfy. + Requirements []*QuotaRequirement `protobuf:"bytes,2,rep,name=requirements,proto3" json:"requirements,omitempty"` + // Optional allow-list for candidate locations. + AllowedLocations []string `protobuf:"bytes,3,rep,name=allowed_locations,json=allowedLocations,proto3" json:"allowed_locations,omitempty"` + // Optional select prompt customization (for example, message override). + SelectOptions *SelectOptions `protobuf:"bytes,4,opt,name=select_options,json=selectOptions,proto3" json:"select_options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiLocationWithQuotaRequest) Reset() { + *x = PromptAiLocationWithQuotaRequest{} + mi := &file_prompt_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiLocationWithQuotaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiLocationWithQuotaRequest) ProtoMessage() {} + +func (x *PromptAiLocationWithQuotaRequest) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiLocationWithQuotaRequest.ProtoReflect.Descriptor instead. +func (*PromptAiLocationWithQuotaRequest) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{31} +} + +func (x *PromptAiLocationWithQuotaRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *PromptAiLocationWithQuotaRequest) GetRequirements() []*QuotaRequirement { + if x != nil { + return x.Requirements + } + return nil +} + +func (x *PromptAiLocationWithQuotaRequest) GetAllowedLocations() []string { + if x != nil { + return x.AllowedLocations + } + return nil +} + +func (x *PromptAiLocationWithQuotaRequest) GetSelectOptions() *SelectOptions { + if x != nil { + return x.SelectOptions + } + return nil +} + +type PromptAiLocationWithQuotaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Selected location. + Location *Location `protobuf:"bytes,1,opt,name=location,proto3" json:"location,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiLocationWithQuotaResponse) Reset() { + *x = PromptAiLocationWithQuotaResponse{} + mi := &file_prompt_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiLocationWithQuotaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiLocationWithQuotaResponse) ProtoMessage() {} + +func (x *PromptAiLocationWithQuotaResponse) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiLocationWithQuotaResponse.ProtoReflect.Descriptor instead. +func (*PromptAiLocationWithQuotaResponse) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{32} +} + +func (x *PromptAiLocationWithQuotaResponse) GetLocation() *Location { + if x != nil { + return x.Location + } + return nil +} + +type PromptAiModelLocationWithQuotaRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Azure context with scope.subscription_id required. + AzureContext *AzureContext `protobuf:"bytes,1,opt,name=azure_context,json=azureContext,proto3" json:"azure_context,omitempty"` + // Required model name to evaluate across locations. + ModelName string `protobuf:"bytes,2,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + // Optional allow-list for candidate locations. + AllowedLocations []string `protobuf:"bytes,3,rep,name=allowed_locations,json=allowedLocations,proto3" json:"allowed_locations,omitempty"` + // Optional min remaining quota threshold. + Quota *QuotaCheckOptions `protobuf:"bytes,4,opt,name=quota,proto3" json:"quota,omitempty"` + // Optional select prompt customization (for example, message override). + SelectOptions *SelectOptions `protobuf:"bytes,5,opt,name=select_options,json=selectOptions,proto3" json:"select_options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiModelLocationWithQuotaRequest) Reset() { + *x = PromptAiModelLocationWithQuotaRequest{} + mi := &file_prompt_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiModelLocationWithQuotaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiModelLocationWithQuotaRequest) ProtoMessage() {} + +func (x *PromptAiModelLocationWithQuotaRequest) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiModelLocationWithQuotaRequest.ProtoReflect.Descriptor instead. +func (*PromptAiModelLocationWithQuotaRequest) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{33} +} + +func (x *PromptAiModelLocationWithQuotaRequest) GetAzureContext() *AzureContext { + if x != nil { + return x.AzureContext + } + return nil +} + +func (x *PromptAiModelLocationWithQuotaRequest) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +func (x *PromptAiModelLocationWithQuotaRequest) GetAllowedLocations() []string { + if x != nil { + return x.AllowedLocations + } + return nil +} + +func (x *PromptAiModelLocationWithQuotaRequest) GetQuota() *QuotaCheckOptions { + if x != nil { + return x.Quota + } + return nil +} + +func (x *PromptAiModelLocationWithQuotaRequest) GetSelectOptions() *SelectOptions { + if x != nil { + return x.SelectOptions + } + return nil +} + +type PromptAiModelLocationWithQuotaResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Selected location. + Location *Location `protobuf:"bytes,1,opt,name=location,proto3" json:"location,omitempty"` + // Maximum remaining quota at the selected location across model SKUs. + MaxRemainingQuota float64 `protobuf:"fixed64,2,opt,name=max_remaining_quota,json=maxRemainingQuota,proto3" json:"max_remaining_quota,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PromptAiModelLocationWithQuotaResponse) Reset() { + *x = PromptAiModelLocationWithQuotaResponse{} + mi := &file_prompt_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PromptAiModelLocationWithQuotaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PromptAiModelLocationWithQuotaResponse) ProtoMessage() {} + +func (x *PromptAiModelLocationWithQuotaResponse) ProtoReflect() protoreflect.Message { + mi := &file_prompt_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PromptAiModelLocationWithQuotaResponse.ProtoReflect.Descriptor instead. +func (*PromptAiModelLocationWithQuotaResponse) Descriptor() ([]byte, []int) { + return file_prompt_proto_rawDescGZIP(), []int{34} +} + +func (x *PromptAiModelLocationWithQuotaResponse) GetLocation() *Location { + if x != nil { + return x.Location + } + return nil +} + +func (x *PromptAiModelLocationWithQuotaResponse) GetMaxRemainingQuota() float64 { + if x != nil { + return x.MaxRemainingQuota + } + return 0 +} + var File_prompt_proto protoreflect.FileDescriptor const file_prompt_proto_rawDesc = "" + "\n" + - "\fprompt.proto\x12\x06azdext\x1a\fmodels.proto\"W\n" + + "\fprompt.proto\x12\x06azdext\x1a\fmodels.proto\x1a\x0eai_model.proto\"W\n" + "\x19PromptSubscriptionRequest\x12\x18\n" + "\aMessage\x18\x01 \x01(\tR\aMessage\x12 \n" + "\vHelpMessage\x18\x02 \x01(\tR\vHelpMessage\"V\n" + @@ -1698,7 +2213,44 @@ const file_prompt_proto_rawDesc = "" + "\x10_display_numbersB\x13\n" + "\x11_enable_filtering\"h\n" + "\x1aPromptResourceGroupOptions\x12J\n" + - "\x0eselect_options\x18\x01 \x01(\v2#.azdext.PromptResourceSelectOptionsR\rselectOptions2\x80\x06\n" + + "\x0eselect_options\x18\x01 \x01(\v2#.azdext.PromptResourceSelectOptionsR\rselectOptions\"\xf6\x01\n" + + "\x14PromptAiModelRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x124\n" + + "\x06filter\x18\x02 \x01(\v2\x1c.azdext.AiModelFilterOptionsR\x06filter\x12<\n" + + "\x0eselect_options\x18\x03 \x01(\v2\x15.azdext.SelectOptionsR\rselectOptions\x12/\n" + + "\x05quota\x18\x04 \x01(\v2\x19.azdext.QuotaCheckOptionsR\x05quota\">\n" + + "\x15PromptAiModelResponse\x12%\n" + + "\x05model\x18\x01 \x01(\v2\x0f.azdext.AiModelR\x05model\"\xf8\x02\n" + + "\x19PromptAiDeploymentRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12\x1d\n" + + "\n" + + "model_name\x18\x02 \x01(\tR\tmodelName\x12:\n" + + "\aoptions\x18\x03 \x01(\v2 .azdext.AiModelDeploymentOptionsR\aoptions\x12/\n" + + "\x05quota\x18\x04 \x01(\v2\x19.azdext.QuotaCheckOptionsR\x05quota\x12.\n" + + "\x13use_default_version\x18\x05 \x01(\bR\x11useDefaultVersion\x120\n" + + "\x14use_default_capacity\x18\x06 \x01(\bR\x12useDefaultCapacity\x122\n" + + "\x15include_finetune_skus\x18\a \x01(\bR\x13includeFinetuneSkus\"W\n" + + "\x1aPromptAiDeploymentResponse\x129\n" + + "\n" + + "deployment\x18\x01 \x01(\v2\x19.azdext.AiModelDeploymentR\n" + + "deployment\"\x86\x02\n" + + " PromptAiLocationWithQuotaRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12<\n" + + "\frequirements\x18\x02 \x03(\v2\x18.azdext.QuotaRequirementR\frequirements\x12+\n" + + "\x11allowed_locations\x18\x03 \x03(\tR\x10allowedLocations\x12<\n" + + "\x0eselect_options\x18\x04 \x01(\v2\x15.azdext.SelectOptionsR\rselectOptions\"Q\n" + + "!PromptAiLocationWithQuotaResponse\x12,\n" + + "\blocation\x18\x01 \x01(\v2\x10.azdext.LocationR\blocation\"\x9d\x02\n" + + "%PromptAiModelLocationWithQuotaRequest\x129\n" + + "\razure_context\x18\x01 \x01(\v2\x14.azdext.AzureContextR\fazureContext\x12\x1d\n" + + "\n" + + "model_name\x18\x02 \x01(\tR\tmodelName\x12+\n" + + "\x11allowed_locations\x18\x03 \x03(\tR\x10allowedLocations\x12/\n" + + "\x05quota\x18\x04 \x01(\v2\x19.azdext.QuotaCheckOptionsR\x05quota\x12<\n" + + "\x0eselect_options\x18\x05 \x01(\v2\x15.azdext.SelectOptionsR\rselectOptions\"\x86\x01\n" + + "&PromptAiModelLocationWithQuotaResponse\x12,\n" + + "\blocation\x18\x01 \x01(\v2\x10.azdext.LocationR\blocation\x12.\n" + + "\x13max_remaining_quota\x18\x02 \x01(\x01R\x11maxRemainingQuota2\x9e\t\n" + "\rPromptService\x12[\n" + "\x12PromptSubscription\x12!.azdext.PromptSubscriptionRequest\x1a\".azdext.PromptSubscriptionResponse\x12O\n" + "\x0ePromptLocation\x12\x1d.azdext.PromptLocationRequest\x1a\x1e.azdext.PromptLocationResponse\x12^\n" + @@ -1708,7 +2260,11 @@ const file_prompt_proto_rawDesc = "" + "\x06Select\x12\x15.azdext.SelectRequest\x1a\x16.azdext.SelectResponse\x12F\n" + "\vMultiSelect\x12\x1a.azdext.MultiSelectRequest\x1a\x1b.azdext.MultiSelectResponse\x12s\n" + "\x1aPromptSubscriptionResource\x12).azdext.PromptSubscriptionResourceRequest\x1a*.azdext.PromptSubscriptionResourceResponse\x12v\n" + - "\x1bPromptResourceGroupResource\x12*.azdext.PromptResourceGroupResourceRequest\x1a+.azdext.PromptResourceGroupResourceResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "\x1bPromptResourceGroupResource\x12*.azdext.PromptResourceGroupResourceRequest\x1a+.azdext.PromptResourceGroupResourceResponse\x12L\n" + + "\rPromptAiModel\x12\x1c.azdext.PromptAiModelRequest\x1a\x1d.azdext.PromptAiModelResponse\x12[\n" + + "\x12PromptAiDeployment\x12!.azdext.PromptAiDeploymentRequest\x1a\".azdext.PromptAiDeploymentResponse\x12p\n" + + "\x19PromptAiLocationWithQuota\x12(.azdext.PromptAiLocationWithQuotaRequest\x1a).azdext.PromptAiLocationWithQuotaResponse\x12\x7f\n" + + "\x1ePromptAiModelLocationWithQuota\x12-.azdext.PromptAiModelLocationWithQuotaRequest\x1a..azdext.PromptAiModelLocationWithQuotaResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" var ( file_prompt_proto_rawDescOnce sync.Once @@ -1722,86 +2278,125 @@ func file_prompt_proto_rawDescGZIP() []byte { return file_prompt_proto_rawDescData } -var file_prompt_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_prompt_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_prompt_proto_goTypes = []any{ - (*PromptSubscriptionRequest)(nil), // 0: azdext.PromptSubscriptionRequest - (*PromptSubscriptionResponse)(nil), // 1: azdext.PromptSubscriptionResponse - (*PromptLocationRequest)(nil), // 2: azdext.PromptLocationRequest - (*PromptLocationResponse)(nil), // 3: azdext.PromptLocationResponse - (*PromptResourceGroupRequest)(nil), // 4: azdext.PromptResourceGroupRequest - (*PromptResourceGroupResponse)(nil), // 5: azdext.PromptResourceGroupResponse - (*ConfirmRequest)(nil), // 6: azdext.ConfirmRequest - (*ConfirmResponse)(nil), // 7: azdext.ConfirmResponse - (*PromptRequest)(nil), // 8: azdext.PromptRequest - (*PromptResponse)(nil), // 9: azdext.PromptResponse - (*SelectRequest)(nil), // 10: azdext.SelectRequest - (*SelectResponse)(nil), // 11: azdext.SelectResponse - (*MultiSelectRequest)(nil), // 12: azdext.MultiSelectRequest - (*MultiSelectResponse)(nil), // 13: azdext.MultiSelectResponse - (*PromptSubscriptionResourceRequest)(nil), // 14: azdext.PromptSubscriptionResourceRequest - (*PromptSubscriptionResourceResponse)(nil), // 15: azdext.PromptSubscriptionResourceResponse - (*PromptResourceGroupResourceRequest)(nil), // 16: azdext.PromptResourceGroupResourceRequest - (*PromptResourceGroupResourceResponse)(nil), // 17: azdext.PromptResourceGroupResourceResponse - (*ConfirmOptions)(nil), // 18: azdext.ConfirmOptions - (*PromptOptions)(nil), // 19: azdext.PromptOptions - (*SelectChoice)(nil), // 20: azdext.SelectChoice - (*MultiSelectChoice)(nil), // 21: azdext.MultiSelectChoice - (*SelectOptions)(nil), // 22: azdext.SelectOptions - (*MultiSelectOptions)(nil), // 23: azdext.MultiSelectOptions - (*PromptResourceOptions)(nil), // 24: azdext.PromptResourceOptions - (*PromptResourceSelectOptions)(nil), // 25: azdext.PromptResourceSelectOptions - (*PromptResourceGroupOptions)(nil), // 26: azdext.PromptResourceGroupOptions - (*Subscription)(nil), // 27: azdext.Subscription - (*AzureContext)(nil), // 28: azdext.AzureContext - (*Location)(nil), // 29: azdext.Location - (*ResourceGroup)(nil), // 30: azdext.ResourceGroup - (*ResourceExtended)(nil), // 31: azdext.ResourceExtended + (*PromptSubscriptionRequest)(nil), // 0: azdext.PromptSubscriptionRequest + (*PromptSubscriptionResponse)(nil), // 1: azdext.PromptSubscriptionResponse + (*PromptLocationRequest)(nil), // 2: azdext.PromptLocationRequest + (*PromptLocationResponse)(nil), // 3: azdext.PromptLocationResponse + (*PromptResourceGroupRequest)(nil), // 4: azdext.PromptResourceGroupRequest + (*PromptResourceGroupResponse)(nil), // 5: azdext.PromptResourceGroupResponse + (*ConfirmRequest)(nil), // 6: azdext.ConfirmRequest + (*ConfirmResponse)(nil), // 7: azdext.ConfirmResponse + (*PromptRequest)(nil), // 8: azdext.PromptRequest + (*PromptResponse)(nil), // 9: azdext.PromptResponse + (*SelectRequest)(nil), // 10: azdext.SelectRequest + (*SelectResponse)(nil), // 11: azdext.SelectResponse + (*MultiSelectRequest)(nil), // 12: azdext.MultiSelectRequest + (*MultiSelectResponse)(nil), // 13: azdext.MultiSelectResponse + (*PromptSubscriptionResourceRequest)(nil), // 14: azdext.PromptSubscriptionResourceRequest + (*PromptSubscriptionResourceResponse)(nil), // 15: azdext.PromptSubscriptionResourceResponse + (*PromptResourceGroupResourceRequest)(nil), // 16: azdext.PromptResourceGroupResourceRequest + (*PromptResourceGroupResourceResponse)(nil), // 17: azdext.PromptResourceGroupResourceResponse + (*ConfirmOptions)(nil), // 18: azdext.ConfirmOptions + (*PromptOptions)(nil), // 19: azdext.PromptOptions + (*SelectChoice)(nil), // 20: azdext.SelectChoice + (*MultiSelectChoice)(nil), // 21: azdext.MultiSelectChoice + (*SelectOptions)(nil), // 22: azdext.SelectOptions + (*MultiSelectOptions)(nil), // 23: azdext.MultiSelectOptions + (*PromptResourceOptions)(nil), // 24: azdext.PromptResourceOptions + (*PromptResourceSelectOptions)(nil), // 25: azdext.PromptResourceSelectOptions + (*PromptResourceGroupOptions)(nil), // 26: azdext.PromptResourceGroupOptions + (*PromptAiModelRequest)(nil), // 27: azdext.PromptAiModelRequest + (*PromptAiModelResponse)(nil), // 28: azdext.PromptAiModelResponse + (*PromptAiDeploymentRequest)(nil), // 29: azdext.PromptAiDeploymentRequest + (*PromptAiDeploymentResponse)(nil), // 30: azdext.PromptAiDeploymentResponse + (*PromptAiLocationWithQuotaRequest)(nil), // 31: azdext.PromptAiLocationWithQuotaRequest + (*PromptAiLocationWithQuotaResponse)(nil), // 32: azdext.PromptAiLocationWithQuotaResponse + (*PromptAiModelLocationWithQuotaRequest)(nil), // 33: azdext.PromptAiModelLocationWithQuotaRequest + (*PromptAiModelLocationWithQuotaResponse)(nil), // 34: azdext.PromptAiModelLocationWithQuotaResponse + (*Subscription)(nil), // 35: azdext.Subscription + (*AzureContext)(nil), // 36: azdext.AzureContext + (*Location)(nil), // 37: azdext.Location + (*ResourceGroup)(nil), // 38: azdext.ResourceGroup + (*ResourceExtended)(nil), // 39: azdext.ResourceExtended + (*AiModelFilterOptions)(nil), // 40: azdext.AiModelFilterOptions + (*QuotaCheckOptions)(nil), // 41: azdext.QuotaCheckOptions + (*AiModel)(nil), // 42: azdext.AiModel + (*AiModelDeploymentOptions)(nil), // 43: azdext.AiModelDeploymentOptions + (*AiModelDeployment)(nil), // 44: azdext.AiModelDeployment + (*QuotaRequirement)(nil), // 45: azdext.QuotaRequirement } var file_prompt_proto_depIdxs = []int32{ - 27, // 0: azdext.PromptSubscriptionResponse.subscription:type_name -> azdext.Subscription - 28, // 1: azdext.PromptLocationRequest.azure_context:type_name -> azdext.AzureContext - 29, // 2: azdext.PromptLocationResponse.location:type_name -> azdext.Location - 28, // 3: azdext.PromptResourceGroupRequest.azure_context:type_name -> azdext.AzureContext + 35, // 0: azdext.PromptSubscriptionResponse.subscription:type_name -> azdext.Subscription + 36, // 1: azdext.PromptLocationRequest.azure_context:type_name -> azdext.AzureContext + 37, // 2: azdext.PromptLocationResponse.location:type_name -> azdext.Location + 36, // 3: azdext.PromptResourceGroupRequest.azure_context:type_name -> azdext.AzureContext 26, // 4: azdext.PromptResourceGroupRequest.options:type_name -> azdext.PromptResourceGroupOptions - 30, // 5: azdext.PromptResourceGroupResponse.resource_group:type_name -> azdext.ResourceGroup + 38, // 5: azdext.PromptResourceGroupResponse.resource_group:type_name -> azdext.ResourceGroup 18, // 6: azdext.ConfirmRequest.options:type_name -> azdext.ConfirmOptions 19, // 7: azdext.PromptRequest.options:type_name -> azdext.PromptOptions 22, // 8: azdext.SelectRequest.options:type_name -> azdext.SelectOptions 23, // 9: azdext.MultiSelectRequest.options:type_name -> azdext.MultiSelectOptions 21, // 10: azdext.MultiSelectResponse.values:type_name -> azdext.MultiSelectChoice - 28, // 11: azdext.PromptSubscriptionResourceRequest.azure_context:type_name -> azdext.AzureContext + 36, // 11: azdext.PromptSubscriptionResourceRequest.azure_context:type_name -> azdext.AzureContext 24, // 12: azdext.PromptSubscriptionResourceRequest.options:type_name -> azdext.PromptResourceOptions - 31, // 13: azdext.PromptSubscriptionResourceResponse.resource:type_name -> azdext.ResourceExtended - 28, // 14: azdext.PromptResourceGroupResourceRequest.azure_context:type_name -> azdext.AzureContext + 39, // 13: azdext.PromptSubscriptionResourceResponse.resource:type_name -> azdext.ResourceExtended + 36, // 14: azdext.PromptResourceGroupResourceRequest.azure_context:type_name -> azdext.AzureContext 24, // 15: azdext.PromptResourceGroupResourceRequest.options:type_name -> azdext.PromptResourceOptions - 31, // 16: azdext.PromptResourceGroupResourceResponse.resource:type_name -> azdext.ResourceExtended + 39, // 16: azdext.PromptResourceGroupResourceResponse.resource:type_name -> azdext.ResourceExtended 20, // 17: azdext.SelectOptions.choices:type_name -> azdext.SelectChoice 21, // 18: azdext.MultiSelectOptions.choices:type_name -> azdext.MultiSelectChoice 25, // 19: azdext.PromptResourceOptions.select_options:type_name -> azdext.PromptResourceSelectOptions 25, // 20: azdext.PromptResourceGroupOptions.select_options:type_name -> azdext.PromptResourceSelectOptions - 0, // 21: azdext.PromptService.PromptSubscription:input_type -> azdext.PromptSubscriptionRequest - 2, // 22: azdext.PromptService.PromptLocation:input_type -> azdext.PromptLocationRequest - 4, // 23: azdext.PromptService.PromptResourceGroup:input_type -> azdext.PromptResourceGroupRequest - 6, // 24: azdext.PromptService.Confirm:input_type -> azdext.ConfirmRequest - 8, // 25: azdext.PromptService.Prompt:input_type -> azdext.PromptRequest - 10, // 26: azdext.PromptService.Select:input_type -> azdext.SelectRequest - 12, // 27: azdext.PromptService.MultiSelect:input_type -> azdext.MultiSelectRequest - 14, // 28: azdext.PromptService.PromptSubscriptionResource:input_type -> azdext.PromptSubscriptionResourceRequest - 16, // 29: azdext.PromptService.PromptResourceGroupResource:input_type -> azdext.PromptResourceGroupResourceRequest - 1, // 30: azdext.PromptService.PromptSubscription:output_type -> azdext.PromptSubscriptionResponse - 3, // 31: azdext.PromptService.PromptLocation:output_type -> azdext.PromptLocationResponse - 5, // 32: azdext.PromptService.PromptResourceGroup:output_type -> azdext.PromptResourceGroupResponse - 7, // 33: azdext.PromptService.Confirm:output_type -> azdext.ConfirmResponse - 9, // 34: azdext.PromptService.Prompt:output_type -> azdext.PromptResponse - 11, // 35: azdext.PromptService.Select:output_type -> azdext.SelectResponse - 13, // 36: azdext.PromptService.MultiSelect:output_type -> azdext.MultiSelectResponse - 15, // 37: azdext.PromptService.PromptSubscriptionResource:output_type -> azdext.PromptSubscriptionResourceResponse - 17, // 38: azdext.PromptService.PromptResourceGroupResource:output_type -> azdext.PromptResourceGroupResourceResponse - 30, // [30:39] is the sub-list for method output_type - 21, // [21:30] is the sub-list for method input_type - 21, // [21:21] is the sub-list for extension type_name - 21, // [21:21] is the sub-list for extension extendee - 0, // [0:21] is the sub-list for field type_name + 36, // 21: azdext.PromptAiModelRequest.azure_context:type_name -> azdext.AzureContext + 40, // 22: azdext.PromptAiModelRequest.filter:type_name -> azdext.AiModelFilterOptions + 22, // 23: azdext.PromptAiModelRequest.select_options:type_name -> azdext.SelectOptions + 41, // 24: azdext.PromptAiModelRequest.quota:type_name -> azdext.QuotaCheckOptions + 42, // 25: azdext.PromptAiModelResponse.model:type_name -> azdext.AiModel + 36, // 26: azdext.PromptAiDeploymentRequest.azure_context:type_name -> azdext.AzureContext + 43, // 27: azdext.PromptAiDeploymentRequest.options:type_name -> azdext.AiModelDeploymentOptions + 41, // 28: azdext.PromptAiDeploymentRequest.quota:type_name -> azdext.QuotaCheckOptions + 44, // 29: azdext.PromptAiDeploymentResponse.deployment:type_name -> azdext.AiModelDeployment + 36, // 30: azdext.PromptAiLocationWithQuotaRequest.azure_context:type_name -> azdext.AzureContext + 45, // 31: azdext.PromptAiLocationWithQuotaRequest.requirements:type_name -> azdext.QuotaRequirement + 22, // 32: azdext.PromptAiLocationWithQuotaRequest.select_options:type_name -> azdext.SelectOptions + 37, // 33: azdext.PromptAiLocationWithQuotaResponse.location:type_name -> azdext.Location + 36, // 34: azdext.PromptAiModelLocationWithQuotaRequest.azure_context:type_name -> azdext.AzureContext + 41, // 35: azdext.PromptAiModelLocationWithQuotaRequest.quota:type_name -> azdext.QuotaCheckOptions + 22, // 36: azdext.PromptAiModelLocationWithQuotaRequest.select_options:type_name -> azdext.SelectOptions + 37, // 37: azdext.PromptAiModelLocationWithQuotaResponse.location:type_name -> azdext.Location + 0, // 38: azdext.PromptService.PromptSubscription:input_type -> azdext.PromptSubscriptionRequest + 2, // 39: azdext.PromptService.PromptLocation:input_type -> azdext.PromptLocationRequest + 4, // 40: azdext.PromptService.PromptResourceGroup:input_type -> azdext.PromptResourceGroupRequest + 6, // 41: azdext.PromptService.Confirm:input_type -> azdext.ConfirmRequest + 8, // 42: azdext.PromptService.Prompt:input_type -> azdext.PromptRequest + 10, // 43: azdext.PromptService.Select:input_type -> azdext.SelectRequest + 12, // 44: azdext.PromptService.MultiSelect:input_type -> azdext.MultiSelectRequest + 14, // 45: azdext.PromptService.PromptSubscriptionResource:input_type -> azdext.PromptSubscriptionResourceRequest + 16, // 46: azdext.PromptService.PromptResourceGroupResource:input_type -> azdext.PromptResourceGroupResourceRequest + 27, // 47: azdext.PromptService.PromptAiModel:input_type -> azdext.PromptAiModelRequest + 29, // 48: azdext.PromptService.PromptAiDeployment:input_type -> azdext.PromptAiDeploymentRequest + 31, // 49: azdext.PromptService.PromptAiLocationWithQuota:input_type -> azdext.PromptAiLocationWithQuotaRequest + 33, // 50: azdext.PromptService.PromptAiModelLocationWithQuota:input_type -> azdext.PromptAiModelLocationWithQuotaRequest + 1, // 51: azdext.PromptService.PromptSubscription:output_type -> azdext.PromptSubscriptionResponse + 3, // 52: azdext.PromptService.PromptLocation:output_type -> azdext.PromptLocationResponse + 5, // 53: azdext.PromptService.PromptResourceGroup:output_type -> azdext.PromptResourceGroupResponse + 7, // 54: azdext.PromptService.Confirm:output_type -> azdext.ConfirmResponse + 9, // 55: azdext.PromptService.Prompt:output_type -> azdext.PromptResponse + 11, // 56: azdext.PromptService.Select:output_type -> azdext.SelectResponse + 13, // 57: azdext.PromptService.MultiSelect:output_type -> azdext.MultiSelectResponse + 15, // 58: azdext.PromptService.PromptSubscriptionResource:output_type -> azdext.PromptSubscriptionResourceResponse + 17, // 59: azdext.PromptService.PromptResourceGroupResource:output_type -> azdext.PromptResourceGroupResourceResponse + 28, // 60: azdext.PromptService.PromptAiModel:output_type -> azdext.PromptAiModelResponse + 30, // 61: azdext.PromptService.PromptAiDeployment:output_type -> azdext.PromptAiDeploymentResponse + 32, // 62: azdext.PromptService.PromptAiLocationWithQuota:output_type -> azdext.PromptAiLocationWithQuotaResponse + 34, // 63: azdext.PromptService.PromptAiModelLocationWithQuota:output_type -> azdext.PromptAiModelLocationWithQuotaResponse + 51, // [51:64] is the sub-list for method output_type + 38, // [38:51] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_prompt_proto_init() } @@ -1810,6 +2405,7 @@ func file_prompt_proto_init() { return } file_models_proto_init() + file_ai_model_proto_init() file_prompt_proto_msgTypes[7].OneofWrappers = []any{} file_prompt_proto_msgTypes[11].OneofWrappers = []any{} file_prompt_proto_msgTypes[18].OneofWrappers = []any{} @@ -1822,7 +2418,7 @@ func file_prompt_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_prompt_proto_rawDesc), len(file_prompt_proto_rawDesc)), NumEnums: 0, - NumMessages: 27, + NumMessages: 35, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/prompt_grpc.pb.go b/cli/azd/pkg/azdext/prompt_grpc.pb.go index 93b804b49d5..2fb8a7e9053 100644 --- a/cli/azd/pkg/azdext/prompt_grpc.pb.go +++ b/cli/azd/pkg/azdext/prompt_grpc.pb.go @@ -22,15 +22,19 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - PromptService_PromptSubscription_FullMethodName = "/azdext.PromptService/PromptSubscription" - PromptService_PromptLocation_FullMethodName = "/azdext.PromptService/PromptLocation" - PromptService_PromptResourceGroup_FullMethodName = "/azdext.PromptService/PromptResourceGroup" - PromptService_Confirm_FullMethodName = "/azdext.PromptService/Confirm" - PromptService_Prompt_FullMethodName = "/azdext.PromptService/Prompt" - PromptService_Select_FullMethodName = "/azdext.PromptService/Select" - PromptService_MultiSelect_FullMethodName = "/azdext.PromptService/MultiSelect" - PromptService_PromptSubscriptionResource_FullMethodName = "/azdext.PromptService/PromptSubscriptionResource" - PromptService_PromptResourceGroupResource_FullMethodName = "/azdext.PromptService/PromptResourceGroupResource" + PromptService_PromptSubscription_FullMethodName = "/azdext.PromptService/PromptSubscription" + PromptService_PromptLocation_FullMethodName = "/azdext.PromptService/PromptLocation" + PromptService_PromptResourceGroup_FullMethodName = "/azdext.PromptService/PromptResourceGroup" + PromptService_Confirm_FullMethodName = "/azdext.PromptService/Confirm" + PromptService_Prompt_FullMethodName = "/azdext.PromptService/Prompt" + PromptService_Select_FullMethodName = "/azdext.PromptService/Select" + PromptService_MultiSelect_FullMethodName = "/azdext.PromptService/MultiSelect" + PromptService_PromptSubscriptionResource_FullMethodName = "/azdext.PromptService/PromptSubscriptionResource" + PromptService_PromptResourceGroupResource_FullMethodName = "/azdext.PromptService/PromptResourceGroupResource" + PromptService_PromptAiModel_FullMethodName = "/azdext.PromptService/PromptAiModel" + PromptService_PromptAiDeployment_FullMethodName = "/azdext.PromptService/PromptAiDeployment" + PromptService_PromptAiLocationWithQuota_FullMethodName = "/azdext.PromptService/PromptAiLocationWithQuota" + PromptService_PromptAiModelLocationWithQuota_FullMethodName = "/azdext.PromptService/PromptAiModelLocationWithQuota" ) // PromptServiceClient is the client API for PromptService service. @@ -55,6 +59,27 @@ type PromptServiceClient interface { PromptSubscriptionResource(ctx context.Context, in *PromptSubscriptionResourceRequest, opts ...grpc.CallOption) (*PromptSubscriptionResourceResponse, error) // PromptResourceGroupResource prompts the user to select a resource from a resource group. PromptResourceGroupResource(ctx context.Context, in *PromptResourceGroupResourceRequest, opts ...grpc.CallOption) (*PromptResourceGroupResourceResponse, error) + // PromptAiModel prompts the user to select an AI model scoped by effective location. + // Effective location only affects which models are eligible for selection; + // the returned model keeps canonical metadata (including full locations). + // Effective location is defined by filter.locations. + // If filter.locations is empty, models are considered across subscription locations. + // If quota is set: + // - one location in filter.locations: quota is evaluated at that location. + // - multiple locations in filter.locations: quota is evaluated for each location and + // models are kept when quota is sufficient in at least one location. + // - empty filter.locations: quota is evaluated across model-declared locations. + PromptAiModel(ctx context.Context, in *PromptAiModelRequest, opts ...grpc.CallOption) (*PromptAiModelResponse, error) + // PromptAiDeployment prompts for version, SKU, and capacity sequentially. + // This is the primary interactive primitive for model deployment selection. + // Effective location is defined by options.locations. + // If options.locations is empty, model catalog is considered across subscription locations. + // Quota requires exactly one effective location (via options.locations). + PromptAiDeployment(ctx context.Context, in *PromptAiDeploymentRequest, opts ...grpc.CallOption) (*PromptAiDeploymentResponse, error) + // PromptAiLocationWithQuota prompts for a location filtered by quota requirements. + PromptAiLocationWithQuota(ctx context.Context, in *PromptAiLocationWithQuotaRequest, opts ...grpc.CallOption) (*PromptAiLocationWithQuotaResponse, error) + // PromptAiModelLocationWithQuota prompts for a model location and displays remaining quota. + PromptAiModelLocationWithQuota(ctx context.Context, in *PromptAiModelLocationWithQuotaRequest, opts ...grpc.CallOption) (*PromptAiModelLocationWithQuotaResponse, error) } type promptServiceClient struct { @@ -155,6 +180,46 @@ func (c *promptServiceClient) PromptResourceGroupResource(ctx context.Context, i return out, nil } +func (c *promptServiceClient) PromptAiModel(ctx context.Context, in *PromptAiModelRequest, opts ...grpc.CallOption) (*PromptAiModelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PromptAiModelResponse) + err := c.cc.Invoke(ctx, PromptService_PromptAiModel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *promptServiceClient) PromptAiDeployment(ctx context.Context, in *PromptAiDeploymentRequest, opts ...grpc.CallOption) (*PromptAiDeploymentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PromptAiDeploymentResponse) + err := c.cc.Invoke(ctx, PromptService_PromptAiDeployment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *promptServiceClient) PromptAiLocationWithQuota(ctx context.Context, in *PromptAiLocationWithQuotaRequest, opts ...grpc.CallOption) (*PromptAiLocationWithQuotaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PromptAiLocationWithQuotaResponse) + err := c.cc.Invoke(ctx, PromptService_PromptAiLocationWithQuota_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *promptServiceClient) PromptAiModelLocationWithQuota(ctx context.Context, in *PromptAiModelLocationWithQuotaRequest, opts ...grpc.CallOption) (*PromptAiModelLocationWithQuotaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PromptAiModelLocationWithQuotaResponse) + err := c.cc.Invoke(ctx, PromptService_PromptAiModelLocationWithQuota_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // PromptServiceServer is the server API for PromptService service. // All implementations must embed UnimplementedPromptServiceServer // for forward compatibility. @@ -177,6 +242,27 @@ type PromptServiceServer interface { PromptSubscriptionResource(context.Context, *PromptSubscriptionResourceRequest) (*PromptSubscriptionResourceResponse, error) // PromptResourceGroupResource prompts the user to select a resource from a resource group. PromptResourceGroupResource(context.Context, *PromptResourceGroupResourceRequest) (*PromptResourceGroupResourceResponse, error) + // PromptAiModel prompts the user to select an AI model scoped by effective location. + // Effective location only affects which models are eligible for selection; + // the returned model keeps canonical metadata (including full locations). + // Effective location is defined by filter.locations. + // If filter.locations is empty, models are considered across subscription locations. + // If quota is set: + // - one location in filter.locations: quota is evaluated at that location. + // - multiple locations in filter.locations: quota is evaluated for each location and + // models are kept when quota is sufficient in at least one location. + // - empty filter.locations: quota is evaluated across model-declared locations. + PromptAiModel(context.Context, *PromptAiModelRequest) (*PromptAiModelResponse, error) + // PromptAiDeployment prompts for version, SKU, and capacity sequentially. + // This is the primary interactive primitive for model deployment selection. + // Effective location is defined by options.locations. + // If options.locations is empty, model catalog is considered across subscription locations. + // Quota requires exactly one effective location (via options.locations). + PromptAiDeployment(context.Context, *PromptAiDeploymentRequest) (*PromptAiDeploymentResponse, error) + // PromptAiLocationWithQuota prompts for a location filtered by quota requirements. + PromptAiLocationWithQuota(context.Context, *PromptAiLocationWithQuotaRequest) (*PromptAiLocationWithQuotaResponse, error) + // PromptAiModelLocationWithQuota prompts for a model location and displays remaining quota. + PromptAiModelLocationWithQuota(context.Context, *PromptAiModelLocationWithQuotaRequest) (*PromptAiModelLocationWithQuotaResponse, error) mustEmbedUnimplementedPromptServiceServer() } @@ -214,6 +300,18 @@ func (UnimplementedPromptServiceServer) PromptSubscriptionResource(context.Conte func (UnimplementedPromptServiceServer) PromptResourceGroupResource(context.Context, *PromptResourceGroupResourceRequest) (*PromptResourceGroupResourceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method PromptResourceGroupResource not implemented") } +func (UnimplementedPromptServiceServer) PromptAiModel(context.Context, *PromptAiModelRequest) (*PromptAiModelResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PromptAiModel not implemented") +} +func (UnimplementedPromptServiceServer) PromptAiDeployment(context.Context, *PromptAiDeploymentRequest) (*PromptAiDeploymentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PromptAiDeployment not implemented") +} +func (UnimplementedPromptServiceServer) PromptAiLocationWithQuota(context.Context, *PromptAiLocationWithQuotaRequest) (*PromptAiLocationWithQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PromptAiLocationWithQuota not implemented") +} +func (UnimplementedPromptServiceServer) PromptAiModelLocationWithQuota(context.Context, *PromptAiModelLocationWithQuotaRequest) (*PromptAiModelLocationWithQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PromptAiModelLocationWithQuota not implemented") +} func (UnimplementedPromptServiceServer) mustEmbedUnimplementedPromptServiceServer() {} func (UnimplementedPromptServiceServer) testEmbeddedByValue() {} @@ -397,6 +495,78 @@ func _PromptService_PromptResourceGroupResource_Handler(srv interface{}, ctx con return interceptor(ctx, in, info, handler) } +func _PromptService_PromptAiModel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PromptAiModelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PromptServiceServer).PromptAiModel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PromptService_PromptAiModel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PromptServiceServer).PromptAiModel(ctx, req.(*PromptAiModelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PromptService_PromptAiDeployment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PromptAiDeploymentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PromptServiceServer).PromptAiDeployment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PromptService_PromptAiDeployment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PromptServiceServer).PromptAiDeployment(ctx, req.(*PromptAiDeploymentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PromptService_PromptAiLocationWithQuota_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PromptAiLocationWithQuotaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PromptServiceServer).PromptAiLocationWithQuota(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PromptService_PromptAiLocationWithQuota_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PromptServiceServer).PromptAiLocationWithQuota(ctx, req.(*PromptAiLocationWithQuotaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PromptService_PromptAiModelLocationWithQuota_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PromptAiModelLocationWithQuotaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PromptServiceServer).PromptAiModelLocationWithQuota(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PromptService_PromptAiModelLocationWithQuota_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PromptServiceServer).PromptAiModelLocationWithQuota(ctx, req.(*PromptAiModelLocationWithQuotaRequest)) + } + return interceptor(ctx, in, info, handler) +} + // PromptService_ServiceDesc is the grpc.ServiceDesc for PromptService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -440,6 +610,22 @@ var PromptService_ServiceDesc = grpc.ServiceDesc{ MethodName: "PromptResourceGroupResource", Handler: _PromptService_PromptResourceGroupResource_Handler, }, + { + MethodName: "PromptAiModel", + Handler: _PromptService_PromptAiModel_Handler, + }, + { + MethodName: "PromptAiDeployment", + Handler: _PromptService_PromptAiDeployment_Handler, + }, + { + MethodName: "PromptAiLocationWithQuota", + Handler: _PromptService_PromptAiLocationWithQuota_Handler, + }, + { + MethodName: "PromptAiModelLocationWithQuota", + Handler: _PromptService_PromptAiModelLocationWithQuota_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "prompt.proto", diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index da588c3949f..8ab6d073429 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -25,6 +25,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/ai" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" @@ -81,6 +82,7 @@ type BicepProvider struct { portalUrlBase string keyvaultService keyvault.KeyVaultService subscriptionManager *account.SubscriptionsManager + aiModelService *ai.AiModelService // Internal state // compileBicepResult is cached to avoid recompiling the same bicep file multiple times in the same azd run. @@ -2513,6 +2515,7 @@ func NewBicepProvider( keyvaultService keyvault.KeyVaultService, cloud *cloud.Cloud, subscriptionManager *account.SubscriptionsManager, + aiModelService *ai.AiModelService, ) provisioning.Provider { return &BicepProvider{ envManager: envManager, @@ -2528,6 +2531,7 @@ func NewBicepProvider( keyvaultService: keyvaultService, portalUrlBase: cloud.PortalUrlBase, subscriptionManager: subscriptionManager, + aiModelService: aiModelService, } } diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index fde5b53b96f..b4f1c647f3c 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -451,6 +451,7 @@ func createBicepProvider(t *testing.T, mockContext *mocks.MockContext) *BicepPro ), cloud.AzurePublic(), nil, + nil, ) err := provider.Initialize(*mockContext.Context, projectDir, options) @@ -1125,6 +1126,7 @@ func TestUserDefinedTypes(t *testing.T) { ), cloud.AzurePublic(), nil, + nil, ) bicepProvider, gooCast := provider.(*BicepProvider) require.True(t, gooCast) @@ -1777,6 +1779,7 @@ func createBicepProviderWithEnv( ), cloud.AzurePublic(), nil, + nil, ) err := provider.Initialize(*mockContext.Context, projectDir, options) diff --git a/cli/azd/pkg/infra/provisioning/bicep/prompt.go b/cli/azd/pkg/infra/provisioning/bicep/prompt.go index 34865314352..94eadf94338 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/prompt.go +++ b/cli/azd/pkg/infra/provisioning/bicep/prompt.go @@ -7,15 +7,13 @@ import ( "context" "encoding/json" "fmt" - "log" "slices" "strconv" "strings" - "sync" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/azure/azure-dev/cli/azd/pkg/ai" "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -82,99 +80,37 @@ func autoGenerate(parameter string, azdMetadata azure.AzdMetadata) (string, erro return genValue, nil } -// locationsWithQuotaFor checks which locations have available quota for a specified list of SKU. -// It concurrently queries the Azure API for usage data in each location and filters the results -// based on the quota and capacity requirements. +// locationsWithQuotaFor finds locations that have sufficient quota for the given usage requirements. // -// Parameters: -// - ctx: The context for controlling cancellation and deadlines. -// - subId: The subscription ID to query against. -// - locations: A list of Azure locations to check for quota availability. -// - quotaFor: list of SKU name and optional capacity (comma-separated) to check for. Example: "OpenAI.S0.AccountCount, 2" -// or ["OpenAI.S1.SkuDescription, 1", "OpenAI.S0.AccountCount, 2"] -// -// Returns: -// - A slice of location strings that have the required quota and capacity available. -// - An error if any issues occur during the process or if no locations meet the criteria. -// -// The function first queries the Azure API for usage data in each location concurrently. -// It then filters the results based on the specified list of SKU name and capacity requirements. -// If no locations meet the criteria, it returns an error with details about the maximum available capacity found. +// The quotaFor parameter uses the Bicep metadata format: "UsageName" or "UsageName, Capacity". +// An implicit requirement for "OpenAI.S0.AccountCount" with capacity 2 is always included. func (a *BicepProvider) locationsWithQuotaFor( ctx context.Context, subId string, locations []string, quotaFor []string) ([]string, error) { - var sharedResults sync.Map - var wg sync.WaitGroup - - azureAiServicesLocations, err := a.azapi.GetResourceSkuLocations( - ctx, subId, "AIServices", "S0", "Standard", "accounts") - if err != nil { - return nil, fmt.Errorf("getting Azure AI Services locations: %w", err) + if a.aiModelService == nil { + return nil, fmt.Errorf("AI model service is not configured") } - if locations == nil { - // If no locations are provided, use the Azure AI Services locations - locations = azureAiServicesLocations + // Always require minimum S0 account quota + requirements := []ai.QuotaRequirement{ + {UsageName: "OpenAI.S0.AccountCount", MinCapacity: 2}, } - for _, location := range locations { - if !slices.Contains(azureAiServicesLocations, location) { - // Skip locations that are not in the list of Azure AI Services locations - continue + for _, definedUsageName := range quotaFor { + usageDetails, err := usageNameDetailsFromString(definedUsageName) + if err != nil { + return nil, fmt.Errorf("parsing quota '%s': %w", definedUsageName, err) } - wg.Add(1) - go func(location string) { - defer wg.Done() - results, err := a.azapi.GetAiUsages(ctx, subId, location) - if err != nil { - // log the error but don't return it - log.Println("error getting usage for location", location, ":", err) - return - } - sharedResults.Store(location, results) - }(location) - } - wg.Wait() - - var results []string - var iterationError error - sharedResults.Range(func(location, quotaDetails any) bool { - usages := quotaDetails.([]*armcognitiveservices.Usage) - hasS0SkuQuota := slices.ContainsFunc(usages, func(q *armcognitiveservices.Usage) bool { - // The minimum quota for the S0 SKU in Microsoft.CognitiveServices/accounts is 2 capacity units - return *q.Name.Value == "OpenAI.S0.AccountCount" && (*q.Limit-*q.CurrentValue) >= 2 + requirements = append(requirements, ai.QuotaRequirement{ + UsageName: usageDetails.UsageName, + MinCapacity: usageDetails.Capacity, }) - if !hasS0SkuQuota { - // If the S0 SKU quota is not available, skip this location - return true - } + } - // Check if all requested quotas can be satisfied in this location - for _, definedUsageName := range quotaFor { - usageDetails, err := usageNameDetailsFromString(definedUsageName) - if err != nil { - iterationError = fmt.Errorf("parsing quota '%s': %w", definedUsageName, err) - return false - } - hasQuotaForModels := slices.ContainsFunc(usages, func(usage *armcognitiveservices.Usage) bool { - hasQuota := *usage.Name.Value == usageDetails.UsageName - if !hasQuota { - return false - } - remaining := *usage.Limit - *usage.CurrentValue - return *usage.Name.Value == usageDetails.UsageName && remaining >= usageDetails.Capacity - }) - if !hasQuotaForModels { - // If the quota for this model is not available, skip this location - return true - } - } - // If the quota for this model is available, add the location to the results - results = append(results, location.(string)) - return true - }) - if iterationError != nil { - return nil, fmt.Errorf("looking for location with quota: %w", iterationError) + results, err := a.aiModelService.ListLocationsWithQuota(ctx, subId, locations, requirements) + if err != nil { + return nil, fmt.Errorf("getting locations with quota: %w", err) } + if len(results) == 0 { formattedQuota := make([]string, len(quotaFor)) for i, quota := range quotaFor { diff --git a/cli/azd/pkg/prompt/prompt_service.go b/cli/azd/pkg/prompt/prompt_service.go index 6c6c87095e7..f3cdcc644fe 100644 --- a/cli/azd/pkg/prompt/prompt_service.go +++ b/cli/azd/pkg/prompt/prompt_service.go @@ -130,7 +130,7 @@ type ResourceService interface { // SubscriptionManager defines the methods that the SubscriptionManager must implement. type SubscriptionManager interface { GetSubscriptions(ctx context.Context) ([]account.Subscription, error) - ListLocations(ctx context.Context, subscriptionId string) ([]account.Location, error) + GetLocations(ctx context.Context, subscriptionId string) ([]account.Location, error) } // PromptServiceInterface defines the methods that the PromptService must implement. @@ -318,7 +318,7 @@ func (ps *promptService) PromptLocation( if ps.globalOptions.NoPrompt { // Default location always exists (fallback to eastus2), so we can use it // Load locations and find the default - locationList, err := ps.subscriptionManager.ListLocations( + locationList, err := ps.subscriptionManager.GetLocations( ctx, azureContext.Scope.SubscriptionId, ) @@ -345,7 +345,7 @@ func (ps *promptService) PromptLocation( return PromptCustomResource(ctx, CustomResourceOptions[account.Location]{ SelectorOptions: mergedOptions, LoadData: func(ctx context.Context) ([]*account.Location, error) { - locationList, err := ps.subscriptionManager.ListLocations( + locationList, err := ps.subscriptionManager.GetLocations( ctx, azureContext.Scope.SubscriptionId, ) diff --git a/cli/azd/test/mocks/mockaccount/mock_subscriptions.go b/cli/azd/test/mocks/mockaccount/mock_subscriptions.go index 745ec7e024f..60d51a722ae 100644 --- a/cli/azd/test/mocks/mockaccount/mock_subscriptions.go +++ b/cli/azd/test/mocks/mockaccount/mock_subscriptions.go @@ -23,3 +23,8 @@ func (m *MockSubscriptionManager) ListLocations(ctx context.Context, subscriptio args := m.Called(ctx, subscriptionId) return args.Get(0).([]account.Location), args.Error(1) } + +func (m *MockSubscriptionManager) GetLocations(ctx context.Context, subscriptionId string) ([]account.Location, error) { + args := m.Called(ctx, subscriptionId) + return args.Get(0).([]account.Location), args.Error(1) +}